Architecture · Guide ·

How to add a fourth channel without building a fourth pipeline

Multi-channel messaging systems reliably grow one pipeline per channel. Not because anyone decides to — because the differences between providers are loud and visible, and everything the channels share is quiet and abstract.

[ 01 — The drift ]

How you end up with four of everything

Nobody sits down and designs four parallel messaging pipelines. You get there one reasonable decision at a time.

You start with email, because everyone starts with email. It needs a way to resolve an audience, schedule a send, pace itself so the provider doesn't throttle you, retry the failures, and collect delivery status from a webhook. You build that. It works.

Then someone sells WhatsApp. WhatsApp has template approval, a completely different payload shape, its own auth, its own rate limits, and a status webhook with different names for similar things. You look at the email pipeline, you look at WhatsApp, and the honest engineering assessment is: this is not the same thing. So you build a second pipeline.

That assessment is wrong, but it is wrong in a way that is genuinely hard to see at the time. Here is the asymmetry that causes it:

What differs between channels is concrete, visible, and in the API docs in front of you. What they share is abstract, invisible, and only becomes obvious on the third one.

Payload shape is a thing you can see. "Retry policy" is not a thing you can see. So the boundary gets drawn around the visible difference — the channel — instead of around the actual concern. Then SMS arrives and you do it a third time, because now there is a precedent, and matching the precedent is the conservative choice.

Why the cost is worse than 4×

Duplicated infrastructure is usually priced as "we wrote it four times." The real bill is larger and arrives later:

Broadcast delivery, before and after consolidation Before: WhatsApp, email and SMS each run their own schedule, retry and status steps before reaching a provider, and adding RCS would mean building all three again. After: all four channels feed one shared Step Functions orchestration that owns scheduling, fan-out, retry, idempotency, status and audit, then pass through a thin channel adapter — payload shape, provider auth and webhook parsing — to their providers. The adapter is the only new code a channel needs. BEFORE WhatsApp schedule retry status provider Email schedule retry status provider SMS schedule retry status provider RCS a new channel means building all three again 3 scheduling implementations · 3 retry policies · 3 status pipelines · nothing shared AFTER WhatsApp Email SMS RCS Step Functions orchestration schedule · fan-out retry · idempotency status · audit Channel adapter payload shape provider auth webhook parse the only new code provider provider provider provider 1 scheduling implementation · 1 retry policy · 1 status pipeline · adding a channel is an adapter
Four per-channel pipelines collapse into one orchestration plus a thin adapter per channel. The dashed RCS row is the whole argument: on the left, a fourth channel means building schedule, retry and status a fourth time. On the right it means writing an adapter.
[ 02 — The boundary ]

Drawing the boundary in the right place

The fix is not "abstract the channels." It is to sort every responsibility in the system into one of two buckets, and be strict about it: does this concern the lifecycle of a broadcast, or does it concern talking to one specific provider?

Orchestration — one implementation
  • Audience resolution and segmentation
  • Scheduling and time-zone handling
  • Fan-out and batching
  • Rate pacing
  • Retry policy and backoff
  • Idempotency per recipient
  • Status aggregation
  • Suppression and consent
  • Audit trail
Adapter — one per channel
  • Payload construction
  • Provider authentication
  • Provider-specific limits
  • Webhook parsing
  • Mapping provider status → your status
  • Channel constraints (templates, length, capability)
The left column is the system. The right column is a translation layer. Almost every mistake in this design is something from the left column leaking into the right — where it immediately becomes four things again.

Notice what is not in the right-hand column. Retry is not there. Scheduling is not there. Idempotency is not there. Those feel channel-specific when you are elbow-deep in a provider's API, because that provider has opinions about all three — but the opinion is a parameter, not a policy. WhatsApp's rate limit is a number the orchestration should be given, not a reason for WhatsApp to own its own pacing loop.

[ 03 — The core ]

What the orchestration owns

A broadcast is a long-running process. It can run for hours, it must survive deploys, and it has to be resumable from wherever it was when something failed. That rules out "a loop in a worker" almost immediately, and points at durable orchestration — a managed workflow engine, or a state machine you own with checkpoints in a database.

The lifecycle it drives is the same for every channel:

// Identical for email, SMS, WhatsApp, RCS, and whatever is next.
resolve audience      → who, minus suppression and consent
fan out               → batches sized for the provider
pace                  → against that provider's rate limit
attempt               → hand one batch to the adapter
record                → per-recipient outcome, idempotently
retry                 → bounded, only what is retryable
finalise              → aggregate, close out, emit audit

