Hierarchies in Backend Design · Lesson 3

REST, commands, and who invents identity

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.

1. Standard methods, and the point where they run out

Prefer the five you already have

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".

The three hierarchy operations that fail the test

For hierarchies, three operations fail that test, and it is worth knowing exactly why:

OperationWhy it isn't CRUDShape
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".

2. Does the parent belong in the URL?

Type hierarchy: nest

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.

Value hierarchy: flatten

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.

3. Identity: UUIDv7 or v4?

You asked why I said UUIDv7. Fair — I asserted it. Here is the actual argument, and it has a real counter-argument.

The case for v7: index locality

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.

The counter-argument, which is not weak

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.

The client-generated wrinkle

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.

4. Two mechanisms for safe retries — don't build both

Client IDs or an idempotency key

A tree-mutating command must survive a retry. There are two ways, and they are not equivalent.

Client-generated resource IDsIdempotency-Key header
HowSame IDs in the payload + ON CONFLICT DO NOTHING Server stores the first response for the key and replays it
ProtectsCreates only — the row is the dedup token Any operation, including ones that create nothing
Returns on retryWhatever the second execution computes Byte-identical original response, including the original error
Fails atPartial payloads, mixed create/update, deletesNothing 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.

Picking per operation

Pick by operation: :import is pure creation, so client IDs alone suffice. :move and :publish create nothing, so they need the header.

5. The U step: parentId is not a field

The 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.

6. The R step in one paragraph

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.

Exercises

Exercise 1 — Recall (free)

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?

Exercise 2 — Bug hunt: click the offending line

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

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

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

Exercise 4 — Execution: name the shape

For each, give the HTTP shape and the retry mechanism, in one line each. Watch for the one that is genuinely CRUD.

  1. Rename a folder.
  2. Drag a 10 000-node folder into another folder.
  3. Render the top three levels of a user's folder tree on page load.
  4. Push a validated campaign to Meta.
  5. Create 5 000 ads from a spreadsheet.

Retention

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".