Hierarchies in Backend Design · Lesson 4
One operation, and it collides with every other lesson at once: storage cost, aggregate boundary, authorisation, concurrency, derived data, and an axis you haven't met yet.
Why this one is the hard one: move is the only hierarchy operation that changes the meaning of rows the caller never named, spans two authorisation domains, invalidates derived data, and races against itself.
New here: the concurrent-move cycle race · advisory locks as a serialisation scope · the closure-table move in two statements · sibling ordering via fractional indexing · desired-state commands.
Every move implementation starts with the obvious guard: reject if the destination is the node itself or one of its descendants, because that detaches the branch from the root. Correct, necessary, and insufficient.
PostgreSQL's default isolation level is READ COMMITTED, under which each statement sees the data committed when that statement began. So:
Neither transaction did anything wrong. Each validated against a state that was true when it looked, and the combination is invalid — the textbook write skew: two transactions read an overlapping set, write disjoint rows, and jointly break a constraint neither could see alone. Note that no foreign key can catch this, and re-reading inside the transaction cannot either.
You should be able to name both:
SELECT pg_advisory_xact_lock(hashtext(root_id)). Now only one move per tree runs at a time, so
check-then-act is safe. The reason this is cheap is a fact from Lesson 1's matrix: moves are rare compared to reads.
You are serialising the rare operation.The transferable shape: when an invariant spans rows that no single row's constraint can express, you need either a serialisable transaction or an explicit lock whose scope is the invariant's scope. Choosing the scope is the design work — lock the tree, not the table (kills throughput) and not the node (doesn't cover the invariant).
Under a materialised path, one statement covers the whole subtree (Lesson 1 Ex 3):
UPDATE node SET path = $newParentPath || subpath(path, nlevel($oldPath) - 1) WHERE path <@ $oldPath;
Under a closure table, the canonical move is a delete and an insert. Worth being able to read, because it looks cryptic and is actually simple: disconnect the subtree from its old ancestors, then connect every old-ancestor-free pair to the new ones.
-- 1. remove links from the subtree's nodes to their former ancestors
DELETE FROM closure
WHERE descendant_id IN (SELECT descendant_id FROM closure WHERE ancestor_id = $node)
AND ancestor_id IN (SELECT ancestor_id FROM closure WHERE descendant_id = $node
AND ancestor_id <> $node);
-- 2. cross-join new ancestors with the subtree
INSERT INTO closure (ancestor_id, descendant_id, depth)
SELECT a.ancestor_id, d.descendant_id, a.depth + d.depth + 1
FROM closure a, closure d
WHERE a.descendant_id = $newParent AND d.ancestor_id = $node;
That cross join is the |subtree| × |ancestor path| row count from Lesson 1's matrix, made concrete. Both
statements plus the parent_id update belong in one transaction — the derived structure must never be
observable in a state that disagrees with the truth.
Here is the thing most designs miss until the UI ticket arrives. parent_id answers "whose child am I?"
It says nothing about order, and users who drag things expect to drop them between two others.
position column failsThe naive answer is an integer position column, which fails the same way nested set fails: inserting at
position 3 among 500 siblings renumbers 497 rows, and two concurrent inserts at the same position produce duplicates or
lost updates.
Fractional indexing is the fix, and it is how Figma and Linear order sibling sequences. Give each node a sort key drawn from a densely ordered set, and to insert between two neighbours, pick a key strictly between theirs. Figma's original formulation uses a real number — the average of the two neighbours' indices. String keys (the family often called LexoRank) are the practical version, exploiting a property integers lack: between any two strings there is always another string.
siblings: a0 a1 a2 drop between a0 and a1 → key = 'a0V' // one row written, no neighbours touched drop between a0 and a0V → key = 'a0G' // still one row
Every reorder writes exactly one row, regardless of sibling count, and concurrent reorders at different positions don't interact at all.
a0V. Order by (sort_key, id) so ties resolve deterministically rather
than arbitrarily per query.Note the pattern repeating: integer position is nested set, and fractional indexing is
adjacency list — dense encodings make reads trivial and writes global; sparse ones make writes local. Third time this
trade has appeared, on a different axis each time. That's the web forming.
A move is a removal from one parent and an insertion into another. Both need permission. Almost every first implementation checks only the node being moved — which is how a user with read-only access to a shared destination folder writes into it anyway, and how the cross-tenant graft from Lesson 2 §3 arrives through a different door.
These belong here too, because move is where they get violated: the resulting depth must stay within your maximum (someone will build a 500-deep chain and break every recursive read), and the destination must be in the same tenant and, usually, the same tree root.
Compare two payloads:
POST /nodes/n1:move { "newParentId": "n7" } // desired state — naturally idempotent
POST /nodes/n1:move { "direction": "up" } // delta — retry moves it twice
The first can be retried freely: applying "your parent is n7" twice leaves the same tree, so a network timeout followed by a retry is harmless even before you add an idempotency key. The second is a delta, and a retried delta is a double application. This is the cheapest correctness win in API design — prefer desired state over deltas — and it generalises far beyond trees.
Add the Idempotency-Key header anyway (Lesson 3 §4), because the caller also wants the same
response on retry, including the affected count. Return the moved node plus how many descendants came with it;
the UI needs it, and it's the number that makes the operation auditable.
Click the steps in the order they must happen. Wrong slots turn red.
The order that matters most is lock before load. If you load and validate first and then take the lock, you validated against a snapshot that the lock does not protect — you have written §1's race with extra steps. The lock must precede every read whose result you intend to act on.
Second: the outbox write goes inside the transaction (Lesson 2 Ex 3), which is why it comes before commit rather than after. Publishing after commit is the dual-write problem — Lesson 5.
async function moveNode(user: User, nodeId: string, newParentId: string) { return db.$transaction(async (tx) => { const node = await tx.node.findFirstOrThrow({ where: { id: nodeId, tenantId: user.tenantId } }); const parent = await tx.node.findFirstOrThrow({ where: { id: newParentId, tenantId: user.tenantId } }); await assertCanWrite(user, node); if (isPrefixOf(node.path, parent.path)) throw new BadRequest('cannot move into own subtree'); await tx.node.update({ where: { id: nodeId }, data: { parentId: newParentId } }); await tx.$executeRaw`UPDATE node SET path = ${parent.path} || subpath(path, nlevel(${node.path}) - 1) WHERE tenant_id = ${user.tenantId} AND path <@ ${node.path}`; await tx.outbox.create({ data: { type: 'NodeMoved', nodeId, newParentId } }); });}
Line 5. One authorisation check, for the node being moved. Nothing verifies the user
may write into parent. Tenant scoping on line 4 stops the cross-tenant case, but within a tenant this
lets anyone with edit rights on their own folder insert it into a destination they can only read. The fix is two
assertions: assertCanWrite(user, node) and assertCanWrite(user, parent) — a move is a
removal and an insertion.
Two further defects, both worth raising: no lock and default isolation, so §1's cycle race is live — the line 6 check is correct and still insufficient. And no depth-limit check, so a caller can build a chain deep enough to break every recursive read. The path rewrite on lines 8–9 is right, including the tenant predicate.
Reordering a node among its siblings, fractional-indexing style.
async function reorder(user: User, nodeId: string, afterId: string | null, beforeId: string | null) { return db.$transaction(async (tx) => { const [after, before] = await Promise.all([ afterId ? tx.node.findFirstOrThrow({ where: { id: afterId, tenantId: user.tenantId } }) : null, beforeId ? tx.node.findFirstOrThrow({ where: { id: beforeId, tenantId: user.tenantId } }) : null, ]); const key = keyBetween(after?.sortKey ?? null, before?.sortKey ?? null); await tx.node.update({ where: { id: nodeId }, data: { sortKey: key } }); });}
Line 7. The neighbours are never checked for being siblings of each other — or of the
node being moved. A caller can pass an afterId from one parent and a beforeId from another,
and keyBetween will happily compute a key from two unrelated sequences. The node lands at an arbitrary
position in its own sibling list, and the result depends on unrelated subtrees' keys. Validate
after.parentId === before.parentId === node.parentId before computing anything.
A defensible answer if you clicked 8: two users dropping into the same gap
concurrently compute the same key, and nothing here tie-breaks. That is real, but it degrades to an arbitrary order
between two items rather than a wrong position, and the durable fix lives in the read path — sort by
(sort_key, id). The sibling check is the one that lets a caller corrupt the ordering outright.
Your sibling keys are a0, a1, a2. A user drags an item between
a0 and a1 thirty times in a row, always into the topmost gap. Describe what happens to the
keys, what eventually goes wrong, and what you do about it. Then: why is this still better than an integer
position column?
Each insert subdivides the same interval, so keys grow by about one character per insert:
a0V, a0G, a0B… After thirty you have a ~30-character key. Nothing is
broken — string comparison still orders correctly — but keys grow without bound, index entries get fatter, and
comparisons slow marginally. The fix is a background rebalance that rewrites the sibling set with evenly spaced short
keys, run rarely and on a threshold (max key length in the sibling set), not on every write.
Still better than integers because the cost profile is inverted in the direction that
matters: fractional indexing pays a small amortised cost occasionally in a background job, while
position pays a large synchronous cost on every reorder — up to n sibling rows, in the
user's request, contending with every other reorder in that parent. Same trade as adjacency list vs nested set: keep
writes local, pay for tidiness later, out of band.
Previous: Lesson 3 — REST, commands, and who invents identity · Next: Lesson 5 — Eventual consistency between aggregates
Map: STATUS · Sources: RESOURCES · Why: MISSION
Grounded in: Karwin, SQL Antipatterns (closure-table move); PostgreSQL isolation-level and advisory-lock docs; Figma, "Realtime Editing of Ordered Sequences"; fractional-indexing scheme survey.
Feedback on this lesson