Hierarchies in Backend Design · Lesson 5
Lesson 2 said cross-level rules are enforced "eventually, by events and reconciliation". That was a promissory note. This lesson pays it.
Carried in: aggregate boundaries and the budget rule that spans them (L2) · the outbox line you were shown without explanation in L2 Ex 3 and L4 · single source of truth vs derived data (L1 §5).
New here: the dual-write problem · the transactional outbox and its relay · at-least-once delivery and idempotent consumers · per-aggregate ordering, and the specific way hierarchies break it · compensating actions and reconciliation.
You need to do two things: change your database, and tell someone else. Publish the campaign, and call Meta. Mark the subtree deleted, and update the search index. There is no transaction spanning both resources, so pick your poison:
| Order | Failure between them | Result |
|---|---|---|
| Commit, then publish | Process dies after commit | Database says published; Meta never heard. Lost event. |
| Publish, then commit | Transaction rolls back | Meta has ads for a campaign that does not exist. Phantom event. |
This is the dual-write problem, and the important thing to internalise is that there is no
ordering that fixes it. Retries don't fix it either — a retry cannot run if the process is gone. Any code shaped like
"await db.commit() … await somethingElse()" has this bug, and it is invisible in testing
because it needs a crash in a specific window.
The transactional outbox reduces two resources to one. Write the state change and the intent-to-publish into the same database, in the same transaction. A separate process — the relay — reads the outbox table and publishes.
outbox( id uuid PRIMARY KEY, -- the dedup token for consumers seq bigserial, -- relay ordering aggregate_id uuid NOT NULL, -- partition key: per-aggregate ordering topic text NOT NULL, body jsonb NOT NULL, published_at timestamptz) -- NULL = not yet relayed
Atomicity is now the database's problem, which it is good at.
The relay can be polling
(WHERE published_at IS NULL ORDER BY seq — simple, adds latency and load) or
change data capture (tail the write-ahead log with something like Debezium — lower latency, more
infrastructure). Start with polling.
Notice what this is: the outbox row is derived data whose truth is the state change — the
same relationship as path to parent_id in Lesson 1 §5. That is why losing the broker is
survivable and losing the transaction is not.
The relay can publish and then die before marking the row — so the row is published again. Two relay instances can read the same batch. This gives you at-least-once delivery, and you should stop hoping for exactly-once: it isn't available across a network without making the consumer idempotent anyway, at which point at-least-once plus idempotency is the implementation.
So every consumer needs a dedup mechanism, keyed on the event id. The standard one is the inbox pattern — a table of processed event ids, written in the same transaction as the consumer's own state change:
BEGIN; INSERT INTO processed_events (event_id) VALUES ($1); -- PK violation ⇒ already handled, roll back -- … apply the effect … COMMIT;
Same shape as the idempotency key from Lesson 3 §4: a unique constraint is what makes check-then-act safe. Third appearance of that idea; it is the single most reusable thing in this path.
Global ordering across a broker is expensive and almost never needed. Per-aggregate ordering usually
is: NodeMoved then NodeDeleted applied in reverse leaves a deleted node visibly moved. Get it by
partitioning on aggregate_id so one aggregate's events land in one ordered partition, and relaying in
seq order.
Hierarchies add a failure mode that per-aggregate ordering does not cover, because each node is its own
aggregate: a child's event can arrive before its parent's. Your search-index consumer receives
NodeCreated(child, parentId: p) when it has never heard of p. Three responses, in
increasing order of quality:
The third is the same relaxation of referential integrity your original notes proposed for the write path in Lesson 2 §3 — except here it is not optional. A consumer of a hierarchy's events must tolerate temporarily dangling parent references, because there is no transaction spanning the producers. If you were looking for the honest answer to "should we allow violations of referential integrity?", it is: inside one aggregate, never; across aggregates, you have no choice.
Back to Lesson 2's example: the sum of ad set budgets must not exceed the campaign budget, with Campaign and AdSet as separate aggregates. The rule cannot be a transactional invariant. So it becomes a pipeline:
AdSetBudgetChanged → handler recomputes the campaign's total
→ if over: emit CampaignOverBudget, set campaign.flagged = true,
pause delivery on the newest ad sets ← compensating action
A compensating action is the deliberate response to a violation you allowed to happen. Its existence is the price of the boundary, and specifying it is the design work people skip. Three questions you must answer before shipping an eventually-consistent rule — and if you cannot answer them, the rule is a true invariant after all and belongs inside one aggregate:
Events get dropped, consumers get deployed with bugs, brokers get purged during incidents. Every eventually-consistent system needs a periodic reconciliation sweep that recomputes the derived state from the source of truth and repairs the difference — and, importantly, reports the size of the difference, because a drift metric trending upward is your only early warning that the pipeline is broken.
For hierarchies the sweep is usually cheap and worth having on day one: recompute every node's
path from parent_id, rebuild the closure table, re-derive each campaign's budget total. All of
it is possible precisely because Lesson 1's rule was obeyed — the derived structures are rebuildable from a truth the
database enforces. The outbox, the search index, the read model and the closure table are all the same kind of thing,
and reconciliation is the same job for each.
If you only keep one sentence from this path: choose one source of truth the database enforces, and make everything else rebuildable from it. Storage models, aggregate boundaries, outboxes and reconciliation are all consequences of taking that seriously.
async function publishCampaign(user: User, id: string) { const campaign = await db.campaign.findFirstOrThrow({ where: { id, tenantId: user.tenantId } }); await assertValid(campaign); await db.campaign.update({ where: { id }, data: { status: 'PUBLISHED', publishedAt: new Date() } }); await meta.publishCampaign(campaign); await searchIndex.upsert(campaign); return { ok: true };}
Line 5. The dual write. Line 4 has committed, so if the process dies, or Meta times
out, or the deploy rolls the pod, the database permanently claims PUBLISHED and Meta has nothing. Line 6
is the same bug against a second system, so it compounds: a partial failure can leave the database, Meta and the search
index in three different states with no record of which succeeded.
The fix is not a try/catch or a retry — a retry needs a live process, and the failure case is that
there isn't one. Write an outbox row inside the same transaction as line 4 and let the relay call Meta, with
meta.publishCampaign made idempotent (Meta's own idempotency key, or a stored external id checked first).
A quieter defect: assertValid at line 3 reads descendants that belong to other aggregates,
so its result can be stale by line 4 — exactly the point from Lesson 2 that a validation gate is not an invariant. It
must be re-checked by the publisher, not just at the API edge.
The polling relay.
async function relayBatch() { await db.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('outbox-relay'))`; const batch = await tx.outbox.findMany({ where: { publishedAt: null }, orderBy: { seq: 'asc' }, take: 100 }); for (const ev of batch) { await broker.publish(ev.topic, { key: ev.aggregateId, id: ev.id, body: ev.body }); await tx.outbox.update({ where: { id: ev.id }, data: { publishedAt: new Date() } }); } });}
No bug — and the point is learning to recognise deliberate at-least-once
delivery instead of reporting it as a defect. Yes: if the process dies between lines 7 and 8, the event republishes.
That is the contract, not a bug, and it is why consumers dedupe on ev.id. The advisory lock on line 3
means only one relay drains at a time, so per-aggregate seq order survives multiple instances, and
key: ev.aggregateId preserves that order into the broker's partitioning.
Legitimate review comments, none of them correctness bugs: a poison message blocks the queue head forever, so you want an attempt counter and a dead-letter path; there is no backoff on broker failure; publishing inside the transaction holds the lock for the duration of 100 network calls, which caps throughput; and nothing prunes published rows. Being able to separate "this is the design" from "this is broken" is the reviewing skill — the same distinction you made in Lesson 1 Ex 4.
From memory: why does no ordering of "write to the database" and "call the external system" fix the dual-write problem? Then state what the outbox actually changes about the situation.
Because there are two resources and no transaction spanning them, so there is always a window between the two operations in which a crash leaves them disagreeing. Commit-then-publish loses events; publish-then-commit creates phantoms. Retries don't help, because the failure mode is that no process survives to retry.
The outbox reduces the problem to one resource: state and intent are written atomically to the same database. The two-resource step still exists — it has moved into the relay, where the worst outcome is publishing twice rather than losing or fabricating. Duplicates are recoverable by an idempotent consumer; lost and phantom events are not.
Specify the enforcement of "sum of ad set budgets ≤ campaign budget" with Campaign and AdSet as separate aggregates. Answer all four: (a) what triggers the check, (b) the compensating action, (c) the window and what a user sees during it, (d) how you detect the pipeline silently failing.
(a) An AdSetBudgetChanged event (and CampaignBudgetChanged,
which people forget — the rule can be broken from either side) triggers a handler that recomputes the campaign total.
Not a database trigger: it must run outside the writing transaction, or you have re-created the wide aggregate.
(b) Flag the campaign and pause delivery on the most recently changed ad sets, then notify. Pausing is reversible; refusing the write is not available to you here, and deleting the offending ad set would destroy user work over a rule the system chose to enforce late.
(c) A number — say p99 under 5 seconds. During the window the campaign page shows the computed total with a "recalculating" state rather than a green tick, so the UI never asserts a guarantee the backend isn't making. If the business answer is "an over-budget campaign must never be deliverable, not for one second", then the rule is a true invariant and Campaign and AdSet belong in one aggregate — accept the contention from Lesson 2 §2.
(d) A reconciliation job that recomputes every campaign's total from the source of truth, repairs drift, and emits the count of repairs as a metric. Zero is the expected value; a non-zero trend is the alarm. Without that metric the pipeline can be broken for weeks and look healthy, which is the characteristic failure of event-driven designs.
await db.commit() followed by await
externalCall() is a dual write. That grep is worth running on real codebases.Previous: Lesson 4 — The move endpoint
Now unlocked: permission inheritance over a tree · DAGs and multi-parent hierarchies (when a tree is a lie).
Map: STATUS · Sources: RESOURCES · Why: MISSION
Grounded in: Richardson, "Transactional outbox" (microservices.io); AWS Prescriptive Guidance on the outbox pattern; Vernon, Effective Aggregate Design Part II.
Feedback on this lesson