Skip to content
GitHub

Composition Patterns

Previously: Composing Workflows. We learned to orchestrate multi-step operations. Now let’s zoom out to the philosophy that makes it work.


You’re building a user notification system. Requirements come in:

  • Send welcome emails
  • Send password reset emails
  • Send order confirmation emails

Easy. You write sendNotification(type, data) with a switch statement. Ship it.

Three months later:

  • Marketing wants SMS for high-value customers
  • Legal needs audit logs for every notification
  • The fraud team wants to block notifications to flagged accounts
  • Mobile team wants push notifications

Your sendNotification function is now hundreds of lines with nested conditionals. Every new requirement touches every code path. Tests are brittle. Adding Slack notifications means understanding the entire function.

You built a do everything function. Not because you’re a bad developer, but because you didn’t have composition primitives.


Here’s the mindset shift: don’t solve every problem upfront. Build pieces that combine.

The monolithic approach tries to predict all requirements:

async function sendNotification(
  args: { userId: string; message: string },
  deps: { db: Database },
  options: {
    channels?: ('email' | 'sms' | 'push')[];
    audit?: boolean;
    checkFraud?: boolean;
  }
) {
  if (options.checkFraud) {
    const user = await deps.db.getUser(args.userId);
    if (user.fraudFlagged) return;
  }

  if (options.audit) {
    await deps.db.auditLog.create({ userId: args.userId, message: args.message, timestamp: Date.now() });
  }

  for (const channel of options.channels ?? ['email']) {
    if (channel === 'email') { /* 50 lines */ }
    else if (channel === 'sms') { /* 50 lines */ }
    else if (channel === 'push') { /* 50 lines */ }
  }
}

Every new requirement adds another conditional. The function grows unbounded.

The composable approach builds independent pieces that share a uniform interface:

First, define your boundary type once:

// types.ts
type Notification = {
  id: string;
  userId: string;
  email?: string;
  phone?: string;
  subject: string;
  body: string;
};

Then build composable pieces:

// Core functions follow fn(args, deps): sendEmail(args, deps) => Promise<void>
// Factory functions bind deps: createSendEmail(deps) => (args) => sendEmail(args, deps)
// Composed interface used everywhere: SendChannel = (args) => Promise<void>

type SendChannel = (args: { notification: Notification }) => Promise<void>;

// Each channel is independent and focused
const sendEmail = createSendEmail({ emailClient });
const sendSms = createSendSms({ smsClient });
const sendAudit = createSendAudit({ auditDb });

// Compose at startup
const channels: SendChannel[] = [sendEmail, sendSms, sendAudit];

// Fan-out: send to all channels
await Promise.all(channels.map(ch => ch({ notification })));

New channel? Add it to the array. No existing code changes.

graph TD
    subgraph GodFunction["DO-EVERYTHING FUNCTION"]
        M1[sendNotification<br/>hundreds of lines] --> M2[email logic]
        M1 --> M3[sms logic]
        M1 --> M4[push logic]
        M1 --> M5[audit logic]
        M1 --> M6[fraud check]
    end

    subgraph Composable["COMPOSABLE"]
        C1[sendEmail]
        C2[sendSms]
        C3[sendAudit]

        W1[welcomeNotification] --> C1
        W1 --> C3

        W2[highValueNotification] --> C1
        W2 --> C2
        W2 --> C3
    end

    style GodFunction fill:#475569,stroke:#0f172a,stroke-width:2px,color:#fff
    style Composable fill:#64748b,stroke:#0f172a,stroke-width:2px,color:#fff
    style M1 fill:#f87171,stroke:#0f172a,stroke-width:2px,color:#0f172a
    style C1 fill:#cbd5e1,stroke:#0f172a,stroke-width:2px,color:#0f172a
    style C2 fill:#cbd5e1,stroke:#0f172a,stroke-width:2px,color:#0f172a
    style C3 fill:#cbd5e1,stroke:#0f172a,stroke-width:2px,color:#0f172a
    style W1 fill:#94a3b8,stroke:#0f172a,stroke-width:2px,color:#0f172a
    style W2 fill:#94a3b8,stroke:#0f172a,stroke-width:2px,color:#0f172a

