Engineering Atlas › Low-Level Design

Composition: owned parts

10 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem: parts that escape

A refund feature needs "the invoice's lines with negative amounts", so a developer passes invoice.lines to a new credit note and flips the signs. The original invoice now shows negative amounts too, because both hold the same list. The lines were meant to belong to one invoice, but the code let them be shared. Composition is the decision, stated and enforced, that they cannot be.

What ownership means in code

A composite whole does four things that an aggregate does not:

Removing a part from the whole ends its life too; JPA's orphanRemoval = true deletes a line dropped from the collection.

Lifecycle and cascading delete

Lifecycle is the main payoff. A Connection that owns a Socket and a Buffer closes both in close(), so no caller can forget a step. In a database the rule is a foreign key with ON DELETE CASCADE from order_line.order_id to order.id. Cascades are only safe along ownership edges; following one into a shared table destroys data other wholes still use.

Composition over inheritance

Inheritance couples a subclass to its parent's internals: which methods call which, in what order. A class CountingList(list) that overrides append to count items misses everything added through extend, because CPython's extend does not call append. Java's classic version is a HashSet subclass that double-counts, because the base addAll does call add. That is the fragile base class problem. The Gang of Four put it as "favour object composition over class inheritance".

The composed alternative owns a list and exposes only the operations it supports. It costs a few forwarding methods, depends only on the list's public contract, and can vary behaviour at runtime by swapping an owned collaborator.

Go has no inheritance. Embedding promotes methods, but the receiver stays the inner value, so the outer type cannot override what the inner one calls on itself.

When not to compose

If the part has an identity outside the whole (a customer on an order), it is not owned. If it is a large immutable value, sharing it is cheaper and safe.

Reviewing agent-written code

Agents tend to return the live list from getters, accept pre-built parts in constructors, and write shallow clone methods. Check that parts are built inside the whole, getters return copies, clone is deep along owned fields, and every cascade follows an ownership edge.

The same shape elsewhere

A Kubernetes Deployment owns its ReplicaSets, which own their Pods, through ownerReferences, and deleting it garbage-collects the tree. A process owns its file descriptors, which the kernel closes at exit. Ownership is a tree, and cleanup walks it.

Down the stack

Layer 1The contract

Every part has exactly one owner for its whole life. No reference to a part is reachable except through its owner. Deleting, closing or cloning the owner applies the same operation to all of its parts, and after the owner is gone no part is observable.

Layer 2The mechanism

C++ and Rust encode composition in the type system. A member object is constructed with the whole and destroyed with it: C++ runs member destructors in reverse order of declaration, Rust drops fields in declaration order. std::unique_ptr<T> and Rust's Box<T> own heap parts and free them with the owner; Rust's borrow checker rejects code that keeps a reference to a part past its owner's life.

Go stores a struct-typed field inline, and a []Line slice holds lines contiguously. Assigning the whole copies inline fields, but a slice field copies only its 24-byte header (pointer, length, capacity), so the copy shares the backing array. A correct clone must allocate a new slice and copy into it.

Java, Python, JavaScript hold every object field as a reference, so ownership lives in your API: private fields, copies at the boundary, and close() with try-with-resources or with for non-memory resources.

Databases. Postgres implements ON DELETE CASCADE with internal referential-integrity triggers: deleting an order row fires a trigger that deletes matching order_line rows, which may fire further cascades. Each deleted row is a separate WAL record and row lock, all in the caller's transaction.

Layer 3The machine

Embedding by value changes memory layout. A Go []Line of 100 32-byte lines is one 3.2 KB block, 50 cache lines the prefetcher streams; summing them takes well under a microsecond. The same lines as []*Line are 100 separate heap objects, each a potential cache miss of about 100 ns, plus 100 objects for the garbage collector to trace. Composition is what makes the value layout safe, because nobody else holds a pointer into the slice.

Ownership also simplifies locking: if parts are reachable only through their owner, one mutex on the owner guards the tree, as long as no part reference escapes.

The cost appears at delete time: cascading one order with 10,000 lines writes 10,000 WAL records and holds 10,000 row locks until commit.

Why it's built this way

  1. Parts reachable from several places can be changed or freed from several places, so each gets one owner.
  2. With one owner, lifecycle becomes a tree walk: create, copy and destroy follow the ownership edges.
  3. A tree with no escaping references needs only one lock at the root and can be laid out contiguously.
  4. Runtimes that cannot see ownership leave it to the API, so private fields and defensive copies carry the rule.

Diagram

stateDiagram-v2
  [*] --> Created: order.add_line()
  Created --> Updated: order.set_quantity()
  Updated --> Updated: order.set_quantity()
  Updated --> Removed: order.remove_line()
  Created --> Removed: order.remove_line()
  Updated --> Removed: delete order (cascade)
  Created --> Removed: delete order (cascade)
  Removed --> [*]

Worked example

A warehouse system stores 4 million orders with an average of 6 lines each, 24 million line rows. Orders older than seven years must be purged, about 300,000 orders a month.

The first job ran one monthly DELETE FROM orders WHERE created_at < $1. The cascade removed about 1.8 million lines in a single transaction, generated gigabytes of WAL, and lagged the replicas for 40 minutes.

Composition made the fix straightforward. Because lines belong to exactly one order and nothing else references them, the job deletes in batches of 1,000 orders per transaction. Each batch removes about 6,000 lines, commits in around 200 ms, and replica lag stays under a second. No orphan check is needed, because no line is shared; a shared table such as products would need a reference check before every delete.

Trade-offs

OptionStrengthsWeaknessesChoose when
Composition (owned parts)One lifecycle, one lock, safe value layoutParts cannot be shared; deep copies costParts mean nothing outside the whole
Aggregation (shared parts)No duplication, parts outlive wholesRemoval policy, shared mutationParts have their own identity and users
InheritanceLittle code for true is-a hierarchiesFragile base class, fixed at compile timeStable framework base classes designed for extension
Composition with delegationBehaviour swappable at runtime, small contractsForwarding boilerplateVarying behaviour by combination or configuration

Pitfalls in production

Leadership lens

Check yourself

  1. Why can a composite whole protect all of its parts with a single lock?

    Show answer

    Because no reference to a part exists outside the whole, every access goes through the whole. Holding the whole's lock therefore serialises all access to the parts.

  2. A Go Order struct has a Lines []Line field. Why does copy := *order not produce an independent order?

    Show answer

    The struct copy duplicates the slice header, not the backing array, so both orders share the same lines. A clone must allocate a new slice and copy the elements.

  3. Why is it safe to batch a cascading delete for orders but risky for products?

    Show answer

    Order lines belong to one order and are referenced nowhere else, so deleting them cannot affect other data. Products are shared by many orders, so each delete needs a reference check.

  4. A subclass of a list overrides append to count items. Why might the count be wrong after extend?

    Show answer

    Whether the base class's extend calls append is an internal detail. In CPython it does not, so the count misses items; a base that did would double-count if the subclass also counted in extend.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →