Composition: owned parts
Open in the interactive app: practice, quiz, mock interview →In one minute
- Composition is the strong whole-part relationship: the whole owns its parts, creates them, and destroys them. An order owns its order lines; delete the order and the lines go with it.
- A part belongs to exactly one whole at a time and is never handed out for others to keep. Copying the whole copies the parts.
- Ownership gives you one place for lifecycle (create, validate, close, delete) and one lock for concurrency, because nothing outside the whole can reach the parts.
- "Composition over inheritance" is the same idea applied to behaviour: build a class from owned collaborators instead of inheriting from a base class you cannot see into.
- Go and C++ can embed parts by value and Rust enforces single ownership; in Java, Python and JavaScript composition is a discipline.
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:
- Creates its parts.
order.add_line(sku, qty, price)builds the line inside, so no outside reference exists. - Guards its parts. Readers get ids or copies; mutation goes through the whole, which keeps invariants such as the total.
- Copies its parts. Cloning an order deep-copies its lines with new identities.
- Destroys its parts. Deleting the whole deletes every part. There is no orphan state.
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
- Parts reachable from several places can be changed or freed from several places, so each gets one owner.
- With one owner, lifecycle becomes a tree walk: create, copy and destroy follow the ownership edges.
- A tree with no escaping references needs only one lock at the root and can be laid out contiguously.
- 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
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Composition (owned parts) | One lifecycle, one lock, safe value layout | Parts cannot be shared; deep copies cost | Parts mean nothing outside the whole |
| Aggregation (shared parts) | No duplication, parts outlive wholes | Removal policy, shared mutation | Parts have their own identity and users |
| Inheritance | Little code for true is-a hierarchies | Fragile base class, fixed at compile time | Stable framework base classes designed for extension |
| Composition with delegation | Behaviour swappable at runtime, small contracts | Forwarding boilerplate | Varying behaviour by combination or configuration |
Pitfalls in production
- Getters return the live list of parts, and callers mutate or keep them. Fix: return copies, immutable views or ids.
- Shallow
cloneof a composite, so the copy shares parts with the original. Fix: deep-copy owned fields, and test that mutating the copy leaves the original unchanged. - Cascade delete configured on an edge that is not ownership. Fix: cascade only from owner to owned; use restrict elsewhere.
- One giant cascading delete holding locks and flooding replication. Fix: delete in batches or soft-delete and reclaim later.
- Inheriting from a collection type to add behaviour. Fix: own the collection and expose only the operations you support.
Leadership lens
- Ownership decides what a delete removes. Require it to be explicit in design review for every "has" relationship.
- Ask how large the biggest composite can grow. Unbounded trees make deletes, copies and locks unbounded too.
- Prefer composition in shared libraries. A base class invites every team to subclass it, and then its internals can never change.
- Data retention and GDPR deletion depend on accurate ownership trees.
Check yourself
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.
A Go
Orderstruct has aLines []Linefield. Why doescopy := *ordernot 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.
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.
A subclass of a list overrides
appendto count items. Why might the count be wrong afterextend?Show answer
Whether the base class's
extendcallsappendis 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 inextend.
Watch
- Composition over Inheritance (Fun Fun Function): a short, concrete case for building objects from parts instead of class hierarchies.
- The Flaws of Inheritance (CodeAesthetic): how inheritance couples code and how composition with interfaces avoids it.
Free resources
- Composition over inheritance (Wikipedia): the principle, its origin in the Gang of Four book, and its costs.
- Object composition (Wikipedia): composition versus aggregation in UML and across languages.
- DDD Aggregate (Martin Fowler): consistency boundaries built from owned objects, a close cousin of composition.
- Drop trait (Rust std docs): how owned fields are destroyed, including drop order.
- Go Slices: usage and internals (Go blog): why copying a struct with a slice field shares the backing array.
- Constraints, foreign keys section (PostgreSQL docs):
ON DELETE CASCADEand the other referential actions.