Skip to main content

Caveats or Criticism Of Composite Design Pattern

While the Composite pattern is a very useful design pattern, it does have certain potential drawbacks that developers should keep in mind.

Composite might violate the Single Responsibility Principle (SRP)

In our example, Folder has responsibilities that are different from File. The File is responsible for maintaining data, while Folder manages the collection of FileSystemComponent. This division of responsibilities can make the Composite class (Folder in our case) more complex.

The Single Responsibility Principle (SRP) is a computer programming principle that states that every module, class or function should have responsibility over a single part of the functionality provided by the software, and that responsibility should be entirely encapsulated by the class.

In the context of the Composite Pattern, the "composite" class (Folder in our file system example) tends to have more than one responsibility. For example:

  • It's responsible for managing its children (addComponent, removeComponent, getComponents).
  • It's also responsible for implementing the behavior defined in the FileSystemComponent interface (getName, getSize).

On the other hand, the "leaf" class (File in our example) typically only has a single responsibility, which aligns more directly with the SRP. It only implements the behavior defined in the FileSystemComponent interface.

So, in the context of the Composite Pattern, the SRP is potentially violated because the composite class (Folder) ends up with multiple responsibilities. This might lead to a situation where a change in the way the Folder handles its children could require changes in how it implements its own behavior (and vice versa), thus making the class less stable and harder to maintain.

Here's a specific code example from the implementation:

class Folder implements CompositeFileSystemComponent {
private components: FileSystemComponent[] = [];

constructor(private name: string) {}

getName(): string {
return this.name;
}

getSize(): number {
// calculate size by summing sizes of all components
return this.components.reduce(
(total, component) => total + component.getSize(),
0
);
}

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

removeComponent(component: FileSystemComponent) {
const index = this.components.indexOf(component);
if (index !== -1) {
this.components.splice(index, 1);
}
}

getComponents(): FileSystemComponent[] {
return this.components;
}
}

In this Folder class:

  • The getName and getSize methods are part of the FileSystemComponent behavior. They're responsible for providing details about this particular filesystem component.
  • The addComponent, removeComponent, and getComponents methods are responsible for managing the subcomponents of the Folder. They form another area of responsibility for the Folder class.

Therefore, the Folder class has two reasons to change, which violates the Single Responsibility Principle. It is important to note, however, that design principles like SRP are guidelines, not hard rules, and they can sometimes be relaxed if it leads to a more pragmatic solution, as is often the case with the Composite Pattern.

Type checking

Type checking issues in the Composite Pattern can arise due to the need to treat composites and individual components uniformly. That is, in languages with static typing, like TypeScript, it can sometimes be difficult to determine whether a method should only be called on a leaf node or only on a composite.

Consider our file system example. In this case, addComponent, removeComponent, and getComponents methods only make sense for a Folder (which is a CompositeFileSystemComponent), and not for a File (which is a simple FileSystemComponent).

Now suppose we have a function in our program that receives a FileSystemComponent and tries to add another FileSystemComponent to it:

function addToFileSystemComponent(
parent: FileSystemComponent,
child: FileSystemComponent
) {
parent.addComponent(child);
}

This code will fail to compile if parent is a File, because File does not have an addComponent method.

We might try to solve this with an instanceof check:

function addToFileSystemComponent(
parent: FileSystemComponent,
child: FileSystemComponent
) {
if (parent instanceof Folder) {
parent.addComponent(child);
} else {
throw new Error("Cannot add a component to a file!");
}
}

This works, but we've lost some of the elegance and simplicity of the Composite Pattern. We're no longer able to treat all FileSystemComponents uniformly, and we've had to introduce a type check that wouldn't be necessary in a more dynamic language.

A compromise solution in this case could be to add addComponent, removeComponent, and getComponents to the FileSystemComponent interface and throw an error in the File implementation:

interface FileSystemComponent {
getName(): string;

getSize(): number;

addComponent?(component: FileSystemComponent): void;

removeComponent?(component: FileSystemComponent): void;

getComponents?(): FileSystemComponent[];
}

class File implements FileSystemComponent {
// ...
addComponent() {
throw new Error("Cannot add a component to a file!");
}

removeComponent() {
throw new Error("Cannot remove a component from a file!");
}

getComponents() {
throw new Error("Cannot get components from a file!");
}
}

