Back to the path
Level 0 intermediate 24 min #lld#oop#class-relationships#uml#composition#aggregation#association#dependency#object-modeling#ownership

Class Relationships: Association, Aggregation, Composition & Dependency

Master the four ways classes connect — uses-a, has-a, owns-a, and depends-on — with a rigorous focus on lifetime and ownership semantics in TypeScript, Python, and C++. Learn the precise vocabulary interviewers expect, why aggregation means independent lifetime (not sharing), and how to encode each relationship correctly.

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:

RelationshipInformal nameLifetime couplingUML arrow
Association”uses-a” / “knows-a”independentplain line ——
Aggregation”has-a” (whole/part)independent (part may outlive whole)hollow diamond ◇—
Composition”owns-a” (exclusive)child dies with parentfilled 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.

Key idea

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 StudentCourse: 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 independently

In 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.

Watch out

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 messi

Choosing 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 intentC++ 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 wholesstd::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.

Common pitfall

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.

Tip

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 _engine deterministically when its reference count drops to zero. That happens the instant the owning Car’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 = self so the Engine points back at the Car — 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.
Watch out

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:

  1. Is the reference stored as a field?
    • No → it’s a dependency (parameter/local/return). Done.
    • Yes → continue.
  2. 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.
  3. Is there a whole/part “has-a” feel, with independent lifetime?
    • Yesaggregation (and separately ask: is it shared-owned? that decides shared_ptr vs observer).
    • No, it’s a peer “knows-a” link → plain association.
Key idea

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_ptr for composition, and for aggregation pick an observer (weak_ptr / checked raw pointer) or shared_ptr only when ownership is truly shared — and reach for weak_ptr to 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.

Assessment

Pass mark: 70% on the concept questions unlocks the next lesson.

1. A `Garage` class holds `private readonly toolbox = new Toolbox();` (created internally, never returned by any getter) and also exposes `addCar(car: Car)` that pushes injected `Car` objects into a list. Which relationships are these, respectively?

2. What is the DEFINING UML property that distinguishes aggregation from composition?

3. A `Report` class calls `printer.print(body)` where `printer` is a method PARAMETER that is never stored as a field. The relationship between `Report` and `Printer` is:

4. Which statements about modeling a non-owning aggregation in C++ are accurate? (Select all that apply.) (select all)

Design problem 5

Model a small Library domain with classes `Library`, `Book`, `Member`, and `Loan`, plus a `LateFeePolicy` strategy. Your model must demonstrate ALL FOUR relationships — association, aggregation, composition, and dependency — and you must state, for each, WHO creates the object and WHO controls its lifetime. Pick a sketch language (TS, Python, or C++) and annotate each relationship.