The Single Responsibility Principle is easy to agree with and hard to apply.

“Each unit should have one reason to change” sounds nice until requirements arrive. Then we add flags, options, conditionals, and configuration objects.

That’s not accidental.

SRP violations show up as configuration.

  • sendNotification(type, options)
  • if (audit) …
  • if (checkFraud) …
  • if (channel === 'sms') …

Every new requirement adds a new branch. The function accumulates reasons to change.

Composition is how you resolve SRP pressure. It’s how you defer decisions until you actually have information.

Instead of teaching one unit about more cases, you:

  • Split behavior into focused units
  • Give them a uniform interface
  • Combine them at the boundary

This is the same refactoring many of us learned years ago in MVVM: move business logic out of ViewModels into small testable units, then compose them instead of growing the ViewModel.

Different era, same lesson.

SRP isn’t about smaller files. It’s about making change happen by addition, not by rewriting existing code.


Here’s a common problem: you need to send the same notification to multiple destinations. Email, SMS, audit log all at once.

Failure semantics (all-or-nothing vs best-effort) are a composition concern, not a channel concern.

The configuration approach makes the sender know about every destination:

async function sendNotification(
  args: { notification: Notification },
  deps: { emailClient: EmailClient; smsClient: SmsClient; auditDb: AuditDb },
  options: { email?: boolean; sms?: boolean; audit?: boolean }
) {
  if (options.email) await deps.emailClient.send(args.notification);
  if (options.sms) await deps.smsClient.send(args.notification);
  if (options.audit) await deps.auditDb.log(args.notification);
}

Adding Slack? Modify the function. Adding a data warehouse? Modify it again.

The fan-out pattern inverts this. Define a uniform interface, then compose an array:

// types.ts

// Uniform interface: every channel is a function with this shape
type SendChannel = (args: { notification: Notification }) => Promise<void>;

Each channel follows fn(args, deps). The factory functions (createSendEmail, createSendSms, etc.) are implemented functions that bind deps at creation time, returning a function that takes only args. This keeps every channel callable the same way, so arrays and wrappers don’t care which dependencies it needs:

This is just pre-binding dependencies: createSendEmail(deps) returns (args) => sendEmail(args, deps) The core implementation stays fn(args, deps), and the composed interface becomes (args) => ....

// channels/email.ts
type SendEmailDeps = { emailClient: EmailClient };

export function createSendEmail(deps: SendEmailDeps): SendChannel {
  return async (args) => {
    if (!args.notification.email) return;
    await deps.emailClient.send({
      to: args.notification.email,
      subject: args.notification.subject,
      body: args.notification.body,
    });
  };
}
// channels/sms.ts
type SendSmsDeps = { smsClient: SmsClient };

export function createSendSms(deps: SendSmsDeps): SendChannel {
  return async (args) => {
    if (!args.notification.phone) return;
    await deps.smsClient.send({
      to: args.notification.phone,
      message: args.notification.body,
    });
  };
}
// channels/audit.ts
type SendAuditDeps = { auditDb: AuditDb };

export function createSendAudit(deps: SendAuditDeps): SendChannel {
  return async (args) => {
    await deps.auditDb.log({
      notificationId: args.notification.id,
      userId: args.notification.userId,
      subject: args.notification.subject,
      timestamp: Date.now(),
    });
  };
}

Now create a service factory that wires channels together:

// notification-service.ts (service factory)
import { createSendEmail } from './channels/email';
import { createSendSms } from './channels/sms';
import { createSendAudit } from './channels/audit';

type NotificationServiceDeps = {
  emailClient: EmailClient;
  smsClient: SmsClient;
  auditDb: AuditDb;
  slackClient?: SlackClient;
  pushService?: PushService;
};

export function createNotificationService(deps: NotificationServiceDeps) {
  const channels: SendChannel[] = [
    createSendEmail({ emailClient: deps.emailClient }),
    createSendSms({ smsClient: deps.smsClient }),
    createSendAudit({ auditDb: deps.auditDb }),
  ];

  return {
    notify: async (args: { notification: Notification }) => {
      await Promise.all(channels.map(ch => ch(args)));
    },
  };
}

The actual composition root is where you create clients and wire services together:

