carlos caballero
Angular
JavaScript
NestJS
NodeJS
TypeScript
UI-UX
ZExtra

Understanding the Composite Design Pattern

13 min read

There are 23 classic design patterns described in the original book Design Patterns: Elements of Reusable Object-Oriented Software. These patterns provide solutions to particular problems often repeated in software development.

In this article, I am going to describe how the Composite pattern works and when it should be applied.


Composite: Basic Idea

The first thing we need to look at is the definition offered by the book from the Gang of Four:

“Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly.”

Next, we need to look at the UML class diagram of this pattern to understand each of the elements that compose it.

The Composite pattern typically includes the following main elements:

  • Component: Defines the interface for objects in the composition. It declares an interface for accessing and managing children of composite objects.
  • Leaf: Represents leaf objects in the composition. A leaf has no children. It implements the operations defined in the component interface.
  • Composite: Defines the behavior of components that have children. It stores subcomponents and provides the implementation of operations related to children, such as adding, removing, and accessing child components.
  • Client: Manipulates the objects in the composition through the component interface.

With these elements, the Composite pattern allows building tree-shaped object structures that can be treated uniformly, whether it is an individual object or a composition of objects.


When to Use the Composite Pattern

The Composite pattern is an excellent solution when faced with a system where a hierarchical structure of objects needs to be managed uniformly. Here are some common situations where the use of the Composite pattern can be beneficial:

  1. Hierarchical Structures: When you need to represent object hierarchies, such as menus, organizations, or scene graphs. The Composite pattern facilitates the creation and manipulation of these hierarchies, allowing composite objects to contain other composite objects or leaves.
  2. Uniform Treatment of Objects: If you want clients to treat individual objects and compositions of objects in the same way, the Composite pattern is ideal. This simplifies client code, as it does not need to differentiate between simple and composite objects.
  3. Flexibility and Scalability: By using the Composite pattern, you can easily add new types of components. This facilitates the extension and modification of the structure without needing to change the code that interacts with it.

In summary, the Composite pattern is useful in situations where there is a need to handle hierarchical structures of objects uniformly, simplify interaction with these objects, and enhance the flexibility and scalability of the system.

Let’s now look at examples of applying this pattern.


Composite Pattern: Examples

Next, we will illustrate the composite pattern with two examples:

  1. Basic Structure of the Composite Pattern: In this example, we will translate the theoretical UML diagram into TypeScript code to identify each of the classes involved in the pattern.
  2. Recipe Book: We will have a set of ingredients that make up a recipe, but within a recipe, we can also have another recipe that must be made prior to our delicious dish.

Example 1: Basic Structure of the Composite Pattern

As always, the starting point is the UML class diagram. This UML class diagram is the same as we explained at the beginning of the post, and to streamline the post, we will not explain it again as such. However, we will begin to see its implementation step by step.

Let’s start with the Component interface. This interface defines the contract that any component in the system must follow. In this case, it only has one method, operation, which is implemented by both leaf nodes and composite nodes.

export interface Component {
    operation(): void;
}

The next class is Leaf, this class implements the Component interface. The Leaf class represents the final objects in the composition, which have no children. The operation method in the Leaf class simply performs the leaf-specific operation.

import { Component } from "./component";

export class Leaf implements Component {
    operation(): void {
        console.log("Leaf operation.");
    }
}

The next class is Composite. This class also implements the Component interface. It can have children and defines operations related to its children. The Composite class has methods for adding and removing children, and its operation method iterates over all its children and calls their operation method.

import { Component } from "./component";

export class Composite implements Component {
    private children: Component[] = [];

    // Add a child to the list of children
    addChild(child: Component): void {
        this.children.push(child);
    }

    // Remove a child from the list of children
    removeChild(child: Component): void {
        const index = this.children.indexOf(child);
        if (index !== -1) {
            this.children.splice(index, 1);
        }
    }

    // Implement the operation defined in the Component interface
    operation(): void {
        console.log("Composite operation.");
        this.children.forEach(child => child.operation());
    }
}

The Composite class manages a list of children who are also Component objects. This allows for a tree structure where each node in the tree is either a Leaf or a Composite. Now, let’s see how to use this pattern from the Client class.

import { Component } from "./component";
import { Composite } from "./composite";
import { Leaf } from "./leaf";

const leaf1: Component = new Leaf();
const leaf2: Component = new Leaf();
const leaf3: Component = new Leaf();
const composite1: Component = new Composite();
const composite2: Component = new Composite();

