Engineering Atlas › Low-Level Design

Aggregation: a has-a relationship

10 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem: who is allowed to delete a part

A music service has 40 million tracks and 300 million playlists. An early design gives Playlist a cascade delete on its tracks, and in staging a tester deletes a test playlist and 50 tracks vanish from every other playlist that held them. The model said "part of" when it meant "refers to". Aggregation is that weaker relationship: a whole that groups parts it does not own, which can belong to several wholes and outlive all of them.

Shared parts and independent lifetimes

Three questions separate aggregation from composition. Can the part exist with no whole? Can it be in two wholes at once? Should copying the whole leave the parts shared? For a playlist and its tracks all three answers are yes: duplicating a playlist should point at the same tracks, not create 50 new track records.

In code this means the whole stores references or ids and accepts parts from outside: playlist.add(track), never playlist.new_track(...). Its constructor does not build parts and its teardown does not destroy them. A shallow copy is the correct copy.

The removal question

Independent lifetimes cut both ways: a label can withdraw a track from under every playlist holding it. Decide once, centrally, what happens:

A common split is unlink within one database and tolerate across services, where the client shows "no longer available" for missing ids.

Shared mutable parts

If a shared part is mutable, a change made through one whole is visible through every other. That is the point when a track's title is corrected, and a bug when a team lead edits "their" copy of an engineer's hours and changes them for every team. Keep shared parts immutable, or make every mutation go through the part's own methods with its own locking, never through a whole.

When aggregation is the wrong model

If a part makes no sense without its whole (an order line without an order) or must never be shared, you want composition. If the "whole" is only a query result, such as "tracks by this artist", do not store it as an aggregate at all; compute it.

Reviewing agent-written code

Agents often add cascades and deep copies because they look thorough. Check that wholes never construct or destroy shared parts, that clones are shallow for aggregated fields, and that part removal has one documented answer.

The same shape elsewhere

Hard links and symlinks have the same shape: a file survives until its last directory entry goes, and a symlink that outlives its target dangles. Container registries share image layers and need a garbage collection pass to find unused ones.

Down the stack

Layer 1The contract

A whole promises to hold references to parts and to expose them, nothing more. It does not promise the part exists: callers must handle a part that has been removed, unless the system enforces restrict or unlink. Removing a whole must leave every part, and every other whole, unchanged.

Layer 2The mechanism

In garbage-collected runtimes, aggregation is simply reachability. A tracing collector (Go's, the JVM's G1) frees a part only when no live path reaches it, and CPython frees it when its reference count hits zero. The language cannot say "this playlist does not own this track"; only your code's behaviour does.

Rust makes the distinction visible. A plain field owns its value, so sharing requires Rc<T> or, across threads, Arc<T>: a compiler-checked hollow diamond.

In a relational database, the part's table has its own primary key, and the whole references it through a join table (playlist_track(playlist_id, track_id, position)). The foreign key's ON DELETE clause is the removal policy: RESTRICT or NO ACTION blocks deleting a track in use; CASCADE on the join table's track_id deletes the link rows, not the playlists; SET NULL suits a one-to-many column such as employee.team_id. Postgres enforces these through internal triggers on both tables, which is why an index on the referencing column matters: without it, every track delete scans the join table.

Layer 3The machine

A playlist of 50 tracks is an array of 50 pointers or 50 8-byte ids. The tracks themselves live elsewhere on the heap or on other database pages, so rendering the playlist is 50 dependent loads. In memory, each miss costs about 100 ns; in Postgres, fetching 50 tracks by primary key may touch 50 different 8 KB pages, each a buffer cache hit of a microsecond or so, or an SSD read of roughly 100 us when cold. That scattering is the price of sharing: one copy per part, many readers.

Shared parts across threads carry a second cost. Atomic reference counts (Arc, Swift's ARC, shared_ptr) bounce one cache line between cores on every clone and drop; under contention each increment can take tens to hundreds of nanoseconds instead of about one. Immutable parts can be read concurrently with no locks at all, which is the main argument for making shared parts immutable.

Why it's built this way

  1. Many wholes want the same part, and copying it would let the copies drift apart, so each part is stored once and referenced.
  2. A part referenced from many places cannot be destroyed by any single one of them, so its lifetime is independent.
  3. Independent parts can still be removed, so every holder needs a defined reaction: restrict, unlink or tolerate.
  4. Unlinking needs to find holders fast, so systems keep a reverse index or rely on the collector's reachability scan.

Diagram

flowchart LR
  P1["Playlist: Running"] --> T1["Track t1"]
  P1 --> T2["Track t2"]
  P2["Playlist: Focus"] --> T2
  P2 --> T3["Track t3"]
  C["Catalog"] --> T1
  C --> T2
  C --> T3
  D["delete Running"] -.-> P1
  D -.-> K["t1, t2 kept: catalog and Focus still reach them"]
  R["remove t2 from catalog"] -.-> U["unlink t2 from Running and Focus"]

Worked example

A team-management tool has 12,000 employees and 1,800 teams. Each employee is on 1 to 4 teams, 21,000 memberships in total. Teams aggregate employees: disbanding a team must never delete a person.

The first schema stored id arrays on both employee and team. Disbanding a team scanned all 12,000 employees, and the arrays drifted when one update failed. The second schema used membership(team_id, employee_id) with a composite primary key and an index on employee_id, plus ON DELETE CASCADE on both foreign keys. Disbanding a team of 15 now deletes 15 link rows through the primary key; offboarding an employee deletes their 1 to 4 link rows through the second index. Neither touches the other side's main table. The remaining issue was shared mutation. Team leads could edit a member's "role" from the team page, which wrote to employee.title and changed it on every team. Moving role onto the membership row fixed it: the person is shared, the role is per team.

Trade-offs

OptionStrengthsWeaknessesChoose when
Restrict removalNo dangling references everPopular parts become hard to removeParts are rarely removed and integrity matters most
Unlink on removalWholes stay cleanNeeds a reverse index; large fan-out deletesOne database, moderate fan-out
Tolerate dangling idsCheap writes, works across servicesEvery reader handles missing partsParts and wholes live in different systems
Copy parts into the wholeNo sharing bugs, simple readsCopies drift, more storageSnapshots that must not change, such as an invoice

Pitfalls in production

Leadership lens

Check yourself

  1. What is the quickest test for aggregation versus composition?

    Show answer

    Ask whether the part can exist without the whole and be in two wholes at once. If yes, it is aggregation and the whole must not destroy it.

  2. Why should duplicating a playlist not duplicate its tracks?

    Show answer

    The playlist only refers to tracks it does not own. Copying them would create distinct tracks that drift apart, so the new playlist should hold the same references.

  3. A track is deleted and the join table has ON DELETE CASCADE on track_id. What is deleted?

    Show answer

    The track row and the link rows that reference it. The playlists themselves stay, now without that track.

  4. Why is Arc<Track> in a Rust struct a stronger signal than a hollow diamond in UML?

    Show answer

    The compiler enforces it: holding one track in several places requires shared ownership, and the track lives until the last holder drops it.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →