Java Aggregation: Has-A Relationships
java aggregation: Understand Java aggregation, how it differs from composition, and when to use it to model has-a relationships.
In Java, aggregation models a has-a relationship where one object holds a reference to another without controlling its lifecycle. This is a common design pattern in object-oriented programming, and understanding how to implement it correctly affects how you manage dependencies, shared state, and object lifetimes. Java aggregation is often confused with composition, but the distinction matters when you design classes that need to remain flexible and testable.
What Java Aggregation Means in Practice
Aggregation is a form of association where a class contains a reference to another class, but the contained object can exist independently. For example, a Department class may hold a list of Employee objects, but those employees can also exist without the department. The department does not own the employees' lifecycles; it merely references them.
public class Department { private List<Employee> employees; public Department(List<Employee> employees) { this.employees = employees; } } public class Employee { private String name; private String id; public Employee(String name, String id) { this.name = name; this.id = id; } }
The Department class holds a reference to Employee instances that are created elsewhere. If the department is garbage-collected, the employees remain alive as long as other references exist. This is the core semantic of aggregation: the contained object's lifecycle is not tied to the container.
Aggregation vs Composition: Lifecycle Differences
The key difference between aggregation and composition is ownership. In composition, the child object cannot exist without the parent. When the parent is destroyed, the child is destroyed too. In aggregation, the child is independent.
Consider a House and a Room. A house is composed of rooms; if the house is demolished, the rooms cease to exist. That is composition. In contrast, a Car and a Wheel are often modeled as aggregation because wheels can be removed and used elsewhere, even if the car is scrapped.
In Java, composition is typically implemented by creating the child inside the parent's constructor and never exposing it directly. Aggregation is implemented by passing an existing instance through a constructor or setter.
// Composition: child is created and owned by parent public class House { private Room room; public House() { this.room = new Room(); } } // Aggregation: child is passed in and exists independently public class Car { private Wheel wheel; public Car(Wheel wheel) { this.wheel = wheel; } }
The lifecycle distinction affects how you reason about memory and object sharing. With aggregation, multiple containers can reference the same object, which can be useful for shared configuration or shared services.
Implementing Aggregation with Plain References
You can implement aggregation with a simple field, a constructor parameter, or a setter. The key is that the referenced object is not created inside the containing class. This allows the caller to control the creation and reuse of the dependent object.
public class Order { private Customer customer; public Order(Customer customer) { this.customer = customer; } public Customer getCustomer() { return customer; } }
Here, Order aggregates a Customer. The customer can be used across multiple orders, and its lifecycle is managed externally. This is a typical pattern in domain models where entities are passed around rather than created in place.
When the aggregated object is optional, you might use a setter instead of a constructor to allow null values. This is common when the relationship is not always present.
public class Report { private DataSource source; public void setDataSource(DataSource source) { this.source = source; } }
Handling Nullable and Shared Objects
Aggregation often involves nullable references. A Department might have no employees, or an Order might not yet have a customer. You need to decide whether to allow null and how to handle it in methods that use the reference.
public class Department { private List<Employee> employees; public Department(List<Employee> employees) { this.employees = employees != null ? employees : List.of(); } public int getEmployeeCount() { return employees.size(); } }
Using an immutable empty list avoids null checks and prevents accidental modification. For shared objects, be aware that if the aggregated object is mutable, changes will be visible to all containers that reference it. This can be intentional, but it can also introduce subtle bugs if you expect each container to have its own copy.
When to Choose Aggregation Over Composition
Choose aggregation when the contained object is shared, reused, or has a longer lifecycle than the container. For example, a Logger instance passed to multiple services is a good candidate for aggregation. The services do not own the logger; they merely use it.
Composition is better when the child is an intrinsic part of the parent and cannot exist on its own. A Transaction and its TransactionDetails are often composed because the details have no meaning without the transaction.
The decision also affects testability. Aggregation makes it easier to inject mocks or alternative implementations because the dependency is passed from outside. Composition can make testing harder because the parent creates its own dependencies internally.
Maintaining Aggregation Relationships in Larger Systems
In larger systems, aggregation relationships can become tangled if not managed carefully. A common issue is circular aggregation, where two objects reference each other. This can complicate garbage collection and make the design harder to reason about.
public class A { private B b; public void setB(B b) { this.b = b; } } public class B { private A a; public void setA(A a) { this.a = a; } }
Circular references are not inherently wrong, but they require careful handling when serializing or cloning objects. They can also indicate that the abstraction is leaking. If you find yourself adding bidirectional aggregation, consider whether the relationship should be one-directional or whether a separate lookup service would be cleaner.
Another maintainability concern is the risk of leaking internal state. If you expose the aggregated object through a getter, callers can modify it directly. To protect invariants, return unmodifiable views or copies when the aggregated object is a collection.
public List<Employee> getEmployees() { return Collections.unmodifiableList(employees); }
This prevents external code from adding or removing employees without going through the Department methods, preserving the integrity of the aggregation.
Finally, consider the effect of aggregation on memory. Because aggregated objects are not owned, they are eligible for garbage collection only when no other references exist. If you hold a reference to a large object that is no longer needed, you can explicitly set the field to null to release it earlier. This is rarely necessary in well-designed systems, but it can be useful in long-lived containers that accumulate references over time.