Idempotency belongs here, not in the adapter

Anything durable and resumable will re-enter. A workflow retries a step, a deploy interrupts a run, an operator replays a failed broadcast. Every one of those can hand the same recipient to the adapter twice.

The guard is a per-recipient, per-broadcast key checked before the send, in the orchestration. Put it in the adapter and you have written it four times — and the fourth one will be subtly different, and it will be the one that double-charges someone's SMS bill.

Pacing belongs here too

Fan-out without pacing is how you discover a provider's rate limit in production. The orchestration should hold the token bucket, because it is the only component that knows how many batches are in flight across the whole broadcast. An adapter only ever sees the batch it was handed, so an adapter that tries to self-pace is guessing.

[ 04 — The edge ]

What the adapter owns

An adapter should be boring, small, and stateless. It converts your internal representation into a provider's, makes the call, and converts what comes back into your vocabulary. That is the entire job.

// The contract, in whichever type system you have.
interface ChannelAdapter {
  send(batch: Recipient[], content: Content): Promise<AttemptResult[]>;
  parseWebhook(raw: unknown): StatusEvent[];
  limits(): { batchSize: number; perSecond: number };
}

The third method is the one people leave out, and it is what keeps pacing in the orchestration while still respecting per-provider constraints. The adapter declares its limits; the orchestration enforces them. Policy in one place, parameters at the edge.

AttemptResult and StatusEvent are your types, not the provider's. This matters more than it looks. The moment a provider's status string reaches anything downstream — a dashboard, a report, a database column — you have re-coupled to that provider, and swapping it becomes a migration instead of a config change.

[ 05 — Failure modes ]

Four ways the split leaks

Getting the boundary right once is easy. Keeping it right while shipping is the actual work, and it fails in a small number of recognisable ways.

LeakHow it startsWhat it costs
Retry in the adapter One provider has a flaky endpoint, so someone adds a quick retry loop inside its adapter. Two retry policies now compose unpredictably. Attempt counts stop meaning anything.
Provider status leaking out A dashboard needs a detail your normalised status doesn't carry, so the raw code gets passed through. Every consumer of that field is now coupled to that provider.
Channel branches in the core if (channel === "whatsapp") in the orchestration, once, for a deadline. It is never once. This is the pipeline growing back inside the shared code.
Non-durable orchestration The workflow holds state in memory because it's simpler and broadcasts are "usually quick". A deploy mid-broadcast either drops recipients or re-sends them.
The tell

When someone asks for a cross-channel feature — global suppression, a unified delivery report, one rate limit per tenant — count how many files change. One means the boundary held. Four means it didn't, and the architecture diagram is now fiction.

[ 06 — In production ]

What this looked like in production

The version of this I worked on had four channels — WhatsApp, email, RCS and SMS — and, predictably, four sets of scheduling, retry and status-tracking code. Four implementations of the same three problems, four sets of bugs, four places to change anything.

We consolidated them onto shared AWS Step Functions orchestration, with a thin adapter per channel: one workflow owning the lifecycle above, Redis-backed segment delivery for fan-out, and a webhook-driven status pipeline normalising provider callbacks into one stream.

The part that proved it worked

RCS launched on that infrastructure rather than beside it. It needed an adapter — payload shape, auth, webhook parsing — and nothing else. No scheduler, no retry policy, no status pipeline, because those already existed and were channel-agnostic. It now carries 2.5M messages a month for 50+ enterprise clients.

That is the whole argument for this design, and it is worth being precise about what the achievement actually is. Shipping RCS is not interesting; it is a feature, and someone was always going to build it. What is interesting is that shipping it required writing an adapter instead of a pipeline — which is the difference between a channel costing a sprint and a channel costing a quarter.

The consolidation is also the part that would never appear on a roadmap. It shipped no user-visible feature on the day it landed. It only paid out later, on the next channel, which is the general shape of infrastructure work and the general reason it is hard to get prioritised.

[ 07 — The takeaway ]

The only test that matters

You can evaluate any multi-channel design with one question, and you do not need to see the code to ask it:

When you add the next channel, what do you have to build?

If the answer is "an adapter" — payload, auth, webhook parsing, declared limits — the boundary is in the right place. If the answer includes a scheduler, a retry policy, or a status pipeline, then you do not have a multi-channel system. You have several single-channel systems that share a repository, and the fifth one will cost exactly what the fourth one did.

The related failure, in a different part of the same platform: running Pub/Sub in a microservice system, where the shared thing that everyone forgot about was the downstream, not the scheduler.