CASE STUDY

Storing a Hierarchy and Returning All Descendants

3 min read·552 words·Intermediate

Asked at

1 candidate report in Jan 2026

How to use this case study

SDE-2 / Mid

Design APIs to add a node under a parent and get all descendants, using an adjacency list (parent_id) and a recursive query.

SDE-3 / Senior

Compare adjacency list, materialized path, nested sets and closure table for read vs write speed, and handle moving subtrees.

Staff / Principal

Discuss very deep or very wide trees, caching subtrees, consistency during moves, and scaling beyond one database.


0) Problem Restatement

Atlassian asked: design a service to store a hierarchical tree of nodes (think Confluence pages under pages, or folders) and expose APIs to:

  1. Add a node under a parent.
  2. Return all descendants (children, grandchildren, and so on) of a node.
Plus common follow-ups: move a subtree, delete a node, get ancestors (breadcrumbs), and multiple roots (a forest).


1) APIs

  • POST /nodes { parent_id?, name }{ node_id }
  • GET /nodes/{id}/descendants?depth=&cursor= → a list or nested tree
  • GET /nodes/{id}/ancestors
  • POST /nodes/{id}/move { new_parent_id }
  • DELETE /nodes/{id}?cascade=true


2) Four Ways to Store a Tree

ModelHowGet descendantsAdd nodeMove subtree
Adjacency listEach row has parent_idRecursive query (many steps)O(1)O(1) (update one parent_id)
Materialized pathEach row stores its path /1/4/9/WHERE path LIKE '/1/4/%' (fast with an index)O(1)Update all paths in the subtree
Nested setsEach row has left, right numbersWHERE left BETWEEN a AND b (very fast)Renumber many rows (slow)Very slow
Closure tableA separate table of every (ancestor, descendant, depth) pairSimple join, fastInsert one row per ancestorDelete and insert pairs for the subtree
Recommendation: adjacency list + closure table (or adjacency list + materialized path) gives fast reads and reasonable writes, and is easy to explain.

3) Schema (adjacency + closure table)

CREATE TABLE nodes (
  node_id   BIGINT PRIMARY KEY,
  parent_id BIGINT NULL REFERENCES nodes(node_id),   -- NULL = root (forest allowed)
  name      TEXT NOT NULL
);
CREATE TABLE node_paths (            -- every ancestor/descendant pair, including self (depth 0)
  ancestor_id   BIGINT NOT NULL,
  descendant_id BIGINT NOT NULL,
  depth         INT NOT NULL,
  PRIMARY KEY (ancestor_id, descendant_id)
);
CREATE INDEX ON node_paths (descendant_id);

3.1 Add a node (in one transaction)

INSERT INTO nodes (node_id, parent_id, name) VALUES (:id, :parent, :name);
-- the new node is a descendant of every ancestor of its parent, plus itself
INSERT INTO node_paths (ancestor_id, descendant_id, depth)
  SELECT ancestor_id, :id, depth + 1 FROM node_paths WHERE descendant_id = :parent
  UNION ALL SELECT :id, :id, 0;

3.2 Get all descendants

SELECT n.* , p.depth FROM node_paths p JOIN nodes n ON n.node_id = p.descendant_id
WHERE p.ancestor_id = :id AND p.depth > 0
ORDER BY p.depth, n.name;

One indexed query, no recursion. To return a nested tree as JSON, fetch the rows with parent_id and build the tree in memory (O(n)).

3.3 Move a subtree

In one transaction: delete the pairs linking the subtree's nodes to their old outside ancestors, then insert pairs linking them to the new parent's ancestors (a cross join of new ancestors × subtree nodes), and update parent_id. Reject moves that would create a cycle (the new parent is inside the subtree).


4) Alternative Without a Closure Table

With only parent_id, use a recursive CTE:

WITH RECURSIVE sub AS (
  SELECT node_id, parent_id, name, 1 AS depth FROM nodes WHERE parent_id = :id
  UNION ALL
  SELECT n.node_id, n.parent_id, n.name, s.depth + 1 FROM nodes n JOIN sub s ON n.parent_id = s.node_id
) SELECT * FROM sub;

This works well for moderate trees, since each level is one indexed step (index on parent_id). Deep trees mean many steps.

Architecture Diagram

flowchart LR
    API["Hierarchy API"] --> DB[("nodes + node_paths")]
    API --> C[("Cache - subtree results")]
    DB -->|"change events"| INV["Invalidate cached subtrees of affected ancestors"]
    INV --> C

5) Scale and Edge Cases

  • Huge subtrees: paginate descendants (a cursor by depth, name, id), or return only a few levels at a time (depth parameter), as file explorers do.
  • Caching: cache subtree results, and invalidate the caches of all ancestors when a node is added, moved or deleted (the closure table tells you exactly which).
  • Delete: cascade (delete the subtree, found via the closure table) or re-parent the children, as the product decides.


6) Wrap-Up

Keep parent_id for simple structure, and add a closure table of all ancestor–descendant pairs with depth, so "all descendants" and "all ancestors" are single indexed queries, adds insert one row per ancestor, and moves rewrite only the subtree's outside links (with a cycle check). Paginate or depth-limit large subtrees, and cache results with ancestor-based invalidation. A recursive CTE on parent_id is the simpler alternative for moderate trees.

More Case Studies

Practice with a Mock Interview

Apply what you learned in a live system design mock interview with our AI interviewer.

Start System Design Interview →