Have you ever found yourself tangled in a web of if-else statements, trying to handle objects that can be either simple items or complex groups of items? This often happens in systems that manage hierarchies, like UI components, file systems, or—as we’ll see today—a shopping cart with bundled products.
The Composite Pattern is a structural design pattern that lets you compose objects into tree structures and then work with these structures as if they were individual objects. It provides a way to treat single (leaf) objects and compositions of (composite) objects uniformly.
[!NOTE] What is a Structural Design Pattern? Structural patterns are concerned with how classes and objects are composed to form larger structures. They simplify the structure by identifying a simple way to realize relationships between entities.
The Problem: A Shopping Cart That Doesn’t Scale
Imagine you’re building an e-commerce application. You start with a simple Product class and a ShoppingCart.
// A simple product
public class Product {
private String name;
private double price;
// Constructor, getters...
}
// The shopping cart
public class ShoppingCart {
private List<Object> items = new ArrayList<>();
public void addItem(Object item) {
items.add(item);
}
public double calculateTotalPrice() {
double total = 0;
for (Object item : items) {
if (item instanceof Product) {
total += ((Product) item).getPrice();
}
}
return total;
}
// ... other methods
}
This works perfectly fine. But then, the business introduces a new requirement: “We want to offer free gift items, like stickers, with certain purchases.”
To handle this, you create a GiftItem class and modify the ShoppingCart.
public class GiftItem {
private String name;
// No price, it's free!
}
Your calculateTotalPrice method now needs a check to handle this new type.
public double calculateTotalPrice() {
double total = 0;
for (Object item : items) {
if (item instanceof Product) {
total += ((Product) item).getPrice();
- }
+ } else if (item instanceof GiftItem) {
+ // Price is 0, so do nothing.
+ total += 0;
+ }
}
return total;
}
The code is already getting a bit messy. Now, the marketing team comes up with a brilliant idea: “Let’s sell products in bundles with a special discount!”
So you create a Bundle class that contains a list of products and a discount.
public class Bundle {
private String name;
private List<Product> products = new ArrayList<>();
private double discount;
public double getPrice() {
double total = 0;
for(Product p : products) {
total += p.getPrice();
}
return total - discount;
}
// ...
}
And your ShoppingCart becomes even more complex:
public double calculateTotalPrice() {
double total = 0;
for (Object item : items) {
if (item instanceof Product) {
total += ((Product) item).getPrice();
} else if (item instanceof GiftItem) {
total += 0;
+ } else if (item instanceof Bundle) {
+ total += ((Bundle) item).getPrice();
}
}
return total;
}
The final blow comes when they say: “What if a bundle could contain another bundle?”
Our current design collapses. We’d need recursion inside our if block, and the ShoppingCart class would become a monstrous, unmaintainable piece of code, violating both the Single Responsibility Principle and the Open/Closed Principle.
[!WARNING] Open/Closed Principle: Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. Our
ShoppingCartfails this test, as we have to modify it for every new item type.
The Solution: The Composite Pattern
The Composite pattern’s goal is to allow a client to treat individual objects and compositions of objects uniformly. It does this by defining a common interface for both.
Here are the key players:
- Component (
CartItem): The abstraction for all objects in the composition, both leaves and composites. It declares the interface for the operations that are common to all. - Leaf (
Product,GiftItem): Represents the individual objects in the composition. A leaf has no children and implements the operations from the Component interface. - Composite (
Bundle): Represents a composite object that can have children. It stores child components and implements the operations from the Component interface, usually by delegating the work to its children.
Let’s visualize this structure with a class diagram.
classDiagram
direction TB
class ShoppingCart {
+addItem(CartItem)
+calculateTotalPrice()
}
class CartItem {
<<interface>>
+getPrice()
+print()
}
class Product {
-name: String
-price: double
+getPrice()
}
class GiftItem {
-name: String
+getPrice()
}
class Bundle {
-name: String
-discount: double
-children: List~CartItem~
+add(CartItem)
+remove(CartItem)
+getPrice()
}
ShoppingCart o-- "1..*" CartItem
CartItem <|-- Product
CartItem <|-- GiftItem
CartItem <|-- Bundle
Bundle o-- "0..*" CartItem
Refactoring to the Composite Pattern
Let’s refactor our code. First, we define our Component interface.
1. The Component Interface
// src/main/java/com/example/CartItem.java
public interface CartItem {
double getPrice();
void print(int indent);
}
2. The Leaf Classes
Next, our Product and GiftItem classes implement this interface. They are the “leaves” of our tree.
// src/main/java/com/example/Product.java
public class Product implements CartItem {
private String name;
private double price;
public Product(String name, double price) {
this.name = name;
this.price = price;
}
@Override
public double getPrice() {
return this.price;
}
@Override
public void print(int indent) {
System.out.println(" ".repeat(indent) + name + ": $" + price);
}
}
// src/main/java/com/example/GiftItem.java
public class GiftItem implements CartItem {
private String name;
public GiftItem(String name) {
this.name = name;
}
@Override
public double getPrice() {
return 0.0; // Gifts are free!
}
@Override
public void print(int indent) {
System.out.println(" ".repeat(indent) + name + ": $0.0 (Gift)");
}
}
3. The Composite Class
This is the core of the pattern. The Bundle class also implements CartItem, but it contains a list of other CartItem objects. Its getPrice() method delegates the call to its children.
// src/main/java/com/example/Bundle.java
import java.util.ArrayList;
import java.util.List;
public class Bundle implements CartItem {
private String name;
private double discount;
private List<CartItem> children = new ArrayList<>();
public Bundle(String name, double discount) {
this.name = name;
this.discount = discount;
}
public void add(CartItem item) {
children.add(item);
}
public void remove(CartItem item) {
children.remove(item);
}
@Override
public double getPrice() {
double total = 0;
for (CartItem item : children) {
total += item.getPrice(); // Recursive call!
}
return total - discount;
}
@Override
public void print(int indent) {
System.out.println(" ".repeat(indent) + "+ " + name + " (Bundle Price: $" + getPrice() + ")");
for (CartItem item : children) {
item.print(indent + 4);
}
}
}
4. The Cleaned-Up Client (NewShoppingCart)
Finally, look how clean our NewShoppingCart becomes. It doesn’t know or care whether an item is a Product, a Bundle, or a Bundle of Bundles. It just works with the CartItem interface.
// src/main/java/com/example/NewShoppingCart.java
import java.util.ArrayList;
import java.util.List;
public class NewShoppingCart {
private List<CartItem> items = new ArrayList<>();
public void addItem(CartItem item) {
items.add(item);
}
public double calculateTotalPrice() {
double total = 0;
for (CartItem item : items) {
total += item.getPrice(); // Simple, clean, uniform!
}
return total;
}
public void printItems() {
System.out.println("--- Shopping Cart Contents ---");
for (CartItem item : items) {
item.print(0);
}
System.out.println("------------------------------");
System.out.println("Total Price: $" + calculateTotalPrice());
}
}
Our new project structure looks like this:
src/main/java/com/example/
├── CartItem.java
├── Product.java
├── GiftItem.java
├── Bundle.java
└── NewShoppingCart.java
Putting It All Together: A Complex Example
Let’s build a complex order with a bundle inside another bundle.
graph TD
subgraph Main Offer (Discount $100)
A["Monitor ($1000)"]
B["Adapter ($80)"]
subgraph Laptop Bundle (Discount $50)
C["Pro Laptop ($2000)"]
D["Mechanical Keyboard ($150)"]
E["Gaming Mouse ($100)"]
F["Sticker (Gift)"]
end
end
B --> Laptop Bundle
A --> Laptop Bundle
Here’s the code to create this structure:
public class Main {
public static void main(String[] args) {
// 1. Create individual products (Leaves)
Product laptop = new Product("Pro Laptop", 2000.0);
Product keyboard = new Product("Mechanical Keyboard", 150.0);
Product mouse = new Product("Gaming Mouse", 100.0);
Product monitor = new Product("4K Monitor", 1000.0);
Product adapter = new Product("USB-C Adapter", 80.0);
GiftItem sticker = new GiftItem("Brand Sticker");
// 2. Create the inner bundle (Composite)
Bundle laptopBundle = new Bundle("Laptop Power Bundle", 50.0);
laptopBundle.add(laptop);
laptopBundle.add(keyboard);
laptopBundle.add(mouse);
laptopBundle.add(sticker); // Add a gift to the bundle
// 3. Create the main bundle (Composite containing another Composite)
Bundle mainOffer = new Bundle("Work From Home Main Offer", 100.0);
mainOffer.add(monitor);
mainOffer.add(adapter);
mainOffer.add(laptopBundle); // Nesting the bundle!
// 4. Add the main bundle to the cart
NewShoppingCart cart = new NewShoppingCart();
cart.addItem(mainOffer);
// 5. Print the result
cart.printItems();
}
}
Output:
--- Shopping Cart Contents ---
+ Work From Home Main Offer (Bundle Price: $3280.0)
4K Monitor: $1000.0
USB-C Adapter: $80.0
+ Laptop Power Bundle (Bundle Price: $2200.0)
Pro Laptop: $2000.0
Mechanical Keyboard: $150.0
Gaming Mouse: $100.0
Brand Sticker: $0.0 (Gift)
------------------------------
Total Price: $3280.0
Let’s break down the calculation:
- Laptop Bundle:
$2000 (Laptop) + $150 (Keyboard) + $100 (Mouse) + $0 (Sticker) - $50 (Discount) = $2200 - Main Offer:
$1000 (Monitor) + $80 (Adapter) + $2200 (Laptop Bundle) - $100 (Discount) = $3280
The client (NewShoppingCart) simply called getPrice() on the top-level mainOffer and the entire calculation was handled recursively and transparently.
When to Use the Composite Pattern
[!TIP] Use the Composite Pattern when:
- You need to represent a part-whole hierarchy of objects.
- You want clients to be able to treat individual objects and compositions of objects uniformly.
- The structure can have any level of depth and complexity (e.g., nested groups).
Potential Downsides:
- Overly General Design: The common interface can sometimes become bloated if you try to support too many different operations. It can be hard to restrict composites from having certain children.
- Implementation Complexity: The logic for adding/removing children and the recursive nature of operations can be tricky to implement correctly, especially in performance-critical applications.
The Composite pattern is a powerful tool for building scalable, hierarchical structures while keeping your client code simple and clean. By creating a unified interface, you free the client from the burden of understanding the complex inner workings of the objects it’s dealing with.