// main.ts (composition root)
const logger = createLogger(config.logging);
const emailClient = createEmailClient(config.email);
const smsClient = createSmsClient(config.sms);
const auditDb = createAuditDb(config.database);

const notificationService = createNotificationService({
  logger,
  emailClient,
  smsClient,
  auditDb,
});

This keeps “single place knows everything” discipline: main.ts sees all dependencies, service factories stay focused.

Choose your failure semantics. The Promise.all above is all-or-nothing: it fails fast on the first rejection, potentially leaving other sends in-flight. Pick what fits your use case:

// All-or-nothing (fail fast)
await Promise.all(channels.map(ch => ch(args)));

// Best-effort (collect errors, don't fail the whole operation)
const results = await Promise.allSettled(channels.map(ch => ch(args)));
const errors = results.filter(r => r.status === 'rejected');
if (errors.length > 0) {
  // Log or handle partial failures
}

Adding Slack? Write the channel factory, add one line:

// channels/slack.ts
type SendSlackDeps = { slackClient: SlackClient };

export function createSendSlack(deps: SendSlackDeps, destination: string): SendChannel {
  return async (args) => {
    await deps.slackClient.postMessage(destination, `${args.notification.subject}: ${args.notification.body}`);
  };
}
// notification-service.ts (inside createNotificationService, after channels array)
if (deps.slackClient) {
  channels.push(createSendSlack({ slackClient: deps.slackClient }, '#alerts'));
}

The notify function never changes. It doesn’t know about Slack, email, or SMS. It just calls each channel.

ApproachAdding New Destination
Configuration objectModify sender, add option, add conditional
Fan-out with interfaceWrite channel factory, add to array

You have an email channel that works. Now you need retry logic if sending fails, try again with exponential backoff.

The embedded approach modifies the channel:

// ❌ Email logic mixed with retry logic
export function createSendEmailWithRetry(deps: SendEmailDeps): SendChannel {
  return async (args) => {
    for (let i = 0; i < 3; i++) {
      try {
        await deps.emailClient.send(args.notification);
        return;
      } catch (e) {
        if (i === 2) throw e;
        await sleep(1000 * Math.pow(2, i));
      }
    }
  };
}

Now the email logic is mixed with retry logic. What if SMS needs different retry behavior? Copy-paste. What if you want retry + logging? The function explodes.

The wrapping pattern keeps them separate. A wrapper takes a channel and returns a new channel with the same interface. Because the interface is preserved, wrappers stack.

Wrappers are SRP for cross-cutting concerns: retry, logging, and metrics stay out of the channel.

// wrappers/retry.ts
export function withRetry(channel: SendChannel, attempts = 3): SendChannel {
  return async (args) => {
    for (let i = 0; i < attempts; i++) {
      try {
        await channel(args);
        return;
      } catch (e) {
        if (i === attempts - 1) throw e;
        await sleep(1000 * Math.pow(2, i));
      }
    }
  };
}

The original channel stays clean. Retry is a wrapper:

// Original channel - just sends email
const sendEmail = createSendEmail({ emailClient });

// Wrap with retry
const sendEmailWithRetry = withRetry(sendEmail, 3);

Wrappers compose. Want logging too? Inject the logger to stay consistent with fn(args, deps):

// wrappers/logging.ts
type Logger = {
  info: (msg: string) => void;
  error: (msg: string, err?: unknown) => void;
};

export function withLogging(logger: Logger, name: string) {
  return (channel: SendChannel): SendChannel => {
    return async (args) => {
      logger.info(`[${name}] Sending ${args.notification.id}`);
      try {
        await channel(args);
        logger.info(`[${name}] Sent ${args.notification.id}`);
      } catch (e) {
        logger.error(`[${name}] Failed ${args.notification.id}`, e);
        throw e;
      }
    };
  };
}
// Compose: logging wraps retry wraps email
const robustEmail = withLogging(logger, 'email')(withRetry(sendEmail, 3));

Each wrapper is independent. You can mix and match per channel:

const channels: SendChannel[] = [
  withLogging(logger, 'email')(withRetry(sendEmail, 3)),
  withRetry(sendSms, 2),
  withLogging(logger, 'audit')(sendAudit),
];

