Hierarchies in Backend Design · Lesson 2

Where the transaction ends

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.

1. Invariant, not relatedness

What an aggregate actually is

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?

Vernon's four rules of thumb

In the order they bite:

  1. Model true invariants in consistency boundaries. A true invariant is a business rule that must hold at every commit. Everything else is a rule that must hold eventually, and belongs outside.
  2. Design small aggregates. Prefer a root plus a few values. Size is not an aesthetic concern — see §2.
  3. Reference other aggregates by identity. Hold adSetId, not an AdSet object. This is what keeps one transaction from dragging in the graph.
  4. Use eventual consistency outside the boundary. Cross-aggregate rules are enforced by domain events and reconciliation, not by widening the transaction.

Applied to campaign → ad set → ad

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.

2. Why big aggregates fail: concurrency, not size

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.

3. The C step: who gets to invent identity?

Two shapes for building a tree

Now the question your notes were circling. To create a three-level tree, there are two shapes.

flowchart LR subgraph S["Server-assigned IDs"] A1["POST /campaigns"] --> A2["→ id c1"] --> A3["POST /campaigns/c1/adSets"] --> A4["→ id a1"] --> A5["POST .../a1/ads"] end subgraph C["Client-generated IDs"] B1["client mints UUIDs
builds whole tree offline"] --> B2["POST /campaigns:import
{nodes, edges}"] --> B3["one transaction"] end

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:

The four bug classes you buy

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 classWhat it looks likeWhere you defend
Cross-tenant parentClient 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.
CyclesClient 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 orderingThe payload lists a child before its parent, so the FK fails on a tree that is perfectly valid as a wholeDatabase: DEFERRABLE INITIALLY DEFERRED, or topologically sort before inserting.
ID collision / guessabilityClient mints a sequential or colliding ID Require UUID format server-side; never let a client-supplied ID overwrite an existing row.

Deferrable constraints dissolve the ordering problem

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.

4. The D step: cascade down, never up

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.

Why ON DELETE CASCADE is the wrong product semantics

Usually not, for four reasons:

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.

5. The R step, briefly

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.

Exercises

Exercise 1 — Recall (free)

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.

Exercise 2 — Bug hunt: click the offending line

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 },      });    }  });}

Exercise 3 — Bug hunt: click the offending line (or declare it clean)

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;  });}

Exercise 4 — Execution: the two-sentence design

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.

Retention

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.