Hierarchies in Backend Design · Lesson 3
The C/R/U/D questions from your original notes, resolved. Plus the answer to your UUIDv7 question, which turns out to be a better question than the answer I gave.
Carried in: type vs value hierarchy (L1) · aggregate boundary (L2) · client-generated identity and its four bug classes (L2).
This lesson adds: when a hierarchy operation stops being CRUD · when the
parent belongs in the URL and when that's a trap · UUIDv7 vs v4 · idempotency keys vs client IDs · why
parentId must not be a PATCH field.
Resource-oriented design gives you five standard methods — List, Get, Create, Update,
Delete — and the discipline is to express as much as possible with them, because their semantics are already known to
every caller. AIP-136 is blunt about the alternative: use a custom method (an RPC-shaped
endpoint, spelled POST /campaigns/c1:publish) only for functionality that cannot be easily
expressed via standard methods — and do not contort a standard method to "sort of work".
For hierarchies, three operations fail that test, and it is worth knowing exactly why:
| Operation | Why it isn't CRUD | Shape |
|---|---|---|
| Import a tree | Creates n resources with one invariant check across all of them. A Create call creates one resource. | POST /campaigns:import |
| Move a subtree | Mutates one row but changes the meaning of n rows. Update's contract is "these fields, this resource". | POST /nodes/n1:move |
| Publish | A state transition with preconditions and side effects in another system. Not a field assignment. | POST /campaigns/c1:publish |
Everything else — create a folder, rename it, list children, delete it — is genuinely CRUD, and dressing it up as a command is the opposite error. The test to apply: does this operation change data the caller did not name? If yes, it wants a verb.
This is the resolution of the "commands vs RESTful API" fork in your notes. It is not a choice between two API styles. It is a resource-oriented API with a small, justified set of verbs where the resource model cannot carry the meaning — and the justification is always "this touches rows the caller didn't name".
For a type hierarchy, yes — this is exactly what hierarchical resource names are for:
GET /campaigns/c1/adSets/a1/ads/x9 # canonical name, depth fixed at 3 POST /campaigns/c1/adSets # parent scopes the collection
The name encodes the containment, the parent scopes uniqueness and authorisation, and depth is bounded by the schema so the URL template is finite. Flatten only when a child is routinely queried across parents — reporting over all ads regardless of ad set — in which case offer both: the canonical nested name plus a flat collection with a filter.
For a value hierarchy, no — and this is the trap:
GET /folders/a/folders/b/folders/c/folders/d/... # ✗ unbounded URL, no finite route template GET /nodes/n4 # ✓ flat identity GET /nodes/n4/descendants?depth=2 # ✓ relationship as a sub-collection
Depth is user-chosen, so any attempt to encode the full path in the URL produces an unbounded template, breaks route matching, and makes the identifier change every time the node moves. A node's identity must not depend on its position — that is precisely the thing users mutate. Keep identity flat; express structure in the body and in relationship sub-collections.
Same distinction as Lesson 1, now on the API surface: fixed-depth heterogeneous → nest; recursive homogeneous → flatten. If you can write the route template with a finite number of segments, nesting is safe. If you cannot, it isn't. That single test settles most URL arguments about trees.
You asked why I said UUIDv7. Fair — I asserted it. Here is the actual argument, and it has a real counter-argument.
UUIDv4 is 122 random bits. UUIDv7, standardised in RFC 9562 (2024), puts a 48-bit Unix millisecond timestamp in the most significant bits and fills the rest with randomness — so v7 values generated in sequence sort in creation order.
The case for v7 is index locality. RFC 9562 states the mechanism directly: non-time-ordered UUIDs have poor database-index locality, while time-ordered ones cluster because new values land near each other. A random v4 insert lands at a random leaf of the B-tree, so the database must fetch and dirty a different page nearly every time; pages fill unevenly and split, parent pages get rewritten, and the working set that must stay in memory grows with the table. v7 inserts land at the right edge, where the previous insert already warmed the page. Reported effects are an order of magnitude on insert throughput at scale, plus meaningfully smaller indexes.
Two secondary wins that matter for hierarchies specifically: a time-ordered primary key gives you a natural cursor for keyset pagination over a large subtree read, and it makes "the ads created by that import" a contiguous range rather than a scatter.
Marc Brooker's critique is the one to hold in mind, because it names four costs:
His proposed fix is elegant and worth knowing as a pattern even if you never implement it: replace the raw timestamp
with unix_ts_ms XOR H(id, unix_ts_ms >> N), a keyed hash where N tunes how long the
prefix stays stable and id is a scope like cluster or customer. Locality survives — values stay
v7-like for 2N milliseconds, so pages are reused — while the clock is obscured.
Now combine with Lesson 2: if the client mints the IDs, v7's timestamp is the client's clock.
A device with a skewed clock produces IDs that sort into the wrong place — inserting in the middle of the index (losing
the locality you chose v7 for) or far in the future (poisoning your cursor pagination). Correctness is unaffected;
the performance property you paid for is not guaranteed. If you need the ordering to mean something, order by a
server-assigned created_at and let the ID be an opaque key.
The transferable idea: v7 is a performance choice with a privacy cost, and it is only sound when the generator's clock is one you trust. "Use v7" is a fine default for server-generated keys and a claim you should defend explicitly for client-generated ones.
A tree-mutating command must survive a retry. There are two ways, and they are not equivalent.
| Client-generated resource IDs | Idempotency-Key header | |
|---|---|---|
| How | Same IDs in the payload + ON CONFLICT DO NOTHING |
Server stores the first response for the key and replays it |
| Protects | Creates only — the row is the dedup token | Any operation, including ones that create nothing |
| Returns on retry | Whatever the second execution computes | Byte-identical original response, including the original error |
| Fails at | Partial payloads, mixed create/update, deletes | Nothing much — it just costs a table |
Stripe's version is the reference: store status code and body against the key, replay them on retry, and reject a key reused with different parameters so a client bug surfaces instead of silently returning the wrong resource. Prune keys after ~24 hours. Note the subtle one — replaying a stored 500 is correct behaviour, because the caller must see the same outcome, not a fresh attempt.
Pick by operation: :import is pure creation, so client IDs alone suffice. :move and
:publish create nothing, so they need the header.
parentId is not a fieldThe most common hierarchy API bug is one line long: including parentId in the PATCH body. It looks like
an ordinary column, so it slips into the generic update handler — and now your rename endpoint silently performs a
subtree move, without a cycle check, without maintaining the derived path or closure rows, and without checking that the
caller may write to the destination as well as the source.
Structural change deserves its own endpoint, for the reason from §1: it changes rows the caller did not name. Lesson 4 is that endpoint.
Offer exactly three reads and refuse the fourth. GET /nodes/{id} for one node.
GET /nodes/{id}/children, paginated, for a lazily-expanding UI.
GET /nodes/{id}/descendants?depth=n with a documented maximum, for rendering a bounded subtree in one
round trip. The fourth — an unbounded ?expand=all — is a denial-of-service endpoint you built for your
largest customer. Every one of these is one recursive CTE or one prefix scan on the server; the round trips are what
you are minimising, not the queries.
From memory: give the mechanism by which UUIDv7 beats v4 on insert throughput, and the two costs you accept. Then: what specifically breaks when the client mints the v7?
Mechanism: index locality. Time-ordered values append at the right edge of the B-tree, so consecutive inserts hit an already-cached page — fewer page splits, less write amplification, smaller working set. Random v4 dirties a different page per insert.
Costs: the timestamp is published in every identifier (leakage), and there are fewer random bits (guessability). Also: correlated prefix rollover across datacentres, and human-confusable IDs.
Client-minted: the timestamp is the client's clock. Skew puts inserts back in the
middle of the index — you lose the locality you paid for — and future-dated IDs poison any cursor pagination built on
ID order. Order by a server-assigned created_at if ordering must be meaningful.
app.patch('/nodes/:id', async (req, res) => { const { name, parentId } = req.body; const node = await db.node.findFirstOrThrow({ where: { id: req.params.id, tenantId: req.user.tenantId }, }); const updated = await db.node.update({ where: { id: node.id }, data: { name, parentId }, }); res.json(updated);});
Line 8. parentId is treated as an ordinary column, so this rename
endpoint is also an unguarded move endpoint. Four consequences, all invisible in code review if you are only reading
for "does it update the row": no cycle check, so a folder can be dragged inside itself and detach a branch; no
maintenance of the derived path or closure rows, so the read index silently diverges from the truth
(Lesson 1 §5); no authorisation check on the destination parent, which is the cross-tenant graft from
Lesson 2 §3; and no event emitted, so downstream systems never learn the subtree moved.
The fix is not "add checks here" — it is to reject parentId in this handler with a 400
and give the move its own endpoint, because the operation changes rows the caller did not name. Secondary defect worth
naming: if name is absent from the body it arrives as undefined, and whether that clears the
column or is ignored depends on your ORM — the classic PATCH-vs-PUT ambiguity. Distinguish "field absent" from
"field set to null" explicitly.
An idempotency wrapper for tree commands.
async function withIdempotency<T>(key: string, tenantId: string, fingerprint: string, fn: (tx: Tx) => Promise<T>) { return db.$transaction(async (tx) => { const prior = await tx.idempotency.findUnique({ where: { tenantId_key: { tenantId, key } } }); if (prior) { if (prior.fingerprint !== fingerprint) throw new ConflictError('key reused'); return prior.response as T; } const response = await fn(tx); await tx.idempotency.create({ data: { tenantId, key, fingerprint, response } }); return response; });}
No bug. The interesting part is why the obvious race isn't one. Two
concurrent requests with the same key both read nothing at line 4 — the check-then-act looks broken. But both then
reach line 11, and the unique constraint on (tenantId, key) lets exactly one commit; the loser's whole
transaction aborts, rolling back its side effects from line 10 too. Safety comes from the constraint, not
the read. Reviewing check-then-act code is mostly asking "what constraint backs this?" — and here, one does.
Also correct: the fingerprint comparison surfaces a client that reused a key with different parameters instead of silently returning the wrong resource, and the stored response is replayed verbatim. Legitimate review comments, none of them bugs: the loser gets a raw unique-violation that should be caught and retried into the replay path; nothing prunes old keys; and a stored 500 will be replayed forever, which is Stripe's behaviour and worth a deliberate decision rather than an accident.
For each, give the HTTP shape and the retry mechanism, in one line each. Watch for the one that is genuinely CRUD.
1. PATCH /nodes/n1 {name} — genuinely CRUD; it touches only the row the
caller named. No idempotency machinery needed: setting a name to the same value twice is naturally idempotent.
2. POST /nodes/n1:move {newParentId} + Idempotency-Key.
Custom method because it changes the meaning of 10 000 rows; header because it creates nothing, so there is no row to
dedup on.
3. GET /nodes/{root}/descendants?depth=3, paginated, documented maximum.
One round trip, one prefix scan. Not ?expand=all.
4. POST /campaigns/c1:publish + Idempotency-Key. A state
transition with preconditions and an external side effect — and since the external call can fail after your commit,
this is the operation that needs Lesson 5's outbox.
5. POST /campaigns/c1/ads:batchCreate (or :import) with
client-generated UUIDs and ON CONFLICT DO NOTHING. Pure creation, so the IDs are the dedup token and no
header is required. If you also added an idempotency key here, you built the same guarantee twice.
Previous: Lesson 2 — Where the transaction ends · Next: Lesson 4 — The move endpoint
Map: STATUS · Sources: RESOURCES · Why: MISSION
Grounded in: Google AIP-121/122/136; RFC 9562; Brooker, "Fixing UUIDv7"; Stripe on idempotency; Brandur, "Implementing Stripe-like Idempotency Keys in Postgres".
Feedback on this lesson