// Build the tree structure
(composite1 as Composite).addChild(leaf1);
(composite1 as Composite).addChild(leaf2);
(composite2 as Composite).addChild(leaf3);
(composite2 as Composite).addChild(composite1);

// Call the operation on the root composite
composite2.operation();

In the client code, we instantiate leaf and composite objects and build a tree structure. We add child components to composite objects, creating a hierarchy. Finally, we call the operation method on the root composite, which propagates the call to its children and so on, performing operations recursively.


Example 2: Recipe Book without the Composite Pattern

Imagine we want to create a system to manage cooking recipes, where each recipe can consist of ingredients and other recipes. Without applying the Composite pattern, we would have a rigid and error-prone implementation. Below is the implementation of a recipe system without using the Composite pattern.

If we look at the UML class diagram, we first encounter the Ingredient class, which consists of two attributes: the name of the ingredient and the amount. The showDetails method is a simple toString method to display the information.

On the other hand, we see the Recipe class, which has attributes for the name of the recipe, a collection of ingredients, which are related through a composition relationship. Additionally, we have a set of recipes needed to make this recipe. Here is where we see a recursive relationship in the recipes. Moreover, the methods we need right now are quite simple: we have methods to add ingredients (addIngredient) and to add recipes to this entity (addRecipe), and a method to display the recipe details called showDetails.

Let’s look at the implementation of this initial solution that does NOT use the Composite pattern.

export class Ingredient {
    name: string;
    amount: string;

    constructor(name: string, amount: string) {
        this.name = name;
        this.amount = amount;
    }

    showDetails(): string {
        return `Ingredient: ${this.name}, Amount: ${this.amount}`;
    }
}

The first point is to look at the Ingredient class, which is a simple class where we only have the two attributes mentioned earlier: name and amount, which are received through the constructor. On the other hand, the showDetails method displays the information of the ingredient.

import { Ingredient } from './ingredient';

export class Recipe {
    name: string;
    ingredients: Ingredient[] = [];
    recipes: Recipe[] = [];

    constructor(name: string) {
        this.name = name;
    }

    addIngredient(ingredient: Ingredient): void {
        this.ingredients.push(ingredient);
    }

    addRecipe(recipe: Recipe): void {
        this.recipes.push(recipe);
    }

    showDetails(): string {
        let details = `Recipe: ${this.name}\n`;

        this.ingredients.forEach(ingredient => {
            details += `  ${ingredient.showDetails()}\n`;
        });

        this.recipes.forEach(recipe => {
            details += `  ${recipe.showDetails()}`;
        });

        return details;
    }
}

The next element in this solution is the Recipe class where we have the private attributes name and a list of ingredients, which are obvious for a recipe. Additionally, we have a recursive relationship through an attribute recipes, which is a collection of recipes. The constructor is quite simple as it only receives the name of the recipe, and then we have two methods that allow adding ingredients ( addIngredient) and recipes (addRecipes).

The class ends with the showDetails method, where we encounter two for loops to display the information of the ingredients and the recipes contained in this recipe.

However, at this point, we can detect serious problems that may not seem important at first, but as the project grows, they will cause significant issues in the software:

  1. Method Redundancy: The Recipe class has two separate methods for adding components to its class, namely the addIngredient and addRecipe methods. These methods introduce redundancy and complicate the code because the logic for adding components is duplicated and exactly the same.
  2. Difficulty Extending Functionality: If in the future another type of component, like Utensil, needs to be added, it would be necessary to add another method (addUtensil) and modify the showDetails logic to include it. This makes the code less flexible and harder to maintain.
  3. Violation of the Liskov Substitution Principle: The addIngredient and addRecipe methods do not allow for treating all components uniformly. This violates the Liskov Substitution Principle, one of the SOLID principles, since Recipe and Ingredient cannot be used interchangeably through a common interface.

These are precisely the problems we are going to solve with the Composite pattern. However, before we move on to the solution, let’s also look at the client code that would use this solution.

import { Ingredient } from "./ingredient";
import { Recipe } from "./recipe";

const ingredient1 = new Ingredient("Flour", "2 cups");
const ingredient2 = new Ingredient("Eggs", "3");
const recipe = new Recipe("Cake");
recipe.addIngredient(ingredient1);
recipe.addIngredient(ingredient2);

