CMP-220 · Lesson 4 of 12 · 21 min

Composition and aggregation

Objects rarely work alone. A counting station has lanes, nodes and a schedule; a node has a battery and a radar; an upload has a list of detections. Most of the structure of an object-oriented program lies in these relationships between objects, and choosing them well matters more than any single class. This lesson covers the has-a relationships: association, aggregation and composition. It shows how to draw them in UML, how to implement them in Java, Python and C++, and how to decide which object owns which. Lesson 5 then turns to the is-a relationship, inheritance, and lesson 10 explains why experienced designers usually prefer composition to inheritance.

Has-a relationships

An association is any lasting relationship in which one object knows about another and can call its methods. In the Hub, an Alert is associated with the Node it concerns: the alert needs the node's identifier and location, but the alert does not own the node, and the node does not depend on the alert. Associations are implemented with fields that hold references, or sometimes only identifiers that can be looked up when needed.

Aggregation is an association that expresses a whole and its parts, where the parts can exist independently of the whole. A Station aggregates Nodes: when the city moves a node from the Princess Street station to a temporary construction site, the node object continues to exist, with its history, and simply belongs to a different station. Composition is the stronger form, in which the part belongs to exactly one whole and its lifetime is bound to it. A Station is composed of its Lanes: a lane has no meaning outside its station, it is created when the station is configured and it disappears when the station is deleted.

A UML class diagram. A Station box in the centre has a line ending in a filled diamond at the Station end connecting to a Lane box, labelled 1 at the Station end and 1 to 8 at the Lane end, captioned composition. A line ending in a hollow diamond at the Station end connects Station to a Node box, labelled 0 to 1 at the Station end and 1 or more at the Node end, captioned aggregation. A plain line with an arrowhead connects an Alert box to the Node box, labelled association. A dashed arrow from Station to a Clock box is labelled uses, a dependency.
UML notation for has-a relationships: composition (filled diamond), aggregation (hollow diamond), association (plain line, arrow for navigability) and dependency (dashed arrow).

UML draws an association as a solid line between classes, with an arrowhead to show navigability, that is, which object holds a reference to the other. A hollow diamond at the whole's end marks aggregation, and a filled diamond marks composition. Multiplicities near each end say how many objects take part: 1 for exactly one, 0..1 for optional, * or 0..* for any number, 1..* for at least one, or a range such as 1..8. In the diagram, each Lane belongs to exactly one Station and each Station has from one to eight lanes, and each Node belongs to at most one Station at a time. A dashed arrow marks a weaker dependency: a class uses another temporarily, for example as a method parameter, without holding on to it.

Do not agonize over the difference between aggregation and composition on a whiteboard; many teams draw only plain associations and decide ownership in the code. What matters is the question the diamonds ask: who owns this object, who creates it, who may change it and who decides when it goes away? Answering those questions clearly prevents a large share of design bugs.

Composition in code

In Java, composition usually means that the whole creates its parts inside its own constructor or methods, keeps them in private fields, and never hands out references that would let outsiders change them. Because the parts are private, the whole can maintain invariants that span several parts: the station guarantees that its lanes are numbered 1 to n with no gaps and that no two lanes share a direction and position. Aggregation means the parts are created elsewhere and passed in, and the whole keeps references to objects that others may also know about.

Station composes its Lanes, which it creates and never exposes, and aggregates Nodes, which are created elsewhere and attached.
public final class Station {
    private final String name;
    private final List<Lane> lanes;                 // composition: created and owned here
    private final Set<Node> nodes = new HashSet<>();  // aggregation: nodes come and go

    public Station(String name, List<String> laneDirections) {
        if (laneDirections.isEmpty() || laneDirections.size() > 8)
            throw new IllegalArgumentException("1 to 8 lanes");
        this.name = name;
        List<Lane> built = new ArrayList<>();
        for (int i = 0; i < laneDirections.size(); i++)
            built.add(new Lane(i + 1, laneDirections.get(i)));   // the station creates its lanes
        this.lanes = List.copyOf(built);
    }

    public void attach(Node n) { nodes.add(n); }    // the node already exists elsewhere
    public void detach(Node n) { nodes.remove(n); }

    public int laneCount() { return lanes.size(); }

    public Map<VehicleClass, Integer> countLane(int laneNo, List<Detection> batch) {
        return lanes.get(laneNo - 1).count(batch);  // delegation to the part
    }
}

The last method shows delegation, the basic mechanism of composition: the station does not count vehicles itself but forwards the request to the right Lane object, which knows how. Delegation lets each class stay small and focused. It also gives the whole a single place to enforce rules before or after delegating; here the station could check that the lane number is in range and translate the error into a clear message.

