Back to Blog
Java

Java Composition vs Aggregation: Key Differences

java composition vs aggregation: Understand the practical difference between composition and aggregation in Java, including lifetime, ownership, and code examples.

Object-Oriented DesignUMLJava ClassesDependency ManagementDesign Patterns
Diagram showing composition as a filled diamond and aggregation as an empty diamond between two Java classes.

When you model relationships between objects in Java, the distinction between composition and aggregation often feels abstract until it affects your code's behavior. Both describe a "has-a" relationship, but they differ in who owns the lifecycle of the contained object. In this article, we'll compare java composition vs aggregation with concrete examples, and show how the choice changes memory management, null safety, and maintainability.

Defining Composition and Aggregation in Java

Composition is a strong ownership relationship. The contained object cannot exist independently of the container. When the container is destroyed, the contained object is destroyed as well. In Java, this typically means the container creates the contained object internally and holds a reference to it. No external code ever obtains that reference, so the contained object's lifetime is strictly tied to the container.

Aggregation is a weaker relationship. The contained object can exist independently. The container holds a reference to an object that was created elsewhere, and that object continues to live even if the container is garbage collected. The container does not own the lifecycle; it merely uses the object.

Here's a minimal example to illustrate the difference. Consider a Car class. In a composition relationship, a Car owns its Engine. The engine is created when the car is created and dies with it. In an aggregation relationship, a Car might have a Driver. The driver exists independently and can be reassigned to another car.

class Engine { void start() { } } class Car { private Engine engine = new Engine(); // Composition: Car creates and owns Engine void start() { engine.start(); } }
class Driver { String name; } class Car { private Driver driver; // Aggregation: Driver is passed in from outside void setDriver(Driver driver) { this.driver = driver; } }

In the first example, the Car creates its Engine directly. No other object can access that engine. In the second, the Driver is supplied externally and can be replaced or reused elsewhere.

Lifetime and Ownership: The Core Difference

The critical distinction lies in who controls the lifecycle. With composition, the container is responsible for creating and destroying the contained object. In Java, destruction is handled by garbage collection, so the practical implication is that the contained object becomes unreachable when the container becomes unreachable. If the container holds the only reference to the contained object and the container is no longer referenced, both become eligible for garbage collection together.

With aggregation, the contained object has an independent lifetime. It may be referenced by other objects even after the container is gone. This means the container must be prepared for the aggregated object to be null or to change over time. The container does not control when the aggregated object is created or destroyed.

Consider a Library and a Book. A library aggregates books because books exist even if the library is closed. A Book and its Page objects, however, are often modeled as composition because pages do not exist outside the book. In Java, this translates to the library receiving book references from outside, while the book creates its own page list internally.

Implementing Composition in Java

Composition is straightforward to implement because the container initializes the contained object in its constructor or at field declaration. This guarantees that the contained object is always present when the container is used, unless you explicitly allow it to be replaced.

public class House { private final Room livingRoom; private final Room kitchen; public House() { this.livingRoom = new Room("Living"); this.kitchen = new Room("Kitchen"); } } class Room { private final String name; Room(String name) { this.name = name; } }

Here, the House owns its Room objects. They are created when the house is created, and they cannot be replaced from outside. The final keyword reinforces that the references are immutable, which is a common pattern for composition. This makes the object graph easy to reason about: the lifetime of the rooms is exactly the lifetime of the house.

One important consequence is that composition often leads to deep object graphs. If the contained object itself contains other objects, the entire graph is created together. This can be a performance consideration during construction, but it also simplifies destruction because the entire graph becomes unreachable at once.

Implementing Aggregation in Java

Aggregation requires that the container accept references from outside. This is typically done through constructor parameters or setter methods. The container should not create the aggregated object itself, because that would make it composition.

public class Team { private final List<Player> players; public Team(List<Player> players) { this.players = players; } } class Player { private final String name; Player(String name) { this.name = name; } }

The Team does not create its players. They are passed in. The team may later remove or add players, but it does not control their lifecycle. A player can belong to multiple teams, or exist without any team. This flexibility is the main advantage of aggregation.

However, aggregation introduces a risk: the container may hold a reference to an object that is mutated or nullified externally. If the aggregated object is mutable, the container must handle concurrent changes or unexpected state. It's often wise to copy the reference or make the aggregated object immutable to avoid surprising behavior.

Another practical detail is that aggregation often requires null checks. Since the aggregated object is not created by the container, it might be absent. For example, a Car might have a Driver that is not assigned yet. The Car methods must handle a null driver gracefully.

UML and Design Implications

In UML class diagrams, composition is shown as a filled diamond on the container side, while aggregation uses an empty diamond. This notation communicates the ownership semantics to other developers. The filled diamond implies that the contained object's lifetime is bound to the container, and that the container is responsible for its creation. The empty diamond implies a weaker relationship where the contained object can exist independently.

From a design perspective, composition is preferred when you want to enforce invariants. Because the container creates its parts, it can guarantee that they are properly initialized. This reduces the chance of null pointer exceptions and makes the object graph more predictable. Aggregation is useful when you need flexibility, such as when objects are shared across multiple containers or when the relationship is dynamic.

A common mistake is to use composition when aggregation is more appropriate, or vice versa. For example, modeling a Customer and Order as composition would imply that an order cannot exist without a customer. That might be true in a business rule, but if orders are archived independently, aggregation is better. The choice should reflect the actual domain rules, not just the convenience of the code.

Choosing Between Composition and Aggregation

Use composition when the contained object has no meaning without the container, and when the container should control the full lifecycle. This is typical for value objects, internal components, and parts that are never shared. Use aggregation when the contained object can exist independently, may be shared, or needs to be replaced at runtime.

Here are concrete decision criteria:

  • If the contained object is always created and destroyed with the container, choose composition.
  • If the contained object can outlive the container or be shared, choose aggregation.
  • If the container must guarantee that the contained object is never null, composition is simpler.
  • If the container must allow dynamic replacement of the contained object, aggregation is necessary.

In practice, many relationships are not purely one or the other. A container might aggregate some objects and compose others. For example, a Company composes its Department objects (departments do not exist without the company) but aggregates its Employee objects (employees can exist independently). This mixed usage is common in real systems.

Runtime and Memory Considerations

Composition and aggregation affect garbage collection and memory usage. With composition, the contained object is only reachable through the container. When the container becomes unreachable, the entire object graph becomes eligible for collection in a single GC cycle. This is efficient because the garbage collector does not need to trace multiple external references.

With aggregation, the contained object may be referenced by other objects. The garbage collector must keep it alive as long as any reference exists. This can lead to longer-lived objects and more complex reachability analysis. If the aggregated object is large and shared, it may stay in memory longer than the container, which could be a concern in memory-constrained environments.

There is also a subtle performance difference during object creation. Composition often involves creating multiple objects together, which can be more expensive if the parts are complex. Aggregation avoids that cost because the container only stores a reference. However, the difference is usually negligible compared to the overall application logic.

Another operational concern is serialization. When you serialize an object graph, composition serializes the contained objects as part of the container. Aggregation serializes only the reference, which may or may not include the referenced object depending on the serialization framework. This can lead to incomplete data if the aggregated object is not serialized separately. You need to be explicit about how you handle shared references.

Common Pitfalls and Maintainability Concerns

One common pitfall is using composition when the contained object needs to be replaced. If you create the contained object inside the container and expose a setter to replace it, you are effectively breaking the composition contract. The original object becomes unreachable, but the container no longer owns the lifecycle of the new object. This hybrid approach can confuse maintainers.

Another issue is cloning. If you implement clone() on a container that uses composition, you must deep-clone the contained objects to preserve the ownership semantics. With aggregation, a shallow clone might be acceptable because the aggregated objects are shared. Failing to handle this correctly can lead to shared mutable state and subtle bugs.

To keep the code maintainable, document the intended relationship. Use final fields for composition to make the ownership explicit. For aggregation, consider using immutable references or defensive copies. Also, avoid exposing the contained object's reference directly if you want to maintain encapsulation. Instead, provide methods that operate on the contained object without letting external code modify it directly.

When you refactor a relationship from aggregation to composition, you must change how the contained object is created. This often involves moving the creation logic into the container's constructor. Conversely, moving from composition to aggregation requires removing the creation logic and adding a way to inject the reference. These changes can ripple through the codebase, so it's best to get the relationship right early.

Final Code Example: Combining Both Relationships

A typical domain model uses both composition and aggregation. Consider a BlogPost that composes its Content sections but aggregates its Author. The sections are created inside the post and cannot exist independently. The author is passed in and can be changed later.

public class BlogPost { private final String title; private final List<Section> sections = new ArrayList<>(); private Author author; public BlogPost(String title, Author author) { this.title = title; this.author = author; } public void addSection(String heading, String text) { sections.add(new Section(heading, text)); } public void changeAuthor(Author newAuthor) { this.author = newAuthor; } } class Section { private final String heading; private final String text; Section(String heading, String text) { this.heading = heading; this.text = text; } } class Author { private final String name; Author(String name) { this.name = name; } }

Here, Section objects are composed: they are created inside BlogPost and are destroyed with it. The Author is aggregated: it is passed in from outside and can be replaced. This example demonstrates how the two relationships coexist naturally in a single class. When you maintain such code, the distinction helps you understand which objects you can safely replace and which are integral to the container.

The key to using composition and aggregation effectively is to be consistent with the ownership semantics. If you treat an aggregated object as if it were composed, you may accidentally create null references or leak mutable state. If you treat a composed object as aggregated, you may break encapsulation and allow external modification of internal parts. By clearly defining the relationship, you make the code easier to reason about and less prone to bugs.

java composition vs aggregation: Practical Usage and Code Ex | RYUSLOG DEV