const ingredient3 = new Ingredient("Milk", "1 cup");
const compositeRecipe = new Recipe("Cake with Milk");
compositeRecipe.addRecipe(recipe);
compositeRecipe.addIngredient(ingredient3);

console.log(compositeRecipe.showDetails());

First, we see that we are creating several ingredients and a recipe for a cake. We have to link the ingredients to the cake through the addIngredient method.

On the other hand, if we want to create a Cake with Milk, we need to create a new recipe, add the Milk that we didn’t have in the previous recipe, and for the Cake with Milk, we need to nest the previously configured cake recipe. Here we can see that to add components, we don’t have a common interface, which even makes it difficult to work with the recipes.

Having outlined all the problems with this solution and the code we’ve analyzed, let’s introduce the Composite pattern to it.


Example 2: Recipe Book using the Composite Pattern

To address the solution, the first thing we need to look at is how the UML class diagram of the problem evolves with the application of the Composite pattern.

Let’s start by noting that we now have an interface named RecipeComponent, which defines the showDetails method as the common interface for all components. Both ingredients and recipes implement this interface. The Ingredient class does not undergo any changes in this solution. In fact, the Ingredient class is equivalent to the Leaf class in the pattern. On the other hand, the Recipe class changes considerably in the solution because it will now have an array of objects that satisfy the RecipeComponent interface. These will be both ingredients and recipes. Additionally, we will no longer have two methods to add ingredients or recipes; instead, there will be a single method called addComponent to perform these operations, and the showDetails method will be implemented.

Of course, let’s go through the code step by step to see how the code has evolved and how the issues have been resolved.

export interface RecipeComponent {
    showDetails(): string;
}

Let’s start by looking at the interface for the components. This interface will be implemented by both ingredients and recipes. Initially, we only define the showDetails method.

import { RecipeComponent } from "./recipe-component";

export class Ingredient implements RecipeComponent {
    name: string;
    amount: string;

    constructor(name: string, amount: string) {
        this.name = name;
        this.amount = amount;
    }

    showDetails(): string {
        return `Ingredient: ${this.name}, Amount: ${this.amount}`;
    }
}

If we look at the Ingredient class, it is exactly the same as before, except that now we implement the RecipeComponent interface, which we did previously as well. Reviewing the class, we see that we have two attributes for each ingredient: the name and the amount. These two attributes are received through the constructor to create each instance, and finally, we have the showDetails method, which is responsible for displaying the information of each ingredient.

import { RecipeComponent } from "./recipe-component";

export class Recipe implements RecipeComponent {
    name: string;
    components: RecipeComponent[] = [];

    constructor(name: string) {
        this.name = name;
    }

    addComponent(component: RecipeComponent): void {
        this.components.push(component);
    }

    showDetails(): string {
        let details = `Recipe: ${this.name}\n`;

        this.components.forEach(component => {
            details += `  ${component.showDetails()}\n`;
        });

        return details;
    }
}

On the other hand, if we look at the class corresponding to the recipes, here we do find changes. We start by seeing that the attributes are now the name of the recipe and an array of objects that satisfy the RecipeComponent interface, meaning they could be both ingredients and recipes. The constructor only requires the name of the recipe and nothing else. Furthermore, we no longer have two methods to manage recipes and ingredients; instead, with a single method like addComponent, we add both recipes and ingredients, eliminating redundancy in the code. The last method we need to look at is showDetails. Now, instead of having several loops, we just need to traverse the data structure we have and invoke the showDetails method because we know that all objects satisfy the interface that introduces this method.

import { Ingredient } from "./ingredient";
import { Recipe } from "./recipe";

// Usage
const ingredient1 = new Ingredient("Flour", "2 cups");
const ingredient2 = new Ingredient("Eggs", "3");
const recipe = new Recipe("Cake");
recipe.addComponent(ingredient1);
recipe.addComponent(ingredient2);

const ingredient3 = new Ingredient("Milk", "1 cup");
const compositeRecipe = new Recipe("Cake with Milk");
compositeRecipe.addComponent(recipe);
compositeRecipe.addComponent(ingredient3);

console.log(compositeRecipe.showDetails());

We also need to look at the client class that uses the pattern. The first thing we would do now is to create instances of ingredients and the cake recipe again, which would have the two ingredients added through the addComponent method. Now, if we want to create a cake with milk, we simply need to create an ingredient that would be the milk and the cake with milk recipe, which would receive both the ingredient and the cake through the addComponent method. Finally, we would see the result through the showDetails method.