Let’s rebuild the notification system from the opening. Small pieces, composed.

// types.ts, channels/*.ts, wrappers/*.ts (from earlier)
// notification-service.ts (service factory)
import { createSendEmail } from './channels/email';
import { createSendSms } from './channels/sms';
import { createSendAudit } from './channels/audit';
import { withRetry } from './wrappers/retry';
import { withLogging } from './wrappers/logging';

type NotificationServiceDeps = {
  emailClient: EmailClient;
  smsClient: SmsClient;
  auditDb: AuditDb;
  logger: Logger;
  slackClient?: SlackClient;
  pushService?: PushService;
};

export function createNotificationService(deps: NotificationServiceDeps) {
  const sendEmail = createSendEmail({ emailClient: deps.emailClient });
  const sendSms = createSendSms({ smsClient: deps.smsClient });
  const sendAudit = createSendAudit({ auditDb: deps.auditDb });

  const channels: SendChannel[] = [
    withLogging(deps.logger, 'email')(withRetry(sendEmail, 3)),
    withLogging(deps.logger, 'sms')(withRetry(sendSms, 2)),
    withLogging(deps.logger, 'audit')(sendAudit),
  ];

  return {
    notify: async (args: { notification: Notification }) => {
      await Promise.all(channels.map(ch => ch(args)));
    },
  };
}

Three months later, marketing wants push notifications:

// channels/push.ts
type SendPushDeps = { pushService: PushService };

export function createSendPush(deps: SendPushDeps): SendChannel {
  return async (args) => {
    await deps.pushService.send({
      userId: args.notification.userId,
      title: args.notification.subject,
      body: args.notification.body,
    });
  };
}
// notification-service.ts (inside createNotificationService, after channels array)
if (deps.pushService) {
  const sendPush = createSendPush({ pushService: deps.pushService });
  channels.push(withLogging(deps.logger, 'push')(withRetry(sendPush, 2)));
}

No existing code changed. The notification service doesn’t know push exists. It just calls each channel.


  1. Build small, focused pieces. Each function does one thing. createSendEmail creates an email sender. withRetry adds retry. Don’t combine them.

  2. Use uniform interfaces. When everything implements the same interface (SendChannel), you can compose them freely. Arrays, wrappers, conditionals all work.

  3. Wrap, don’t embed. Need retry? Wrap it. Need logging? Wrap it. The original function stays clean.

  4. Compose at startup. Wire pieces together in your composition root. Business logic doesn’t know how pieces are composed.

Failure semantics (retries, escalation, partial failure) are layered on top. See Resilience Patterns.


These patterns aren’t arbitrary. They embody principles that make code maintainable:

Open/Closed Principle. Adding push notifications? Write createSendPush, add it to the array. The notify function never changes. Systems are open for extension (new channels) but closed for modification (existing code untouched).

Single Responsibility. Each piece does one thing. createSendEmail sends email. withRetry adds retry. withLogging adds logging. When retry logic needs to change, you change one function, not every channel.

Dependency Inversion. The notify function depends on SendChannel, not on EmailClient or SmsClient directly. The policy is “broadcast to channels”; the channels themselves are details supplied at wiring time. Both policy and details depend on the abstraction (SendChannel), not on each other.

Liskov Substitution. Any SendChannel can replace another. Wrappers return SendChannel, so withRetry(sendEmail) is substitutable wherever sendEmail was used. This is why wrappers stack. Each layer preserves the contract.

The patterns come first. The principles explain why they work.

This chapter is part of a consistent architecture:

  • Validation guards the boundary. Inputs are parsed before they reach functions
  • Typed Errors make failure explicit. Results instead of exceptions
  • Workflows orchestrate multi-step operations with compensation
  • Composition (this chapter) builds extensible pieces that combine
  • Observability wraps functions without polluting them
  • Resilience adds retries at the workflow level, not inside functions

Each concern shifts outward to its proper layer, keeping core functions focused on business logic only.


We’ve seen how composition enables extensibility. Small pieces combine into complex behavior.

But there’s something we’ve glossed over: when things go wrong inside these composed pieces, how do you see what happened? How do you trace a notification through email → retry → logging?


Next: Observability with OpenTelemetry. Making execution visible without cluttering business logic.