Hierarchies in Backend Design · Lesson 2
Lesson 1 was about rows. This one is about the two questions you left blank: what belongs in one consistency boundary, and what you are actually trading away when you let the client build the tree.
You said: campaign / ad set / ad should be three aggregates, because validation happens before publish and a validator can gate it.
Right answer, wrong reason — and the wrong reason is the interesting part. You described a state machine. The thing that decides an aggregate boundary is an invariant. Separating those two is the whole lesson.
An aggregate is a cluster of objects treated as one unit for data changes, with one aggregate root as its only entry point. Its defining property is that it is a consistency boundary: every rule inside it is true at the end of every transaction, with no window in which it is false.
So the deciding question is never "are these things related?" — everything in a hierarchy is related, that's what makes it a hierarchy. The question is:
Which rules must never be observably false, not even for 200 ms?
In the order they bite:
adSetId, not an AdSet
object. This is what keeps one transaction from dragging in the graph.Try the rules. Is there any rule that must hold at every commit and spans the levels?
This is the move to take into a design review: make someone state the rule, then ask what happens in the 200 ms where it's violated. "We show an error" → one aggregate. "We reconcile" → two aggregates and an event. Nine times out of ten it's the second, and the person who wanted one giant aggregate hadn't noticed.
Your validator gate is a real thing, but it is within the Campaign aggregate: a status field
(draft → validating → published) with a rule "cannot enter published unless every descendant
passed validation". Note the shape of that rule — it reaches down across aggregates, so it cannot be a
transactional invariant. It is a check performed at a moment, on data that may be stale by the time the publish lands.
Which is exactly why publish-to-Meta is a job with retries and a reconciliation pass, not a transaction.
Put a campaign and its 10 000 ads in one aggregate. Now the campaign root is the unit of change, which means it
carries the version used for optimistic concurrency control — the pattern where a write
includes the version it read (WHERE id = $1 AND version = $2) and fails if someone else committed first.
-- two users each edit a different ad, in the same campaign, at the same time UPDATE campaign SET version = 4 WHERE id = 'c1' AND version = 3; -- user A: 1 row, commits UPDATE campaign SET version = 4 WHERE id = 'c1' AND version = 3; -- user B: 0 rows, rejected
User B edited an unrelated ad and got a conflict. Nothing was actually in contention; the aggregate boundary invented the contention. Under load this degrades to a retry storm: the bigger the aggregate, the more writers collide on one version, and the collision rate grows with traffic while the useful work does not.
Generalise: an aggregate boundary is a lock scope. Every entity you pull inside it is an entity whose writers now serialise against every other writer in the boundary. "Design small aggregates" is a throughput statement wearing modelling clothes.
Now the question your notes were circling. To create a three-level tree, there are two shapes.
Server-assigned identity forces strictly top-down, sequential round trips. Each request needs the previous response. Three levels and twenty ads is twenty-three serial calls, and if the client dies at call twelve you have a half-built campaign in the database that no user asked for. Referential integrity — the guarantee that every foreign key points at a row that exists — is never violated. But aggregate integrity is violated constantly: the intermediate states are all invalid campaigns.
Client-generated identity (the client mints UUIDv7s) buys three things that are hard to get any other way:
INSERT … ON CONFLICT DO NOTHING makes the retry a no-op. With server IDs you need a separate
idempotency key to get the same property.Here is what you get in exchange, which is the part worth memorising: the client now supplies values you previously trusted the database to have produced. Specifically —
| Bug class | What it looks like | Where you defend |
|---|---|---|
| Cross-tenant parent | Client sends parentId belonging to another
customer's tree; the FK is satisfied, so nothing complains, and now their node is in your tree |
Application: verify every referenced pre-existing ID belongs to the caller's tenant. A foreign key cannot check this. |
| Cycles | Client sends A→B and B→A. Every FK satisfied. Your traversal never terminates. | Application or a check on the derived path/closure. Lesson 1's path <@ path trick. |
| Insert ordering | The payload lists a child before its parent, so the FK fails on a tree that is perfectly valid as a whole | Database: DEFERRABLE INITIALLY DEFERRED, or
topologically sort before inserting. |
| ID collision / guessability | Client mints a sequential or colliding ID | Require UUID format server-side; never let a client-supplied ID overwrite an existing row. |
The ordering one has a clean answer that is worth knowing by name, because it dissolves most of the "allow dirty state?" question:
ALTER TABLE node ADD CONSTRAINT node_parent_fk FOREIGN KEY (parent_id) REFERENCES node(id) DEFERRABLE INITIALLY DEFERRED;
Postgres now checks the constraint at COMMIT rather than per statement. Inside the transaction, rows may
temporarily reference parents that don't exist yet; at commit, the whole graph must be sound. You get order-independent
bulk insert without relaxing the guarantee. Note the trade you are making: constraint violations now surface at
commit, so the error tells you the transaction failed but not which statement caused it — worse diagnostics for better
ergonomics.
This is the general shape of the answer to "where do we enforce invariants?": push each one to the outermost layer that can actually see what it needs. Tenant ownership needs the caller's identity → application. "Parent exists" needs only rows → database. "Sum of budgets" needs multiple aggregates → an event handler and a reconciliation job. Rules enforced in the wrong layer are either unenforceable or accidentally quadratic.
Deleting a folder deletes its contents. Deleting a file does not delete its folder. The asymmetry is not arbitrary — a child's existence depends on its parent's, so cascade follows the dependency, and dependency in a hierarchy points up. The rare exception is when the parent exists only to hold children (an empty auto-created group), and even then prefer a cleanup job to a cascade.
ON DELETE CASCADE is the wrong product semanticsUsually not, for four reasons:
DELETE can destroy 50 000 rows with no count
returned to the user and no confirmation step. "Delete folder?" should say how much.deleted_at stamped across the subtree in one
UPDATE … WHERE path <@ $1 — restores with one statement.Keep ON DELETE CASCADE on derived tables — the closure table from Lesson 1 is the perfect
candidate, since it has no independent meaning. Use explicit, chunked, soft deletes for anything a user can see.
Two failure modes bracket the read side. Per-node reads (GET /nodes/{id}, client walks) reproduce
Lesson 1's N+1 over HTTP, where each hop costs a round trip instead of a query. Whole-graph reads
(GET /campaigns/{id}?expand=all) are unbounded — one customer with 100 000 ads makes that endpoint a
denial-of-service you built yourself. The shape that survives is a bounded subtree read: an explicit
depth parameter, a page size, and a documented maximum. Then one recursive CTE or one prefix scan serves it.
From memory: state the question that decides whether two entities belong in the same aggregate. Then state what goes wrong if you get it wrong in the "too big" direction — be specific about the mechanism.
Question: is there a business rule spanning them that must be true at the end of every transaction — never observably false? If yes, one aggregate. If the rule can be reconciled later, two.
Too big fails through concurrency, not size: the root's version becomes the optimistic-concurrency token for every entity inside, so unrelated writers collide and retry. The boundary manufactures contention that the domain does not have.
A bulk tree import with client-generated IDs. Deferrable FKs are in place, so ordering is handled.
async function importTree(tenantId: string, nodes: NodeInput[]) { return db.$transaction(async (tx) => { for (const n of nodes) { assertUuid(n.id); await tx.node.create({ data: { id: n.id, tenantId, parentId: n.parentId, name: n.name }, }); } });}
Line 6. parentId is written straight through from client input with no
check that it belongs to tenantId — or to this import at all. A caller who knows (or guesses) another
tenant's node id can graft their subtree under it. The foreign key is satisfied, so no error is raised; this is a
tenancy rule and no constraint on node(id) can express it.
The fix is a rule the database can hold — make the reference include the tenant:
UNIQUE (tenant_id, id); FOREIGN KEY (tenant_id, parent_id) REFERENCES node(tenant_id, id) DEFERRABLE INITIALLY DEFERRED;
Now a cross-tenant parent is impossible rather than merely checked. Second defect worth naming: the
serial await in the loop is one round trip per node — the same N+1 as Lesson 1, wearing a different hat.
Use createMany. Third: no ON CONFLICT DO NOTHING, so the retry that client-generated IDs
were supposed to make safe throws a duplicate-key error instead.
Soft-deleting a subtree in a materialised-path design.
async function deleteSubtree(tenantId: string, id: string) { return db.$transaction(async (tx) => { const node = await tx.node.findFirstOrThrow({ where: { id, tenantId } }); const { count } = await tx.$executeRaw` UPDATE node SET deleted_at = now() WHERE tenant_id = ${tenantId} AND path <@ ${node.path} AND deleted_at IS NULL`; await tx.outbox.create({ data: { type: 'SubtreeDeleted', nodeId: id, count } }); return count; });}
No bug — this is the shape to copy. Five things it gets right, each of which is a
finding when absent: the lookup is scoped by tenant; path <@ covers the whole subtree in one statement
instead of a recursive walk; deleted_at IS NULL makes it idempotent, so a retry marks nothing and returns
0; the count comes back so the UI can say how much; and the domain event goes into an outbox table
inside the same transaction, so the event cannot be lost if the process dies after commit — which is what
ON DELETE CASCADE can never give you.
The one thing you might legitimately raise in review: for a very large subtree this is a single long transaction holding many row locks. At scale you would chunk it and make the whole operation resumable. That is a scaling refinement, not a correctness bug.
A customer wants to import a 5 000-node campaign structure from a spreadsheet, and see it in the UI before it goes live on Meta. Specify: (a) how identity is assigned, (b) how many HTTP calls, (c) where "sum of ad set budgets ≤ campaign budget" is enforced, (d) what happens if the request is retried.
(a) Client-generated UUIDv7 for every node, validated server-side for format and non-collision. The spreadsheet parse happens client-side, so the tree exists before any call.
(b) One: a command endpoint (POST /campaigns:import) taking the node
list. Not 5 000 nested REST creates — that is 5 000 serial round trips and 4 999 possible half-built states. This is
the case where a domain command beats resource-per-row REST, and AIP-121 explicitly sanctions a custom method here.
(c) Two different places, because it is two different rules. Import-time: a validation pass that rejects the whole import with per-row errors — this is user-facing feedback, not an invariant. Publish-time: re-checked as part of the publish job, because the campaign budget may have changed between import and publish. Since the rule spans aggregates it can never be a transactional invariant; if you find yourself trying to make it one, you are about to merge Campaign and AdSet and inherit §2's contention.
(d) Nothing new happens. Same client IDs + ON CONFLICT DO NOTHING makes
the retry a no-op returning the same result. This property is the main practical reason to let the client mint IDs,
and it is the one people forget to claim.
Previous: Lesson 1 — Four ways to store a tree
Unlocked next (say the word and I'll write it): eventual consistency between aggregates via the outbox pattern · designing the move/reparent endpoint · permission inheritance over a tree.
Map: STATUS · Sources: RESOURCES · Why: MISSION
Grounded in: Vernon, Effective Aggregate Design I–III; Google AIP-121/122/124; PostgreSQL deferrable-constraint docs.
Feedback on this lesson