Java Association: Modeling Relationships Between Classes
java association: Understand Java association, how to model unidirectional and bidirectional relationships, and when to choose aggregation or composition.
Java association is the most general form of relationship between types: when a Customer class declares a field of type Order, one object holds a reference to another and can navigate to it at runtime. The association exists because one class needs to know about another to perform its work, and the connection is realized when an object is assigned to the field.
What Association Means in Java
An association is a structural relationship that says one class is connected to another. It does not imply ownership or lifecycle dependency. The Customer and Order classes are associated because Order holds a Customer reference, but neither class is responsible for creating or destroying the other. This is the key distinction from aggregation and composition, which add ownership semantics on top of the basic reference.
At runtime, an association is simply a reference stored in a field. The type system enforces that the field can only hold an instance of the declared type (or a subtype), which means the compiler catches many mismatched-association errors before the code runs.
Modeling a Unidirectional Association
The simplest association is unidirectional: one class holds a reference, and the other has no awareness of the relationship.
public class Order { private Customer customer; public Order(Customer customer) { this.customer = customer; } public Customer getCustomer() { return customer; } }
Here Order can navigate to its Customer, but Customer has no reference back. This is usually the right starting point. A reverse reference adds coupling and maintenance cost, so you should only add it when navigation genuinely needs to happen in both directions.
The constructor takes the Customer instance, which guarantees the association is set when the Order is created. If the association can change later, a setter is appropriate; if it is fixed for the object's lifetime, the constructor-only approach prevents accidental reassignment.
Bidirectional Associations and Their Maintenance
A bidirectional association means both classes hold references to each other. This is common in domain models where you need to navigate from either side.
public class Customer { private List<Order> orders = new ArrayList<>(); public void addOrder(Order order) { orders.add(order); order.setCustomer(this); } }
The Order side needs a matching setCustomer method. The important detail is that the association must be kept consistent from both sides. If you call orders.add(order) without setting the customer on the order, the two objects disagree about their relationship.
A common approach is to designate one side as the owner of the relationship and provide a helper method that updates both sides. Callers use the helper instead of manipulating the collection directly. This keeps the invariant in one place and prevents the two sides from drifting apart.
Cardinality: One-to-One, One-to-Many, Many-to-Many
Association cardinality describes how many instances participate on each side of the relationship.
| Cardinality | Java representation | Typical example |
|---|---|---|
| One-to-one | Single field on both sides | Person and Passport |
| One-to-many | Single field on one side, collection on the other | Customer and List<Order> |
| Many-to-many | Collection on both sides | Student and List<Course> |
For one-to-one, a nullable field is the standard representation. The association may or may not be present at any given time, so callers must handle a null return value.
For one-to-many, the "many" side is usually a List or Set. A Set is appropriate when duplicate members are meaningless; a List preserves insertion order, which matters when the order of associated objects is significant.
Many-to-many associations require collections on both sides, and you must decide which side owns the relationship. The owner side manages the collection and provides the methods that keep both sides in sync, while the non-owner side exposes a read-only view.
Association vs Aggregation vs Composition
Aggregation and composition are specialized forms of association with ownership semantics.
Aggregation is a "has-a" relationship where the contained object can exist independently. A Department holds a list of Professor objects, but the professors exist even if the department is deleted.
Composition is a stronger form where the contained object's lifecycle depends on the container. A House creates its Room objects in its constructor and destroys them when the house is no longer reachable.
// Aggregation: professors exist independently of the department public class Department { private List<Professor> professors; } // Composition: rooms are created and owned by the house public class House { private List<Room> rooms; public House() { this.rooms = new ArrayList<>(); this.rooms.add(new Room("living")); } }
Java has no syntax that distinguishes aggregation from composition. The difference is expressed through how you manage object creation and lifetimes. In composition, the container creates the contained objects and is the only path through which they remain reachable. In aggregation, the contained objects are created elsewhere and passed into the container.
Lifecycle and Memory Implications
The strength of the relationship directly affects memory usage and garbage collection. In a composition, when the container becomes unreachable, the contained objects become unreachable too — assuming no external references were leaked. This is efficient because the whole object graph is collected together.
In an aggregation, the contained objects can outlive the container. If a Professor is referenced by both a Department and a university registry, removing the department does not free the professor. This is correct behavior, but it means you must consider who else holds references.
Bidirectional associations create reference cycles. A Customer references its Order objects, and each Order references its Customer. The garbage collector handles cycles in Java, but the entire cycle stays alive as long as any single object in the cycle is reachable from a live root. If you keep a Customer in a cache, every associated Order remains in memory too. For large object graphs, this can keep significantly more data alive than you intended.
Equality, hashCode, and Navigation
Bidirectional associations complicate equals() and hashCode(). If Customer.equals() compares its orders and Order.equals() compares its customer, the two methods can recurse indefinitely. The standard solution is to base equality on a stable identifier, such as a database-generated ID, rather than on associated objects.
Navigation methods should return unmodifiable views so callers cannot corrupt the association from outside:
public List<Order> getOrders() { return Collections.unmodifiableList(orders); }
This forces all mutation to go through the owning side's helper methods, which keeps the bidirectional invariant intact. If callers could modify the returned list directly, they could add an order without setting the customer reference, and the association would be inconsistent.
Choosing the Right Relationship
The decision between association, aggregation, and composition comes down to ownership and lifecycle.
Use a plain association when the objects are independent and the link is temporary or incidental. Use aggregation when the "part" can exist without the "whole" and is created elsewhere. Use composition when the "part" has no independent existence and the container is responsible for its creation and destruction.
Direction matters just as much as strength. If navigation only happens one way, keep the association unidirectional. A bidirectional association doubles the maintenance cost because every mutation must update both sides. Adding the reverse reference later is easy; removing it from a codebase that has come to rely on it is not.