Who creates, who owns, who destroys

Ownership is explicit in C++, where it determines who deletes an object. Composition maps naturally onto members held by value or by std::unique_ptr: the part is destroyed automatically when its owner is. Aggregation and association map onto raw pointers or references that do not own, or onto std::shared_ptr when ownership is genuinely shared. A standard C++ guideline is that a raw pointer never owns. In Java and Python, the garbage collector frees the memory, but ownership still matters for everything else: which object may change the part, which object closes its resources, and which object's invariants it participates in.

In C++, unique_ptr members express composition and non-owning pointers express aggregation.
// C++: ownership is visible in the types
class Station {
public:
    explicit Station(std::string name) : name_(std::move(name)) {}
    void addLane(std::string dir) {
        lanes_.push_back(std::make_unique<Lane>(lanes_.size() + 1, std::move(dir)));
    }
    void attach(Node* n) { nodes_.push_back(n); }   // observes; does not own
private:
    std::string name_;
    std::vector<std::unique_ptr<Lane>> lanes_;     // composition: lanes die with the station
    std::vector<Node*> nodes_;                     // aggregation: someone else owns the nodes
};

Two-way associations deserve extra care. If a Station holds its Nodes and each Node also holds a reference back to its Station, the two references must always agree. Moving a node then requires updating three objects: removing it from the old station, adding it to the new one, and changing the node's own reference. Forgetting one step leaves the object graph inconsistent. Prefer one-way navigation where you can; when both directions are needed, give one class sole responsibility for keeping both ends in step, for example by making Station.attach the only way to set a node's station.

Holding many parts: choosing the container

When a whole has many parts, the field that holds them is a data structure, and the choices from CMP-200 apply directly. A station's lanes are few, numbered and fixed after configuration, so an unmodifiable list indexed by lane number minus one is ideal. Its nodes are looked up by identifier when uploads arrive, added and removed as crews move equipment, and never need an order, so a set, or better a map from node identifier to Node, gives constant-time lookup. A node's recent alerts are most useful newest first and should be bounded, so a deque capped at the last 50 entries fits. Declare such fields with interface types, such as List, Set or Map, and choose the implementation class inside the constructor; lesson 9 explains why that keeps the choice easy to change.

Small composed parts are often best modelled as immutable value objects, such as a GeoPoint record holding a latitude and longitude, or a Direction enum. Because they cannot change, they can be shared freely between wholes without any risk of aliasing bugs, and the question of ownership disappears: nobody needs to own a value that nobody can modify. Mutable parts, such as a node's upload history, are where ownership must be clear.

Talk only to your friends

Composition creates chains of objects, and with chains comes a temptation. Code such as report.station().lane(2).node().battery().volts() reaches through five objects to get one number. Every class in that chain is now a dependency of the caller, so a change to any of them can break it. The Law of Demeter, or principle of least knowledge, suggests that a method should call methods only on its own object, its parameters, objects it creates and its direct fields, not on objects returned by those calls. In practice this means asking the nearest object for what you need, station.lowBatteryNodes() rather than digging, which is Tell, Don't Ask from lesson 2 applied to object graphs.

Passing parts in: dependency injection

Sometimes a class needs a collaborator that should not be fixed when the class is written. The Station needs a clock to decide which 15-minute interval a detection belongs to. If it calls Instant.now() directly, every test depends on the real time of day and cannot check what happens at midnight or at a daylight-saving change. If instead the station receives a Clock object in its constructor, production code passes the system clock and tests pass a fixed clock set to any moment they like. Supplying an object's collaborators from outside is called dependency injection. In its simplest form, constructor injection, it needs no framework at all, and it is the single most useful habit for making classes testable, as lesson 12 shows.

from collections import Counter
from datetime import datetime, timedelta

class Lane:
    def __init__(self, number, direction):
        self.number, self.direction = number, direction
    def count(self, detections):
        return Counter(d["cls"] for d in detections if d["lane"] == self.number)

class Node:
    def __init__(self, node_id):
        self.node_id = node_id
        self.station = None                       # set only by Station.attach

class Station:
    def __init__(self, name, directions, clock):
        self.name = name
        self._lanes = [Lane(i + 1, d) for i, d in enumerate(directions)]  # composition
        self._nodes = set()                                              # aggregation
        self._clock = clock                                              # injected dependency
    def attach(self, node):                       # one place keeps both ends in step
        if node.station is not None:
            node.station._nodes.discard(node)
        self._nodes.add(node)
        node.station = self
    def interval_start(self):
        now = self._clock()
        return now - timedelta(minutes=now.minute % 15, seconds=now.second, microseconds=now.microsecond)
    def count(self, lane_no, detections):
        return dict(self._lanes[lane_no - 1].count(detections))       # delegation

