skip to content

Association, Aggregation & Composition

The strength scale of HAS-A links: knowing about another type, sharing a part, and owning a part whose lifetime you control. A classic modeling-vocabulary check in interviews.

on this pageshow

questions

3

Modelling notations mark a part whose lifetime is owned by its whole (UML's filled diamond, composite aggregation) differently from a part that is merely shared and can outlive the whole (the hollow diamond). In which languages is that distinction actually enforced by the compiler or runtime, and where is it only a comment?

level: middleimportance: should knowfreq 62%

answer

  1. Diamond sits at the whole
  2. C++ value member = destructor cascade
  3. Rust: move + Drop vs Rc vs borrow
  4. Swift ARC: strong = ownership, no cycle collector
  5. GC languages: teeth only for close/Dispose

basics

~20 s

Only where the language has ownership semantics. C++ value members and Rust's move-and-Drop destroy the part with the whole. Swift's ARC leaks if you model it wrong. Under Java, C#, Python or Go a tracing collector frees whatever is unreachable, so the diamond is documentation.

solid answer

~50 s

Composition is a lifetime claim; whether the toolchain hears it depends on the language. - **C++**: a by-value member (`Engine engine;`) is destroyed by the whole's destructor, so composition is a compile-time fact, while a `shared_ptr` member visibly says the other thing. Cost: you must define copy and move semantics. - **Rust**: the part is moved into the whole and dropped with it; a shared part needs `Rc`/`Arc`, a plain association a borrow with a lifetime. Cost: back-references are illegal without `Weak` or index tables. - **Swift**: a strong reference *is* ownership, and ARC has no cycle collector, so mis-modelling composition leaks memory rather than merely misleading a reader. - **Java, C#, Python, Go**: a tracing collector frees the unreachable and all three relationships compile to the same field. Enforcement survives only for non-memory resources: the owner cascades `close`/`Dispose`, the aggregator must not.

code

cpp · 11 lines
cpp
struct Car {
  Engine engine;                            // filled diamond, enforced:
};                                          // ~Car destroys the engine

struct Fleet {
  std::vector<std::shared_ptr<Car>> cars;   // hollow diamond: cars can
};                                          // outlive the fleet

struct Trip {
  Car* car;                                 // association: no claim
};

go deeper

for a junior

Be able to state the three strengths with one example each and where the diamond goes. Knowing that a plain field says nothing about lifetime in a garbage-collected language is already a good answer at this level.

for a middle

Show the mapping to code in at least two languages with different memory models, and name the resource-disposal consequence: the owner closes, the aggregator does not.

for a senior

Argue the enforcement spectrum - checked ownership (Rust), destructor cascade (C++), refcount ownership (Swift), reachability (JVM/CLR/Go/Python) - and say what each costs the modeller.

for a principal

Frame it as where a design claim should be recorded so it can be verified: type system, disposal chain, persistence mapping, or code review. Prose-only ownership is a claim nobody can enforce and everybody will violate.

## The three strengths of a link All three are "object A holds a reference to object B"; they differ in the claim the model makes about B's lifetime and exclusivity. - **Association** - A knows about B so it can call it. No lifetime claim, no ownership. A trip knows its car. - **Aggregation (shared)** - B is treated as a part of A for the purposes of A's behaviour, but B exists independently, may be part of several wholes, and outlives any one of them. A fleet aggregates cars. - **Composition (UML: composite aggregation)** - A owns B exclusively. B belongs to at most one whole at a time and does not outlive it. A car is composed of an engine; delete the car and the engine goes with it. ## What the notation records On a UML class diagram a plain line is an association. An arrowhead marks **navigability** - which end can reach the other, and it is per end, so a link can be one-way. Numbers at each end are **multiplicity** (`1`, `0..1`, `*`, `1..*`), and constraints such as `{ordered}` or `{unique}` at the many end decide which collection type the code should use. A hollow diamond at the whole means shared aggregation; a filled diamond means composite aggregation. The diamond always sits at the *whole*, never at the part. ## Where the claim becomes executable The interesting question is not the notation but who enforces it. **C++** encodes it in the declaration. `Engine engine;` is a subobject: its destructor runs as part of the enclosing destructor, its storage is inside the whole, and it cannot be shared. `std::unique_ptr<Engine>` is the same claim with heap storage and a movable owner. `std::shared_ptr<Engine>` or a raw pointer is aggregation or association. The compiler therefore distinguishes all three, and the price is that you now own copy, move and slicing decisions for the whole. **Rust** goes further because ownership is checked. A `struct Car { engine: Engine }` moves the engine in; when the car is dropped, the engine is dropped, and the borrow checker refuses to let a reference to the engine outlive the car. Sharing requires `Rc`/`Arc` explicitly, and a non-owning link is `&'a Engine` with a lifetime that must be provably shorter than the owner's. The whole model is visible in the type. The price is real: a part that needs to point back at its whole cannot be expressed with plain ownership at all. **Swift** uses automatic reference counting. Strong references keep an object alive and there is no tracing collector to clean up cycles, so a wrongly modelled relationship is not a documentation defect but a permanent leak. `weak` and `unowned` are how you say "association, not ownership", and the compiler makes you choose. **Java, C#, Kotlin, Python, Go, JavaScript** all use tracing (or, in CPython's case, refcounting plus a cycle collector) and none of them treats a field as an ownership statement. `private Engine engine;`, `private List<Car> cars;` and `private Car car;` are the same construct; the object dies when nothing can reach it, regardless of your diagram. So in these languages the diamond is a comment - with one important exception. ## The exception: non-memory resources Garbage collectors manage memory, not file handles, sockets, connections or native buffers. For those, composition has teeth even in Java or C#: the owner of a resource must release it (`AutoCloseable`/try-with-resources in Java, `IDisposable` and cascading `Dispose` in C#, `defer x.Close()` behind `io.Closer` in Go, `__exit__` in a Python context manager), and a class that merely *aggregates* a resource it did not create must **not** close it. A large share of "stream closed" and "connection already returned to the pool" bugs are exactly an aggregation modelled and coded as a composition. So even where the compiler is indifferent, deciding the diamond decides who calls close. ## How to use this in practice First ask whether the part can be handed to a second whole, and whether it is meaningful once the whole is gone. Two noes mean composition. Then ask what your language will do about it: in C++ or Rust, express it in the type and get it checked; in Swift, express it in the strength of the reference; in a GC language, express it wherever a tool can execute it - the disposal chain, and (see the persistence question in this topic) the cascade rules of your ORM. Anywhere else it is prose, and prose does not stop a caller from keeping the part alive.

  • If a garbage collector ignores the distinction, is modelling composition in Java or C# a waste of time?
    No, it moves from memory to resources and invariants. The owner is the one that must close or dispose the part, and the one that must copy it defensively rather than hand out an alias. It also decides cascade behaviour in persistence mappings and serialization boundaries. What you lose is compiler enforcement, not the design content.
  • Where does Go sit, given it is garbage collected but has no destructors at all?
    Go has tracing GC, so memory follows reachability exactly as on the JVM, and it deliberately has no destructors - `runtime.SetFinalizer` is discouraged and unordered. So a composed part's cleanup is always explicit: the owner holds an `io.Closer` and calls `defer part.Close()`. Go therefore makes the ownership question unavoidable in code while giving you no type-level support for it.

A car and its chassis number are inseparable; a car and its driver are not. In C++ or Rust the difference is welded into the frame; in a GC language it is a sticker on the windscreen.

saying these in an interview costs you the question

  • Saying composition means the part is created in the constructor - construction site is a hint, not the claim; the claim is about exclusivity and lifetime.
  • Claiming a Java or C# field 'guarantees' the part dies with the whole; only unreachability frees it, and a leaked alias keeps it alive.
  • Putting the diamond at the part instead of the whole, or reading the hollow diamond as 'weaker composition' with defined semantics.
  • Treating `shared_ptr` everywhere in C++ or `Rc` everywhere in Rust as the safe default - it converts every composition into an aggregation and hides ownership entirely.
  • Assuming reference counting and tracing behave the same; in Swift a mis-modelled ownership cycle leaks forever.

context

open as a page

An association is navigable in both directions: an order knows its customer and the customer knows their orders. What does maintaining that two-way link actually cost, and why is the same shape far harder in some languages than in others?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Two costs: an invariant (both ends must always agree, so one mutator owns both writes) and a reclamation cost that depends on the runtime. Java, C# and Go collect the cycle for free; Swift and C++ shared_ptr leak unless one end is weak; Rust rejects the shape and pushes you to Weak or index tables.

open as a page

Take a whole-part relationship where the part is owned by the whole and dies with it, and assume the in-memory expression of that ownership is already settled. The same claim still gets restated in three other places: the persistence mapping's delete rules (JPA's `cascade` and `orphanRemoval`, Django's mandatory `on_delete` argument, Entity Framework Core's required-versus-optional dependents), the disposal chain (C# `using`/`IDisposable`, Java try-with-resources, Python context managers), and the serialization format (embedding the part's body versus emitting its id). What concretely breaks when those three disagree, and which one do you make authoritative?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Ownership is restated in the mapping's delete rules, in who closes the part, and in whether the wire format embeds it or references it by id. Three engines enforce those independently, so they drift. Make one authoritative and derive the others.

open as a page