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
andgetSize
methods are part of theFileSystemComponent
behavior. They're responsible for providing details about this particular filesystem component. - The
addComponent
,removeComponent
, andgetComponents
methods are responsible for managing the subcomponents of theFolder
. They form another area of responsibility for theFolder
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 FileSystemComponent
s 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 Folder
s, only File
s. 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.
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 DETAILSWhat 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.