Something happens once, and several places need to know about it. A new order comes in: Slack should cheer, the CRM needs the record, the warehouse wants the row, and maybe the public channel wants a quiet bit of social proof. One thing happened, and four different places care about it in four different ways.
So you wire it. Each destination gets a line in the handler, you ship it, and it works the first day. The trouble doesn't show up at the start — it shows up three destinations later, when the handler has quietly become the most important function nobody decided to write.
the function that grows a line every quarter
It almost always looks like this:
// one event, four destinations, all wired by hand
async function onOrderCreated(order) {
await slack.post("#revenue", order.email + " paid " + order.amount);
await discord.post("#wins", "someone just upgraded 🎉");
await fetch(CRM_URL, { method: "POST", body: JSON.stringify(order) });
await db.insert("events.orders", order);
}
// add a destination → edit this function. reshape one → hunt for the line.
// the routing for your whole business ends up living in functions like this.Every await is its own little integration with its own opinion about what to send. Adding a destination means editing this function and redeploying the service it happens to live in. And this isn't the only one — there's a near-identical block in the signup handler, one in a cron, one in the Stripe webhook. The logic that decides where your events go is real, load-bearing routing, and it's scattered across a dozen functions where no single place can tell you what reaches what.
every destination wants a different shape
The harder part isn't the number of destinations, it's that each one wants the event differently. Slack wants a short, human cheer with the real numbers in it. The public channel wants the opposite — a win worth sharing, but no customer name and no dollar amount. The CRM wants the entire record, untouched. The warehouse wants the complete row and wants it forever. Same event, four shapes.
In the handler, that shaping is four ad-hoc string builds inlined next to four awaits, and they drift apart the moment two people touch them. Then a condition creeps in — you don't actually want Slack pinging on every order, only the ones above some amount — so now there's an if in there too. The function is becoming a routing engine written by accident, one requirement at a time.
make the routing the thing, not a side effect
The fix is to stop expressing fan-out as control flow buried in a handler and describe it directly instead. One source. A couple of shared steps every destination benefits from — collapse the checkout retry that fires the event twice, resolve the company behind the email once so nobody downstream has to. Then a list of destinations, each carrying its own shape and its own condition, sitting right next to the place it describes.
Read top to bottom, that's the whole routing story for the event: here's what comes in, here's what each place gets, and here's when. It isn't reconstructed from whichever functions happen to call out — it's one description you can actually look at.
one event, four shapes
Here's the whole thing on an order event — as the diagram you'd see in the app, or the YAML behind it. Flip between them.
representation
01source
02pipeline · 2 steps
- 01CTLdedupekey $event.order_id · within 24h
- 02ENRenrich.company$event.email → company
03destinations · 4
- toslackSlackchannel#revenuewhen$event.amount >= 100
- todiscordDiscordchannel#wins
- towebhookWebhookurl$env.CRM_URL
- towarehouse.pgPostgrestableevents.orders
Two shared steps up front: dedupe so a retried checkout is one order and not two, and enrich.company once, so every destination downstream can use the company name without resolving it again. Then the event fans out four ways, each shaped for its room.
The #revenue channel only hears about orders over a hundred, and gets the company and the real amount. The public #wins channel gets a cheer with no name and no number in it — same event, deliberately less. The CRM webhook gets the full record, untouched. Postgres keeps every order, complete. One event in, four bodies out, and the shaping lives beside the destination it belongs to instead of smeared through a handler.
how ingestlayer does this
Everything above is real. Each destination is first-class — Slack, Discord, a webhook, Postgres — and the same event fans out to all of them at once, each with its own template and its own when. The shared steps run once before the split, so an enrichment four destinations want is paid for a single time.
It's the same shape as the rest of the pipe. The noise moves decide whether an event reaches a place; per-destination redaction decides what it's allowed to carry when it does. Fan-out is the plainest question of the three — where does this one event go, and what does each place get — and answering it in one description instead of a dozen scattered handlers is the difference between routing you can read and routing you reconstruct. There are other use cases, but one event reaching every place it should is the one you hit first.