fixed = lambda: datetime(2026, 11, 2, 7, 44, 31)                       # a test clock
princess = Station("Princess St", ["EB", "EB", "WB", "WB"], fixed)
detour = Station("Detour 3", ["NB", "SB"], fixed)
n17 = Node(17)
princess.attach(n17)
detour.attach(n17)                                 # node moves; both ends updated
print("node 17 belongs to", n17.station.name, "| Princess has", len(princess._nodes), "nodes")
batch = [{"lane": 1, "cls": "car"}, {"lane": 1, "cls": "car"}, {"lane": 1, "cls": "bus"}, {"lane": 3, "cls": "car"}]
print("lane 1 counts:", princess.count(1, batch))
print("interval starts at", princess.interval_start().strftime("%H:%M"))
Output
node 17 belongs to Detour 3 | Princess has 0 nodes
lane 1 counts: {'car': 2, 'bus': 1}
interval starts at 07:30

Worked problem

Choosing relationships for an upload

Each upload from a node contains a header (node identifier, firmware version, battery voltage, upload time) and a list of up to 2,000 detections. The Hub keeps uploads for 90 days for auditing, keeps detections for 10 years, and keeps nodes indefinitely. Decide whether Upload should compose or aggregate its detections and how it should refer to its node.

Show solution
  1. Lifetimes differ: detections outlive uploads by years, so an upload cannot own them in the composition sense; deleting an old upload must not delete its detections.
  2. Use aggregation, or simply an association from each detection to the upload's identifier, so that detections can be stored and queried independently.
  3. The header fields are simple values that live and die with the upload: plain fields of Upload, or a small composed Header record.
  4. The node lives indefinitely and is shared by thousands of uploads; Upload should hold the node's identifier, or a reference to the shared Node object, never its own copy.

Answer: Upload composes its header, aggregates (or merely references) its detections, and associates with its node by identifier. Matching relationships to lifetimes keeps deletion and storage rules simple.

Common mistakes

  • MistakeUsing composition when the part must outlive or move away from the whole.

    InsteadMatch the relationship to lifetimes: compose only parts that live and die with their owner.

  • MistakeExposing a composed part through a getter so callers can change it.

    InsteadKeep composed parts private and offer methods on the whole, so it can protect invariants that span its parts.

  • MistakeKeeping two-way references that can disagree.

    InsteadPrefer one direction; if both are needed, make one method responsible for updating both ends.

  • MistakeChaining calls through many objects to reach data.

    InsteadAsk the nearest object for what you need (Law of Demeter), so changes deep in the graph do not ripple outward.

  • MistakeCreating collaborators such as clocks or databases inside a class.

    InsteadInject them through the constructor so tests and other environments can supply their own.

Summary and key terms

  • Association is any lasting has-a link; aggregation is a whole–part link with independent parts; composition binds a part's lifetime to one owner.
  • UML shows these with lines, hollow and filled diamonds, arrowheads for navigability and multiplicities such as 1, 0..1 and 1..*.
  • Composition is implemented by creating parts internally, keeping them private and delegating work to them.
  • In C++, unique_ptr or value members express ownership and raw pointers observe; in Java and Python, ownership still governs who may change and close an object.
  • Keep object graphs simple: one-way navigation, the Law of Demeter, and constructor injection of collaborators.
Association
A lasting relationship in which one object holds a reference to, and uses, another.
Aggregation
A whole–part association in which the parts can exist independently of the whole.
Composition
A whole–part association in which each part belongs to one whole and shares its lifetime.
Multiplicity
The number of objects that can take part at one end of an association, such as 1 or 0..*.
Delegation
An object forwarding a request to another object that does the work.
Dependency injection
Supplying an object's collaborators from outside, for example through its constructor.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. A building has rooms; a university has professors who can move to another university. Which is composition and which is aggregation?

    Show answer

    Building–room is composition; university–professor is aggregation.

  2. What does a multiplicity of 1..* at the Node end of Station–Node mean?

    Show answer

    Each station has at least one node, possibly many.

  3. Rewrite station.lane(2).node().battery().volts() < 11.5 in the spirit of the Law of Demeter.

    Show answer

    Ask the station directly, for example station.hasLowBattery(2) or station.lowBatteryNodes().

  4. Why should a Station receive a Clock rather than call Instant.now()?

    Show answer

    So tests can supply a fixed time and check behaviour at specific moments, and the station does not depend on the real clock.

  5. In C++, which member type expresses composition: Lane*, std::unique_ptr<Lane> or std::shared_ptr<Lane>?

    Show answer

    std::unique_ptr<Lane> (or a Lane held by value).

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz