- Chapter
- Chapter 1 — Object-Oriented Design
- Prerequisites
- Topics 1.1 and 1.2; Volume I Topic 3.4 (relationships and direction)
- You will be able to
- Distinguish the four class relationships in UML and Java code, choose the weakest relationship that fits, model ownership and lifecycle correctly, and map relationships to persistence and service boundaries
- Covers
- Association · Aggregation · Composition · Dependency
On this page
- 1. Concept / Definition
- 2. Why It Exists
- 3. Core Concepts
- 4. How It Works Internally — Relationships in Code and Memory
- 4A. Deep Dive — Relationships and Aggregates
- 5. APIs / Features / Syntax
- 6. Practical Examples
- 7. Common Problems & Mistakes
- 8. Performance & Production Considerations
- 9. Architecture & Design Considerations
- 10. Interview Questions & Answers
1Concept / Definition
A relationship describes how classes are connected. The four relationships in the TOC differ in strength (how tightly they couple the classes), duration (temporary use vs long-lived reference) and ownership (whether one object controls another's lifecycle).

| Relationship | Meaning | Duration | Ownership | UML |
|---|---|---|---|---|
| Dependency | A uses B temporarily | One operation | None | Dashed arrow |
| Association | A knows B (holds a reference) | Long-lived | None | Solid line/arrow |
| Aggregation | A has B as a part; parts can exist independently/shared | Long-lived | Weak (no lifecycle control) | Hollow diamond |
| Composition | A consists of B; parts live and die with the whole | Long-lived | Strong (lifecycle control) | Filled diamond |
2Why It Exists
Relationship semantics come from OO modelling methods (Booch, Rumbaugh's OMT, Jacobson) merged into UML (1997). Aggregation and composition distinguish "whole–part" structures common in domains (orders and lines, documents and paragraphs). Domain-Driven Design refined composition into aggregates — consistency boundaries with a root that owns its parts — which strongly influence persistence and microservice data design.
3Core Concepts
3.1 Association
An association is a structural relationship: an object holds a reference to another for longer than a single operation. Associations have direction (who knows whom) and multiplicity (one, many, optional).
// Unidirectional association: Order knows its customer
// (by ID — preferred across aggregates)
public final class Order {
private final CustomerId customerId; // reference by identity
…
}
// Object reference association within a module
public final class ReminderService {
private final Mailer mailer; // long-lived collaborator
public ReminderService(Mailer mailer) { this.mailer = mailer; }
}| Design choice | Guideline |
|---|---|
| Direction | Prefer unidirectional; bidirectional associations double the coupling and must be kept consistent |
| Multiplicity | Make it explicit (Optional, List, required constructor parameter) |
| Reference vs ID | Across aggregates/modules/services, hold IDs, not object references |
| Navigation depth | Avoid deep navigation chains (Law of Demeter) |
3.2 Aggregation
Aggregation is a whole–part association where parts can exist independently of the whole and may be shared among several wholes.
// A Team aggregates Players; players exist without the team and can join other teams
public final class Team {
private final TeamId id;
private final Set<PlayerId> members = new HashSet<>(); // references by identity
public void add(PlayerId player) { members.add(player); }
public void remove(PlayerId player) { members.remove(player); }
}UML's aggregation semantics are notoriously weak — the UML specification itself leaves them loosely defined — so many teams simply model aggregation as an association with "has-a" meaning. What matters in practice: the whole does not control the parts' lifecycle.
3.3 Composition
Composition is a strong whole–part relationship: the whole owns its parts, creates them, controls access to them, and deletes them when it is deleted. A part belongs to exactly one whole at a time.

public final class Order {
private final List<OrderLine> lines = new ArrayList<>(); // composed parts
public void addLine(Sku sku, int qty, Money price) { // whole creates parts
lines.add(new OrderLine(sku, qty, price));
}
// no external mutation
public List<OrderLine> lines() { return List.copyOf(lines); }
}// JPA mapping of composition: cascade and orphan removal express lifecycle ownership
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLineEntity> lines = new ArrayList<>();| Composition rule | Reason |
|---|---|
| Parts created by the whole (Creator) | Invariants checked on creation |
| Parts not exposed for modification | Whole enforces consistency |
| Parts deleted with the whole | No orphans |
| Access to parts only through the whole | Aggregate root (DDD) |
3.4 Dependency
A dependency is the weakest relationship: A uses B during an operation — as a parameter, local variable, return type or static call — without keeping a reference.
// Dependency: Clock used only for this call
public Invoice issue(Order order, Clock clock) {
return new Invoice(order.id(), order.total(), LocalDate.now(clock).plusDays(30));
}
| Dependency form | Coupling |
|---|---|
| Parameter of an interface type | Weak — caller supplies implementation |
| Parameter of a concrete class | Moderate |
new ConcreteClass() inside a method | Stronger — hard-wired |
| Static method call on another class | Stronger — hard to substitute |
| Checked exception type in signature | Callers depend on it too |
4How It Works Internally — Relationships in Code and Memory
1. Dependency exists only in bytecode (constant pool references, method signatures); at runtime no reference is kept after the call.
2. Association/aggregation/composition are fields holding references (or IDs); the GC keeps referenced objects alive while reachable.
3. Composition in memory: parts are reachable only through the whole, so when the whole becomes unreachable, parts are collected too.
4. In persistence, composition maps to cascading operations and orphan removal; associations across aggregates map to foreign keys/IDs without cascades.
5. At module level, every relationship is also a compile-time dependency between packages; the direction rules of Volume I Topic 3.4 apply.
4ADeep Dive — Relationships and Aggregates
4A.1 From composition to DDD aggregates
| Concept | Meaning |
|---|---|
| Aggregate | A cluster of objects treated as one unit for consistency |
| Aggregate root | The only entity outside objects may reference; enforces invariants |
| Inside the aggregate | Composition (lines of an order) |
| Between aggregates | Association by ID only (order → customer ID) |
| Transaction boundary | One aggregate per transaction (guideline) |
4A.2 Why reference other aggregates by ID
| Object reference across aggregates | ID reference |
|---|---|
| Loads large object graphs (lazy loading, N+1) | Load only what you need |
| Tempts modification of other aggregates in one transaction | Clear consistency boundary |
| Couples persistence mappings | Independent persistence |
| Impossible across services | Works across services |

5APIs / Features / Syntax
| Relationship | Java/JPA expression |
|---|---|
| Dependency | Method parameter/local/return type; static call |
| Association | Field (private final Mailer mailer) or ID field (CustomerId) |
| Aggregation | Collection of IDs or shared references; @ManyToMany or join table |
| Composition | Private collection created internally; @OneToMany(cascade = ALL, orphanRemoval = true), @Embedded/@ElementCollection for values |
| Inheritance (strongest) | extends; JPA @Inheritance |
// Composition of value objects: embedded in the owner's table
@Embeddable public record Address(String street, String city, String postcode) { }
@Entity class Customer {
@Embedded private Address billingAddress;
@ElementCollection private List<Address> deliveryAddresses = new ArrayList<>();
}6Practical Examples
6.1 Classifying relationships in a domain
| Pair | Relationship | Reason |
|---|---|---|
| Order — OrderLine | Composition | Lines belong to exactly one order and die with it |
| Order — Customer | Association by ID | Customer lives independently; different aggregate |
| Playlist — Track | Aggregation | Tracks exist independently and are shared |
| InvoiceService — Clock | Dependency (or association if injected) | Used during an operation |
| Order — Address | Composition of a value | Copy of the delivery address at order time |
6.2 Reducing association to dependency
// Association: service stores a collaborator it only needs in one method
class ExportService {
private final CsvWriter writer = new CsvWriter();
byte[] export(List<Row> rows) { return writer.write(rows); }
}
// Dependency: collaborator passed where needed (useful when it varies per call)
class ExportService {
byte[] export(List<Row> rows, RowWriter writer) { return writer.write(rows); }
}6.3 Relationship review checklist
- Is each association unidirectional unless both directions are required?
- Do references across aggregates/modules use IDs?
- Are composed parts created and modified only through the whole?
- Are JPA cascades used only for composition?
- Could any stored reference be a method-level dependency instead?
7Common Problems & Mistakes
- Object graphs spanning aggregates, loading half the database.
- Bidirectional associations out of sync.
- Cascade on associations between independent entities (deleting a customer's shared data).
- Exposing composed parts for external modification.
- Hard-wired dependencies (
new, static calls) where injection is needed. - Over-debating aggregation vs association in UML while ignoring lifecycle.
8Performance & Production Considerations
| Aspect | Consideration |
|---|---|
| Lazy loading | Associations mapped as object references can trigger N+1 queries |
| Aggregate size | Large compositions load and lock more data per transaction |
| Memory | Long-lived associations keep object graphs reachable (leak risk in caches) |
| Concurrency | Small aggregates reduce lock contention and optimistic-locking conflicts |
In production, relationship choices mostly show up in the persistence layer. Map @ManyToOne associations as FetchType.LAZY, because JPA makes them eager by default, and load the graph a use case needs with fetch joins or @EntityGraph instead of navigating lazily inside loops, which causes N+1 queries. Hibernate statistics or a query-count assertion in integration tests catch regressions early. Keep compositions small: an aggregate with thousands of children loads, locks and versions all of them on every change, so a very large collection usually signals a missing aggregate. Avoid cascades across aggregates, which can delete or update far more rows than intended. In caches, store IDs or small view records rather than entity graphs, which keep large object trees reachable and inflate heap usage.
9Architecture & Design Considerations
- Composition = consistency boundary; associations by ID = boundaries between aggregates, modules and services.
- Service data ownership follows the same rule: services reference each other's entities by ID only.
- Relationship strength guides modularity: strong relationships belong inside a module; weak ones cross boundaries.
- UML is a communication tool: use relationship notation consistently in design documents.
10Interview Questions & Answers
What is the difference between association and dependency?
An association is a structural, long-lived relationship: one object holds a reference to another, or to its identity, in a field, so the link outlives a single operation and has a direction and a multiplicity. A dependency is temporary use: the class needs the other type only during an operation, as a parameter, local variable, return type or static call, and keeps nothing afterwards. ReminderService storing a Mailer field is an association; issue(Order order, Clock clock) using the Clock only for that call is a dependency. In UML, association is a solid line and dependency a dashed arrow. Dependency is weaker, so prefer it when the collaborator is only needed occasionally.
What is the difference between aggregation and composition?
Both are whole-part relationships, but they differ in who owns the lifecycle. In aggregation the parts exist independently of the whole and can be shared: players exist without a team and can move to another one, and tracks can appear in many playlists. In composition the whole owns its parts: it creates them, controls all changes to them and deletes them with itself, and each part belongs to exactly one whole at a time, like order lines in an order. In Java, composition usually means a private collection populated only by the whole's methods; in JPA it means cascading and orphan removal. UML marks aggregation with a hollow diamond and composition with a filled one.
How is composition expressed in JPA?
For child entities, map the collection on the whole as @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true). Cascading means persisting, merging or removing the Order also applies to its OrderLine rows, and orphan removal deletes a line as soon as it is removed from the collection, so no row survives without its parent. For value objects without identity, use @Embedded for a single value such as a billing Address, stored in the owner's table, and @ElementCollection for a collection of values, stored in a table owned by the entity. Keep the collection private and expose methods such as addLine, so parts change only through the whole in code too.
Why prefer unidirectional associations?
A bidirectional association couples both classes to each other: each must know the other's type, and both sides must be kept consistent on every change. In JPA that means an @OneToMany with mappedBy plus an @ManyToOne, and code that sets line.order whenever a line is added; forget it and the in-memory model disagrees with what is persisted. Unidirectional associations halve that coupling, make ownership obvious and avoid accidental navigation into large object graphs, which also reduces lazy-loading surprises and serialisation cycles in JSON. Choose bidirectional only when navigation in both directions is a real, frequent need, and then hide the synchronisation inside helper methods on the owning side.
Why should aggregates reference each other by ID?
An aggregate is a consistency boundary, and referencing another aggregate by ID keeps that boundary visible in code. With an object reference, loading an Order can pull in the Customer and everything it references, causing lazy-loading traps, N+1 queries and huge graphs. It also tempts developers to change two aggregates in one transaction, which increases locking and optimistic-locking conflicts. Holding a CustomerId makes the dependency explicit: if the use case needs customer data, it loads the customer through its own repository. ID references also decouple persistence mappings, so each aggregate can be stored and evolved independently, and they still work when the other aggregate later moves into a separate service.
What is the weakest relationship, and why prefer it?
Dependency is the weakest relationship, because the class uses the collaborator only during one operation and keeps no reference afterwards. That means no long-lived state to keep consistent, nothing holding other objects reachable in memory, and a collaborator that can differ on every call, which suits things like a Clock, a RowWriter or a pricing context. Coupling is lowest when the parameter is an interface type and higher when the method calls new on a concrete class or a static method. Preferring the weakest relationship that still expresses the domain keeps classes easy to test and change. Promote a dependency to an association only when the collaborator is genuinely needed across many operations.
How do relationships influence module boundaries?
Relationship strength tells you what must stay together and where you can cut. Composition clusters, such as an order with its lines, share invariants and lifecycle, so they belong in one aggregate and therefore one module. Strong object associations between classes suggest they change together and also belong together. Associations by ID, and especially collaboration through events, are weak; they mark natural seams where a module boundary, and later perhaps a service boundary, can sit. When designing modules, I classify relationships first, keep strong ones inside a module, replace object references that cross modules with IDs or published views, and enforce the resulting dependency direction with ArchUnit or Spring Modulith.
How do you model relationships between entities owned by different services?
Across services, the only shared reference is an identifier. The Order service stores a CustomerId, never a Customer object graph, and there are no foreign keys or joins across service databases, because each service owns its data. When a service needs data owned by another, it either calls that service's API on demand, which adds runtime coupling, or keeps a local, read-only copy of just the fields it needs, updated from events such as CustomerRenamed. That copy is eventually consistent, so the design must tolerate staleness, and values such as a delivery address are often snapshotted at order time anyway. Composition and cascades stay strictly inside one service's aggregates.