A photo of an intricate tree canopy

๐ŸŒฟ Unveiling the Composite Design Pattern in Ruby


author

Author: David Martinez

Created: December 3, 2024 - Updated: December 4, 2024

Read Time: 15 min

Ruby

In this article, we explore the Composite Design Pattern and how it can simplify your code when dealing with tree-like structures. ๐ŸŒฟ

What Is the Composite Pattern? ๐Ÿค”

The Composite Pattern is a structural design pattern that lets you compose objects into tree structures to represent part-whole hierarchies. It allows you to treat individual objects and compositions of objects uniformly.

This pattern is particularly useful when your system deals with hierarchical structures, such as organizational charts, file systems, or UI components.

When to Use the Composite Pattern?

๐Ÿ”น Tree-like Structures: Use this pattern when you need to represent part-whole hierarchies, such as folders containing files or organizations with multiple departments.

๐Ÿ”น Uniform Treatment of Objects and Compositions: When individual objects and composites should be treated the same way.

๐Ÿ”น Dynamic Hierarchical Structures: Itโ€™s ideal when objects can be dynamically added or removed, and the hierarchy needs to evolve over time.

Problem Statement

Consider an access control system where different types of roles (e.g., User, Manager, Admin) need to be managed, and permissions are granted to these roles for various resources (e.g., files, documents).

Without the Composite pattern, the system may involve checking each role separately, resulting in complex logic and potential duplication.

Naive Solution (Without Pattern)

In the naive approach, roles and permissions are handled by individual classes, each with its own access logic.

This leads to complex logic and difficulty in managing large numbers of roles or adding new ones and can result in code duplication.

UML Diagram

In this example:

๐Ÿ”น User, Manager, Admin are individual classes with their specific permissions.

๐Ÿ”น AccessControl has to check each role separately and cannot scale well if roles change or new roles are introduced.

class User
attr_reader :owned_resources
def initialize(owned_resources)
@owned_resources = owned_resources
end
def access_to?(resource, action)
case action
when :read, :edit
owned_resources.include?(resource)
else
false
end
end
end
class Manager
def access_to?(_resource, action)
action == :read
end
end
class Admin
def access_to?(_resource, action)
%i[read edit].include?(action)
end
end
class AccessControl
attr_reader :user, :manager, :admin
def initialize(user: nil, manager: nil, admin: nil)
@user = user
@manager = manager
@admin = admin
end
def access_to?(role_type, resource, action)
case role_type
when :user
user ? user.access_to?(resource, action) : false
when :manager
manager ? manager.access_to?(resource, action) : false
when :admin
admin ? admin.access_to?(resource, action) : false
else
false
end
end
end
file1 = 'file1.txt'
file2 = 'file2.txt'
user1 = User.new([file1])
manager_role = Manager.new
admin_role = Admin.new
access_control = AccessControl.new(user: user1, manager: manager_role, admin: admin_role)
puts access_control.access_to?(:user, file1, :read) # true (User1 owns file1)
puts access_control.access_to?(:user, file2, :read) # false (User1 does not own file2)
puts access_control.access_to?(:manager, file2, :read) # true (Manager can read any file)
puts access_control.access_to?(:admin, file2, :edit) # true (Admin can edit any file)
puts access_control.access_to?(:admin, file1, :edit) # true (Admin can edit any file)

Why This Approach Is Not Scalable? ๐Ÿค”

1๏ธโƒฃ Rigid Role Management: New roles or changes in permissions require modifying existing classes, violating the Open-Closed Principle.

2๏ธโƒฃ Complexity: The logic for checking access grows exponentially as more roles or resources are added.

3๏ธโƒฃ Duplication: Permissions logic is scattered across different classes, leading to redundancy and maintenance difficulties.

4๏ธโƒฃ Lack of Hierarchical Flexibility: There is no way to represent roles that aggregate other roles, like a composite of multiple roles.

Applying the Composite Pattern ๐ŸŒฟ

The Composite pattern solves these issues by introducing a common interface for roles and allowing roles to be composed of other roles.

With this pattern, individual roles (like User) and composite roles (like a combination of Manager and Admin) can be treated uniformly.

UML Diagram

In this improved approach:

๐Ÿ”น Role defines a base interface for shapes.

๐Ÿ”น User implements the Role interface for specific access.

๐Ÿ”น CompositeRole is a container for other Role objects, allowing composite access checks.

๐Ÿ”น Manager and Admin are concrete roles that implement the Role interface.

How does it Work?

1๏ธโƒฃ Component: Defines the interface for objects in the composition.

2๏ธโƒฃ Leaf: Represents individual objects in the composition.

3๏ธโƒฃ Composite: Contains leaf elements and implements the component interface.

4๏ธโƒฃ Client: Manipulates the component objects through the component interface.

Advantages of Using the Composite Pattern ๐Ÿš€

๐Ÿ”น Scalability: New roles or permissions can be added without modifying existing code, adhering to the Open-Closed Principle.

๐Ÿ”น Flexibility: Roles can be combined to form composite roles, representing complex access control hierarchies.

๐Ÿ”น Reusability: Individual and composite roles share a common interface, promoting code reuse and reducing redundancy.

๐Ÿ”น Simplified Access Logic: The access check becomes simpler, as it only involves traversing a tree structure, rather than dealing with multiple role types individually.

๐Ÿ”น Easier Maintenance: Changes in role behavior or access permissions are localized in the respective roles, making the system easier to update.

๐Ÿ”น Hierarchical Representation: The pattern provides a natural way to represent hierarchical structures, such as access control systems.

Show me the Code

class Role
def access_to?(resource, action)
raise NotImplementedError, 'Subclasses must implement `has_access_to?`'
end
end
class User < Role
attr_reader :owned_resources
def initialize(owned_resources)
super()
@owned_resources = owned_resources
end
def access_to?(resource, action)
case action
when :read, :edit
owned_resources.include?(resource)
else
false
end
end
end
class Manager < Role
def access_to?(_resource, action)
action == :read
end
end
class Admin < Role
def access_to?(_resource, action)
%i[read edit].include?(action)
end
end
class CompositeRole < Role
attr_reader :sub_roles
def initialize
super()
@sub_roles = []
end
def add_role(role)
sub_roles << role
end
def access_to?(resource, action)
sub_roles.any? { |role| role.access_to?(resource, action) }
end
end
file1 = 'file1.txt'
file2 = 'file2.txt'
user1 = User.new([file1])
manager_role = Manager.new
admin_role = Admin.new
composite = CompositeRole.new
composite.add_role(manager_role)
composite.add_role(admin_role)
composite.add_role(user1)
puts composite.access_to?(file1, :read) # true (User1 or Manager/Admin can read)
puts composite.access_to?(file2, :read) # true (Manager/Admin can read any file)
puts composite.access_to?(file1, :edit) # true (Admin or User1 can edit file1)
puts composite.access_to?(file2, :edit) # true (Admin can edit any file)
puts user1.access_to?(file2, :read) # false (User1 does not own file2)

Conclusion ๐Ÿ”–

The Composite Design Pattern provides an elegant solution for managing complex hierarchies by treating both individual objects and compositions uniformly. It simplifies access control logic, scales gracefully, and promotes flexibility in your system.

By using the Composite pattern, you can manage roles and permissions in a much more maintainable and scalable way, especially as your system grows.

Join the Quest!

๐Ÿ’ป You can find this and other design patterns here ๐Ÿ“š