Composite Design Pattern in iOS

abdul ahad
6 min readMay 13, 2024

--

Photo by Jannik Skorna on Unsplash

The boring theory ( it’s short I promise !)

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

Here’s how it works:

  1. Component: This is the base interface or abstract class that defines operations that can be performed on both simple and complex objects.
  2. Leaf: This represents the “building block” objects of the composition. These are the objects that do not have any child objects. They implement the Component interface.
  3. Composite: This represents the complex objects that have children. It contains methods to add, remove, or retrieve child components. Composite objects delegate operations to their children.

By using the Composite pattern, you can create structures that treat individual objects and compositions of objects uniformly. This pattern is useful in scenarios where you need to represent part-whole hierarchies, such as representing file systems, organization structures, or graphical user interfaces. It simplifies client code by allowing them to interact with Component without worrying about the internal details of the objects being manipulated.

Example

// Component
protocol File {
var name: String { get }
func display()
}

// Leaf
class TextFile: File {
var name: String

init(name: String) {
self.name = name
}

func display() {
print("Text file: \(name)")
}
}

// Leaf
class ImageFile: File {
var name: String

init(name: String) {
self.name = name
}

func display() {
print("Image file: \(name)")
}
}

// Composite
class Folder: File {
var name: String
private var files: [File] = []

init(name: String) {
self.name = name
}

func add(file: File) {
files.append(file)
}

func remove(file: File) {
if let index = files.firstIndex(where: { $0.name == file.name }) {
files.remove(at: index)
}
}

func display() {
print("Folder: \(name)")
for file in files {
file.display()
}
}
}

// Example usage
let textFile1 = TextFile(name: "document1.txt")
let textFile2 = TextFile(name: "document2.txt")
let imageFile = ImageFile(name: "image.png")

let folder1 = Folder(name: "Folder 1")
folder1.add(file: textFile1)
folder1.add(file: textFile2)

let folder2 = Folder(name: "Folder 2")
folder2.add(file: folder1)
folder2.add(file: imageFile)

folder2.display()

In this example:

  • File is the component protocol defining the common interface for leaf and composite objects.
  • TextFile and ImageFile are leaf classes representing simple file objects.
  • Folder is the composite class representing a folder containing files or subfolders.

You can see that the Folder class can contain both leaf objects (text and image files) and other composite objects (other folders). When display() is called on a folder, it recursively displays its contents, including both files and subfolders.

Real-World iOS Example

Suppose you’re developing an application where you need to track various events using different analytics services such as Google Analytics, Firebase, Localytics, Mixpanel, etc

Initially, you decide to implement event tracking directly in your EventTrackerViewController class, coupling it tightly with specific analytics services using conditional statements (if-else).

class class GoogleAnalyticsTracker {
public func track(event: String) {
print("Log \(event) to GoogleAnalytics")
}
}
class FirebaseTracker {
public func track(event: String) {
print("Log \(event) to Firebase")
}
}
class MixpanelTracker {
public func track(event: String) {
print("Log \(event) to Mixpanel")
}
}

public class EventTrackerViewController: UIViewController {
public override func viewDidLoad() {
super.viewDidLoad()
// Coupled implementation with specific analytics services
if shouldUseGoogleAnalytics {
let googleTracker = GoogleAnalyticsTracker()
googleTracker.track(event: "viewDidLoad")
} else if shouldUseFirebase {
let firebaseTracker = FirebaseTracker()
firebaseTracker.track(event: "viewDidLoad")
} else if shouldUseMixpanel {
let mixpanelTracker = MixpanelTracker()
mixpanelTracker.track(event: "viewDidLoad")
}
}

// Other methods similarly coupled with specific analytics services
}

Problem:

The code provided the following if not more issues:

  1. Tight Coupling: The EventTrackerViewController class is tightly coupled with specific analytics services (Google Analytics, Firebase, and Mixpanel) through direct instantiation of tracker objects within its methods. This makes the class difficult to maintain and less flexible to changes.
  2. Violation of Single Responsibility Principle: The EventTrackerViewController class is responsible for both managing its own functionality and directly interacting with analytics services. This violates the Single Responsibility Principle, which states that a class should have only one reason to change.
  3. Poor Scalability: Adding or removing analytics services requires modifying the EventTrackerViewController class. This violates the Open/Closed Principle, which suggests that classes should be open for extension but closed for modification.
  4. Code Duplication: Similar conditional statements are repeated throughout the EventTrackerViewController class for different analytics services. This leads to code duplication and reduces maintainability.
  5. Lack of Abstraction: There’s no abstraction layer to represent different analytics services uniformly. Each service is treated separately within conditional statements, making it challenging to extend or swap implementations.
  6. Violation of Dependency Inversion Principle: The EventTrackerViewController class directly depends on concrete implementations of analytics services, rather than depending on abstractions. This makes the class less reusable and harder to test.