Now let’s move on to analyze how the Composite pattern relates to other design patterns.


Composite Pattern: Relationship with Other Patterns

The Composite pattern integrates effectively with several other design patterns, each complementing its capabilities and extending its applicability in building complex and scalable systems. Below is a description of how the Composite pattern relates to other design patterns:

Decorator

The Composite and Decorator patterns both structure objects recursively, allowing both simple and composite components to be treated uniformly. Both patterns use a common interface for the objects they manage. In Composite, leaves and composites implement the same interface. In Decorator, the decorated object and the decorator share the same interface.

While Composite is used to represent part-whole hierarchies, Decorator is employed to add additional responsibilities to an object dynamically without changing its structure. Decorator can be used in combination with Composite to add extra behaviors to components of a Composite structure.

Iterator

The Iterator pattern complements the Composite pattern by providing a uniform way to traverse elements of the composite structure without knowing its internal structure. Both patterns promote a common interface for handling collections of objects. Composite organizes objects into a hierarchy, while Iterator allows for sequential traversal of these objects.

Composite is used to build complex hierarchical structures, and Iterator can be used with Composite to navigate this structure transparently, facilitating manipulation and access to the components.

Visitor

The Visitor pattern allows for defining new operations on a Composite structure without modifying the component classes, facilitating the extension of functionalities. Both patterns operate on an object structure. Composite facilitates part-whole hierarchies management, while Visitor facilitates the execution of operations on the components of that structure.

Composite is used to create object hierarchies, while Visitor can be applied to these hierarchies to define new operations, allowing operations to change without altering the component classes.

Flyweight

The Flyweight pattern can be used with Composite to efficiently handle a large number of similar objects by sharing intrinsic states. Both patterns aim to optimize resource usage. Composite organizes objects into hierarchical structures, and Flyweight reduces memory usage by sharing common components among objects.

Composite is used to organize objects into part-whole hierarchies. Flyweight can be used within a Composite to reduce the memory needed by sharing common components among multiple instances.

Builder

The Builder pattern can be used to construct Composite structures incrementally and in a controlled manner. Both patterns simplify the creation of complex structures. Composite organizes objects into hierarchies, and Builder facilitates the step-by-step construction of these hierarchies.

Composite is used to represent complex hierarchical structures, while Builder can be used to assemble these structures in a controlled way, ensuring that each component is added correctly.

Chain of Responsibility

The Chain of Responsibility pattern can be used in a Composite structure to propagate requests through the components of the hierarchy. Both patterns handle distributed responsibilities. Composite organizes objects into a part-whole structure, and Chain of Responsibility allows for request handling through a chain of objects.

Composite is used to organize objects in a hierarchy. Chain of Responsibility can be used to handle requests within this structure, allowing requests to propagate from one component to another until one that can handle them is found.


Conclusions

Overall, the Composite pattern is a valuable tool for designing systems that manage complex hierarchies of objects, especially when aiming for:

  • Uniform Treatment of Objects and Compositions: It allows for the uniform treatment of individual objects (leaves) and combinations of objects (composites). This simplifies the handling of complex hierarchical structures by providing a common interface.
  • Flexibility and Reusability: Individual and composite components implement the same interface, allowing for easy reuse and reorganization of components. This facilitates the modification and expansion of the structure without affecting other parts of the system.
  • Ease of Maintenance: By treating individual and composite objects in the same manner, the code becomes easier to maintain and understand. Modifications to the structure or components can be made without affecting the rest of the system.
  • Simplification of Client Code: Clients can treat composite and simple objects in the same way, reducing the complexity of the code that interacts with the hierarchical structure.

However, the use of the Composite pattern also has some disadvantages:

  • Increased Implementation Complexity: The composite structure can make the system design more complex, especially when managing multiple levels of composition and aggregation.
  • Potential Performance Loss: Navigating and managing a deep hierarchical structure can introduce performance overhead, particularly if aggregation and removal operations are performed frequently.
  • Difficulty in Restricting Components: In some cases, it may be challenging to restrict the types of components that can be added to a composite, which could lead to a less secure or less clear design.

The most important thing about the Composite pattern is not its specific implementation, but the ability to recognize the problem that this pattern can solve and when it can be applied. The specific implementation is not that important, as it will vary depending on the programming language used.

See this GitHub repo for the full code.