Why class relationships are the grammar of LLD
When you do high-level system design, you reason about boxes: services, queues, databases. In low-level design (LLD) you zoom into a single service and reason about classes and how they connect. Those connections are not arbitrary — there is a small, precise vocabulary for them, and interviewers use it as a proxy for whether you actually understand object modeling or are just drawing lines between rectangles.
There are four relationships you must be able to name, draw, and code:
| Relationship | Informal name | Lifetime coupling | UML arrow |
|---|---|---|---|
| Association | ”uses-a” / “knows-a” | independent | plain line —— |
| Aggregation | ”has-a” (whole/part) | independent (part may outlive whole) | hollow diamond ◇— |
| Composition | ”owns-a” (exclusive) | child dies with parent | filled diamond ◆— |
| Dependency | ”depends-on” (transient) | none (method-scoped) | dashed arrow - -> |
The single axis that separates these is lifetime and ownership: who creates the object, who holds a reference to it, and who is responsible for destroying it. Get that axis right and the rest of your design tends to fall into place.
The interview-grade distinction is not “has-a vs uses-a” in the abstract — it’s whether the contained object can outlive its container. Composition: no, it dies with the owner. Aggregation: yes, it was passed in and lives on. State this lifetime rule out loud and you instantly sound senior.
A subtle trap that separates mid-level from senior answers: aggregation is about independent lifetime, not about sharing. People often say “aggregation means the part is shared.” Sharing is a separate, orthogonal axis. A part can be aggregated (injected, outlives the whole) while being referenced by exactly one whole and never shared. Keep these two ideas apart — we will come back to it.
1. Association — “uses-a” / “knows-a”
An association is a structural, long-lived link between two objects where neither owns the other. Both exist independently; one simply holds a reference to the other so it can collaborate with it. The classic example is a many-to-many or one-to-one peer relationship.
Think Student ↔ Course: a student enrolls in courses, a course has students. Neither creates or destroys the other. Deleting a course should not delete the student.
Student ──────────── Course
enrolledIn
Association is often bidirectional (both sides hold references) but can be unidirectional. The defining trait: a persistent reference held as a field, with independent lifetimes.
class Course {
constructor(public readonly title: string) {}
}
class Student {
// Long-lived reference -> association. Course is NOT created or owned here.
private courses: Course[] = [];
enroll(course: Course): void {
this.courses.push(course);
}
}
const algorithms = new Course("Algorithms");
const harsh = new Student();
harsh.enroll(algorithms); // both exist independentlyIn the C++ version, Student holds Course* (a non-owning pointer) and never calls delete on it. A non-owning handle is the code signature of association — but the raw pointer is only one such handle. A reference member (Course&), std::weak_ptr<Course>, or gsl::not_null<Course*> are equally valid (often preferable) encodings.
A raw observer pointer like Course* carries a real dangling-pointer hazard: if the Course is destroyed before the Student, the stored pointer is now invalid and dereferencing it is undefined behavior. If the lifetimes are not guaranteed by surrounding structure, prefer std::weak_ptr<Course> (which you can lock() and check) over a raw pointer.
Reflexive (self) associations
An association can connect a class to itself. An Employee who has a manager: Employee, or a TreeNode that holds children: TreeNode[], is a reflexive association. It is still just a stored field between independently-lived objects — the only twist is that both ends are the same type, which interviewers love to probe (“how do you model an org chart?“).
2. Aggregation — “has-a” with independent lifetime
Aggregation is a specialized association implying a whole–part “has-a” relationship — but the part can exist without the whole. The whole holds parts it did not create; the parts were handed to it from outside.
The defining property of aggregation is independent lifetime, not shared ownership. A part may be aggregated by exactly one whole and never shared with anyone. Whether the part is also shared among several wholes is an orthogonal axis layered on top.
Canonical example: a Team has Players. Players exist before the team is formed and continue to exist (and may join other teams) after the team disbands. The team aggregates players; it does not own their existence.
Team ◇──────────── Player
aggregates
The tell-tale code signature: the part is passed in via the constructor or a setter (dependency injection), rather than being new-ed inside.
class Player {
constructor(public readonly name: string) {}
}
class Team {
private players: Player[] = [];
// Players are INJECTED -> aggregation. Team did not create them.
addPlayer(p: Player): void {
this.players.push(p);
}
}
const messi = new Player("Messi");
const team = new Team();
team.addPlayer(messi);
// team going out of scope does NOT destroy messiChoosing the C++ encoding: ownership is a separate decision
Because aggregation is defined by independent lifetime rather than ownership, the smart-pointer choice depends on who actually owns the part:
| Modeling intent | C++ encoding |
|---|---|
| Whole observes a part it does not own (most aggregation) | Player* raw observer, Player&, or std::weak_ptr<Player> |
| Part is genuinely shared-owned by several wholes | std::shared_ptr<Player> |
std::shared_ptr models shared ownership — a stronger statement than aggregation requires. It is the right tool for the shared-ownership sub-case, but presenting it as “the encoding of aggregation” overstates things: a non-owning observer (weak_ptr or a checked raw pointer) faithfully models the more common non-owning aggregation.
std::shared_ptr aggregation can leak via cycles: if Team holds shared_ptr<Player> and Player holds shared_ptr<Team> back, neither refcount ever reaches zero and both objects leak. The canonical fix is to make one direction non-owning with std::weak_ptr, which breaks the ownership cycle while preserving navigability. Mentioning this unprompted reads as senior.
The fastest way to spot aggregation in code: the object is received as a parameter (constructor or method). If you see addPlayer(p: Player), the part predates the whole — that’s aggregation. If you see this.engine = new Engine() inside the constructor, that’s composition.
3. Composition — “owns-a” with exclusive lifetime
Composition is the strong form of “has-a”: the whole exclusively owns its parts and controls their lifetime. When the whole is destroyed, its parts are destroyed too. Parts are not shared with anyone else.
Canonical example: a Car is composed of an Engine. The car creates its engine, owns it, and when the car is scrapped the engine goes with it. You don’t pass a pre-existing engine into the car from the outside world (in this modeling); the car builds it.
Car ◆──────────── Engine
owns (exclusive)
Code signature: the part is created inside the owner (constructor) and never escapes by reference.
class Engine {
start(): void { /* ... */ }
}
class Car {
// Created internally and owned exclusively -> composition.
private readonly engine = new Engine();
ignition(): void {
this.engine.start();
}
// Note: we never return 'engine' to the outside world.
}
const car = new Car();
// When 'car' is unreachable, its engine becomes unreachable too.C++ makes this crisp: std::unique_ptr means exclusive ownership, the textbook encoding of composition. When the Car is destroyed, the unique_ptr member is destroyed, which deletes the Engine. There is no way to share it (you can’t copy a unique_ptr), which enforces the “exclusive” part at compile time.
Lifetime in GC and refcounted languages
In garbage-collected and reference-counted languages (TS / Python) the lifetime rule is semantic, not enforced: composition is signaled by creating the part internally and never leaking a reference to it.
The Python case deserves precision because interviewers will test it:
- CPython destroys the
_enginedeterministically when its reference count drops to zero. That happens the instant the owningCar’s last reference is gone, provided nothing else still references the engine. This is refcounting, not the cyclic garbage collector. - If you introduce a back-reference — e.g.
self._engine.car = selfso theEnginepoints back at theCar— you create a reference cycle. Now neither refcount reaches zero on its own, and collection is deferred to the cyclic GC, whose timing is non-deterministic. This is exactly why composition with back-references needs care.
A common interview trap: candidates say “composition” but then write a getter that returns the internal object, letting callers hold and mutate it after the owner is gone. That leaked reference means the part can outlive the whole — which is aggregation, not composition. To keep composition intact, return a copy or a const reference, never a mutable handle to the owned part. Match your words to your code.
4. Dependency — “depends-on” (transient, method-scoped)
A dependency is the weakest relationship: class A depends on class B if A merely uses B transiently — as a method parameter, a local variable, or a return type — without holding B as a field. The relationship exists only for the duration of a method call.
Example: a Report depends on a Printer that’s passed to its render method. The printer is not stored; once the method returns, the relationship is gone.
Report - - - -> Printer
(method param)
The difference between dependency and association is purely structural: association keeps a field (persistent reference); dependency does not — it touches the other class only within a method’s scope.
interface Printer {
print(text: string): void;
}
class Report {
constructor(private readonly body: string) {}
// Dependency: 'printer' is a parameter, never stored as a field.
render(printer: Printer): void {
printer.print(this.body);
}
}Dependencies are everywhere and usually fine — but a heavy web of dependencies (many concrete types instantiated inside methods) is a smell. That’s exactly what the Dependency Inversion Principle (DIP) and dependency injection target: turn new ConcreteThing() inside a method into an injected abstraction you depend on via a constructor parameter. Note the throughline: a method-scoped dependency on an abstraction often gets “promoted” to a constructor-injected aggregation when you apply DIP.
Three of four in one class (plus association)
Modeling a ride-hailing Trip shows three relationships compactly, and we then add an explicit association so all four are actually present in code — not just described in a comment. Read the annotations; this is the kind of reasoning that earns points in an interview.
class Rider {} // exists independently
class Driver {} // exists independently
class City {} // long-lived peer object, owned by nobody here
class Route {} // meaningless without a Trip
interface PricingStrategy { price(km: number): number; }
class Trip {
// COMPOSITION: the route is created and owned by the trip; dies with it.
private readonly route = new Route();
constructor(
// AGGREGATION: rider/driver are injected; they outlive the trip.
private readonly rider: Rider,
private readonly driver: Driver,
// ASSOCIATION: a stored reference to a long-lived peer the Trip neither
// creates nor owns. Persistent field + independent lifetime = association.
private readonly city: City,
) {}
// DEPENDENCY: strategy is used transiently inside a method, not stored.
fare(distanceKm: number, strategy: PricingStrategy): number {
return strategy.price(distanceKm);
}
}Notice that association and aggregation can use the same pointer type in C++ (city_ is const City*, and a non-owning rider could be too). That is the crux of issue many candidates get wrong: the difference between association and aggregation is not the pointer type — it is the whole/part “has-a” semantics. A Trip knows-a City (peer) but has a Rider (part of the trip). The smart-pointer choice encodes ownership; the modeling intent (peer vs whole/part) lives in your head and your diagram.
A decision checklist you can recite in an interview
Ask these in order when modeling any link between two classes:
- Is the reference stored as a field?
- No → it’s a dependency (parameter/local/return). Done.
- Yes → continue.
- Did this object create the other one, or was it injected?
- Created internally and never leaked → composition (exclusive, dies together).
- Injected (constructor/setter param) → continue.
- Is there a whole/part “has-a” feel, with independent lifetime?
- Yes → aggregation (and separately ask: is it shared-owned? that decides
shared_ptrvs observer). - No, it’s a peer “knows-a” link → plain association.
- Yes → aggregation (and separately ask: is it shared-owned? that decides
Aggregation vs composition is a modeling decision, not an absolute truth. The same Engine could be composition in one design (car builds its own engine) and aggregation in another (a warehouse of engines installed into cars). What matters is that you state your lifetime assumption and code consistently with it.
Why interviewers care
- Correct ownership prevents memory and lifecycle bugs. In C++ this is concrete: pick
unique_ptrfor composition, and for aggregation pick an observer (weak_ptr/ checked raw pointer) orshared_ptronly when ownership is truly shared — and reach forweak_ptrto break cycles. In GC/refcounted languages the same discipline shows up as “don’t leak the composed part via a getter.” - The right vocabulary signals seniority. Saying “this is aggregation because the part is injected and has independent lifetime, and it’s not shared so I’ll model it as a non-owning observer” tells the interviewer you reason about lifetime and ownership as separate axes.
- It drives extensibility. Promoting a hard-coded dependency (
new Concrete()) into an injected abstraction (DIP) is the move that makes a design testable and open for extension — and it is literally a transition from dependency to aggregation.
In an interview, always narrate the lifetime question explicitly: “Who creates this? Who destroys it? Can the part outlive the whole?” That single sentence converts a vague “has-a” answer into a senior-level one.