Composite to the Rescue

I used the Composite Design Pattern to aggregate multiple event trackers like GoogleAnalyticsTracker (Leaf) which implement the EventTracker (Component) into a CompositeTracker (Composite). The CompositeTrackeris also a (leaf) as it implements the (component) which gives us the power to scale however we like.

public protocol EventTracker {
func track(event: String)
}
public class GoogleAnalyticsTracker: EventTracker {
public func track(event: String) {
print("Log \(event) to GoogleAnalytics")
}
}
class FirebaseTracker: EventTracker {
public func track(event: String) {
print("Log \(event) to Firebase")
}
}
class MixpanelTracker: EventTracker {
public func track(event: String) {
print("Log \(event) to Mixpanel")
}
}
public class CompositeTracker: EventTracker {
let trackers: [EventTracker]
public init(trackers: [EventTracker]) {
self.trackers = trackers
}
public func track(event: String) {
trackers.forEach { tracker in
tracker.track(event: event)
}
}
}
public class EventTrackerViewController: UIViewController {
private let tracker: EventTracker
public init(tracker: EventTracker) {
self.tracker = tracker
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func viewDidLoad() {
super.viewDidLoad()
tracker.track(event: "viewDidLoad")

}


}
class EventTrackerComposer {

static func instantiate() -> EventTrackerViewController {
let analyticsTracker = GoogleAnalyticsTracker()
let firebaseTracker = FirebaseTracker()
let googleTrackers = CompositeTracker(trackers: [analyticsTracker, firebaseTracker])
let mixpanelTracker = MixpanelTracker()
let trackers = CompositeTracker(trackers: [googleTrackers, mixpanelTracker])
let viewController = EventTrackerViewController(tracker: trackers)

return viewController
}
}

This refactored code addresses the problems identified earlier in the following ways:

  1. No Tight Coupling: The code now adheres to the Dependency Inversion Principle by depending on abstractions (protocols) rather than concrete implementations. The EventTrackerViewController class no longer directly instantiates specific tracker objects like GoogleAnalyticsTracker or FirebaseTracker. Instead, it depends on the EventTracker protocol, allowing for flexibility and easier swapping of implementations.
  2. No Violation of Single Responsibility Principle: The responsibilities are now properly separated. The EventTrackerViewController class is responsible for managing its own functionality, while the tracking logic is delegated to the injected EventTracker object. This adheres to the Single Responsibility Principle, making the codebase easier to maintain and understand.
  3. No Poor Scalability: The code now supports scalability as new analytics services can be easily added by implementing the EventTracker protocol. The CompositeTracker class aggregates multiple EventTracker objects and treats them uniformly. This allows for seamless addition or removal of trackers without modifying existing code, adhering to the Open/Closed Principle.
  4. No Code Duplication: The code has eliminated duplication by using the CompositeTracker class to handle multiple trackers uniformly. The EventTrackerComposer class provides a centralized way to instantiate the EventTrackerViewController with the desired trackers, reducing duplication and promoting maintainability.
  5. No Lack of Abstraction: The code now abstracts the concept of event tracking using the EventTracker protocol, allowing different trackers to be treated uniformly. This abstraction layer simplifies the management of analytics services and promotes code reusability.
  6. Testable: We can easily replace the trackers during testing using a mock or a spy making our code easily testable. You can visit the GitHub repo to see the tests as well.

Conclusion:

Composite Design pattern promotes better design principles such as abstraction, dependency inversion, and separation of concerns, leading to a more flexible, scalable, and maintainable codebase.

Reference:

Github: https://github.com/abdahad1996/DesignPatterns_Bootcamp

--

--

abdul ahad

A software developer dreaming to reach the top and also passionate about sports and language learning