This allows us to compile the addToFileSystemComponent function without an instanceof check, but at the cost of introducing potentially confusing and non-intuitive behavior (i.e., methods on a File that throw errors when called).

So, when using the Composite Pattern, developers must carefully consider the trade-offs between type safety, simplicity, and uniform treatment of composites and individual components.

Difficulty in restricting components of the composite

When we use the Composite Design Pattern, it may become challenging to enforce restrictions on the types of components that can be added to a composite, or limit the number of components. This issue arises because the Composite Design Pattern, by its nature, encourages treating composites and individual components uniformly.

In our filesystem example, the Folder class (which is a composite) can hold any object that implements the FileSystemComponent interface, whether that object is another Folder or a File. But suppose we have a requirement that a Folder should not contain other Folders, only Files. How would we enforce this?

Here's the relevant part of the current Folder implementation:

class Folder implements CompositeFileSystemComponent {
private components: FileSystemComponent[] = [];

// ...

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

// ...
}

The addComponent method accepts any FileSystemComponent, so there's no way to prevent a client from adding a Folder to a Folder.

One way to enforce the restriction would be to add a type check in the addComponent method:

class Folder implements CompositeFileSystemComponent {
private components: FileSystemComponent[] = [];

// ...

addComponent(component: FileSystemComponent) {
if (component instanceof Folder) {
throw new Error("Cannot add a folder to a folder");
}

this.components.push(component);
}

// ...
}

However, this solution is not ideal. It violates the open-closed principle because we need to modify the Folder class every time we add a new component type. It also reduces the generality and reusability of the Folder class.

Another possible solution could be to create different interfaces and classes for composites and leaf nodes, but this would break the uniform treatment of components that is one of the main advantages of the Composite Pattern.

So, while it's possible to implement some restrictions in a composite, it can add complexity and might go against some of the benefits of using the Composite Pattern in the first place. Developers need to carefully consider these trade-offs when deciding to use the Composite Pattern.

Indirect coupling

Indirect coupling refers to a situation where changes in one object or class can affect another, even if they are not directly linked. This phenomenon can occur in the Composite Pattern due to the hierarchical nature of the design. It happens when a composite (parent) object depends on its components (children), and vice versa.

This kind of coupling could lead to unintended side effects when you modify one part of your code. For example, changes to the way a composite handles its components could inadvertently affect the components themselves, and changes to a component could affect the composite that contains it.

In our FileSystemComponent example, the Folder (composite) calculates its size by summing up the sizes of its components. So if a File (component) changes its size, the total size of the Folder would also change. This is an example of indirect coupling, because a File doesn't need to know anything about a Folder, but it can still affect the Folder's behavior indirectly.

Here is the relevant part of the code:

class Folder implements CompositeFileSystemComponent {
private components: FileSystemComponent[] = [];

// ...

getSize(): number {
return this.components.reduce(
(total, component) => total + component.getSize(),
0
);
}

// ...
}

This getSize method is dependent on the getSize method of each component. Any change in the component's size would indirectly affect the Folder's size.

While this kind of indirect coupling can be desirable in some cases, it can also make the code harder to understand and debug. If you notice that the size of a Folder is not what you expect, you would need to check all of its components to find out why.

It's also worth noting that changes in the Folder class could indirectly affect the File class. For example, if we change the addComponent method in Folder to automatically set the size of a File to 0 when it's added, this would affect the behavior of the File class, even though the File class hasn't changed.

To mitigate the impact of indirect coupling, you should strive to make your classes as encapsulated as possible, so that changes to one class don't inadvertently affect others. In the context of the Composite Pattern, this might mean avoiding unexpected side effects in the methods that manage components, and being aware of the potential for changes in a component to affect its composite.

Remember, design patterns are tools in your toolbox. They're not one-size-fits-all solutions. Developers should use the Composite pattern (or any pattern) only when its benefits outweigh any potential issues.

TypeScript Course Instructor Image
TypeScript Course Instructor Image

Time To Transition From JavaScript To TypeScript

Level Up Your TypeScript And Object Oriented Programming Skills. The only complete TypeScript course on the marketplace you building TypeScript apps like a PRO.

SEE COURSE DETAILS

What Can You Do Next 🙏😊

If you liked the article, consider subscribing to Cloudaffle, my YouTube Channel, where I keep posting in-depth tutorials and all edutainment stuff for ssoftware developers.

YouTube @cloudaffle