Aggregation: a has-a relationship
Open in the interactive app: practice, quiz, mock interview →In one minute
- Aggregation is a whole-part association where the parts have their own lives. A playlist holds tracks; deleting the playlist leaves every track in the catalog.
- Parts can be shared. The same track sits in many playlists, and the same engineer is on several teams.
- Because the whole does not own its parts, it must not create, copy or destroy them. It holds references or ids and nothing more.
- The hard question is the other direction: what happens to every whole when a shared part goes away. Decide it explicitly: block, unlink, or tolerate a dangling id.
- UML's hollow diamond has no precise standard meaning. Write down lifetime and sharing rules instead.
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:
- Restrict. Refuse to remove a part that any whole still uses. Safe, but a widely used part becomes impossible to remove.
- Unlink. Remove the part and strip it from every whole. Needs a fast way to find those wholes, which means a reverse index from part to wholes.
- Tolerate. Leave the id in place and have readers skip or grey out missing parts. Cheap at write time, and the normal choice across service boundaries where you cannot update every holder atomically.
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
- Many wholes want the same part, and copying it would let the copies drift apart, so each part is stored once and referenced.
- A part referenced from many places cannot be destroyed by any single one of them, so its lifetime is independent.
- Independent parts can still be removed, so every holder needs a defined reaction: restrict, unlink or tolerate.
- 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
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Restrict removal | No dangling references ever | Popular parts become hard to remove | Parts are rarely removed and integrity matters most |
| Unlink on removal | Wholes stay clean | Needs a reverse index; large fan-out deletes | One database, moderate fan-out |
| Tolerate dangling ids | Cheap writes, works across services | Every reader handles missing parts | Parts and wholes live in different systems |
| Copy parts into the whole | No sharing bugs, simple reads | Copies drift, more storage | Snapshots that must not change, such as an invoice |
Pitfalls in production
- Cascade delete configured from whole to shared part, so deleting one group destroys data other groups use. Fix: cascade only to the link rows, never to the part table.
- Deep copy when duplicating a whole, creating thousands of near-identical parts. Fix: shallow copy aggregated fields and test that ids are unchanged.
- Per-whole data stored on the shared part, such as a team role on the employee. Fix: move it onto the link.
- Missing index on the referencing column, so every part delete scans the link table under lock. Fix: index both foreign key columns of a join table.
Leadership lens
- In design review, ask for each "has" relationship: can the part exist alone, can it be shared, and who may delete it. Those three answers matter more than the diagram symbol.
- Make the removal policy a written decision per relationship; it is the source of most data-loss incidents in this area.
- Shared parts across team boundaries need an owner. The team that owns the catalog owns removals, and consumers must tolerate them.
- Ask for worst-case fan-out: one part in 2 million wholes turns an unlink into a migration.
Check yourself
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.
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.
A track is deleted and the join table has
ON DELETE CASCADEontrack_id. What is deleted?Show answer
The track row and the link rows that reference it. The playlists themselves stay, now without that track.
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
- UML class diagrams (Lucid Software): class diagram relationships, including the aggregation and composition diamonds.
Free resources
- AggregationAndComposition (Martin Fowler): why the hollow diamond has no agreed meaning and what to say instead.
- Constraints, foreign keys section (PostgreSQL docs): the exact behaviour of
RESTRICT,NO ACTION,CASCADEandSET NULL. - Association Table Mapping (Martin Fowler): storing shared many-to-many membership in a link table.
- Object composition (Wikipedia): background on aggregation versus composition across languages and UML.
- What is ownership? (The Rust Book): the ownership rules that force shared parts to be declared with
RcorArc.