SOLID Principles in Swift - Open / Closed Principle

Background

In this series of posts we are going to be covering the SOLID principles of software development. These are a set of principles / guidelines, that when followed when developing a software system, make it more likely that the system will be easier to extend and maintain over time. Let’s take a look at the problems that they seek to solve:

  • Fragility: A change may break unexpected parts, it is very difficult to detect if you don’t have a good test coverage
  • Immobility: A component is difficult to reuse in another project or in multiple places of the same project because it has too many coupled dependencies
  • Rigidity: A change requires a lot of effort because it affects several parts of the project

So what are the SOLID principles?

  • Single Responsibility Principle - A class should have only a single responsibility / have only one reason to change
  • Open-Closed Principle - Software should be open for extension but closed for modification
  • Liskov Substitution Principle - Objects in a program should be replaceable with instances of their sub types without altering the correctness of the program
  • Interface Segregation Principle - Many client-specific interfaces are better than one general-purpose interface
  • Dependency Inversion Principle - High level modules should not depend on low level modules. Both should depend on abstractions

In this article we will focus on the Open-Closed Principle.

What does it mean?

So the open-closed principle states:

Software should be open for extension but closed for modification

What exactly does this mean? I think out of all the principles this is the hardest to understand. Mostly due to the fact the explanation leaves far too much open to interpretation. A simple Google search will offer up several examples of how this principle works. In this article I will present my take on the principle and how to build software that will comply with it.

Let’s focus on a type that by design, violates the open closed principle.

Enums

Enums are a very powerful tool in Swift. They are first class types and such can have associated values and conform to protocols for example. However when used at the boundary of a particular system or module they can present a particular problem.

Let’s imagine an analytics system where we can log events. This is a design pattern I’ve seen in many places:

// 1
enum AnalyticsEvent {
    case newsList
    case newsDetail(id: Int)
}

// 2
class AnalyticsController {
    func sendEvent(_ event: AnalyticsEvent) {
        let title = event.title
        let params = event.params
        
        // Send data to analytics network
    }
}

// 3
extension AnalyticsEvent {
    var title: String {
        switch self {
        case .newsList:
            return "News List"
        case .newsDetail:
            return "News detail"
        }
    }
    
    var params: [String : String] {
        switch self {
        case .newsList:
            return [:]
        case .newsDetail(let id):
            return ["id": "\(id)"]
        }
    }
}

Let’s look at what we have here.

  1. The first thing that is defined is an enum that houses all the different analytics events that are available. Some with associated values.
  2. Next we have our analytics controller, this takes an event as a parameter, takes information from the event and would then send that on to our analytics system.
  3. Here we have extended the AnalyticsEvent enum to add 2 variables, one for title and one for params that contain a switch for each of our events.

On the surface or at first look this might appear an ok solution. We have hidden our implementation of the analytics network inside our AnalyticsController and setup a defined set of events that we can support.

The Problem

Now lets look at the problems that this approach causes.

  • What happens if we need to add new events to our analytics system?
  • What if our analytics system was part of a separate package or module?
  • What happens when we have a lot of events?

So first of all, every time we need to add / update or remove any of the events in our analytics system we need to modify the enum. We can’t just implement new events and have them be compatible with the system. Also if the number of events becomes very large then the code will grow large in size. Making it hard to read, maintain and a bit of a mess. Also the enum now has multiple responsibilities, as it covers many events breaking the single responsibility principle.

The second issue which is probably the more important one, is let’s say we are breaking our app down in to separate packages. This Analytics Controller and Event would be in a separate package, what if we wanted to re-use it across different projects? Both of these scenarios become very difficult because we are using an enum that would need to be updated to accommodate events for different apps. The package would need constantly updating as new events were added.

The Solution

So we have identified some issues with the above implementation, how can we change it to make solve these issues we have identified? Look at the new example:

// 1
struct NewsListEvent: AnalyticsEvent {
    var title: String {
        return "News List"
    }
    
    var params: [String : String] {
        return [:]
    }
}

struct NewsDetailEvent: AnalyticsEvent {
    let id: Int
    
    var title: String {
        return "News detail"
    }
    
    var params: [String : String] {
        return ["id": "\(id)"]
    }
}

// 2
protocol AnalyticsEvent {
    var title: String { get }
    var params: [String: String] { get }
}

class AnalyticsController {
    func sendEvent(_ event: AnalyticsEvent) {
        let title = event.title
        let params = event.params
        
        // Send data to analytics network
    }
}

Let’s look at how we have changed the example:

  1. First of all we have now removed the enum. Using an enum as a huge list of possible options is considered a code smell. Especially when it involves something that may change often. If you have a finite number of states that is unlikely to change, that is more suited to an enum than a list of analytics events. We have refactored those enum cases into 2 separate classes now.

  2. We have switched the enum for a protocol that exposes the necessary items required by our analytics controller (we could have potentially done this in the previous example however we would still have the enum).

So what advantages does this have over the previous implementation?

  • With the events now being in separate classes we are now following the single responsibility principle, each event has its own class that can be updated whenever they need to be.
  • Now that we are using a protocol and not an enum, we are now able to add new events to our app without ever having to touch the analytics system. Simply create a new class and make it conform to AnalyticsEvent, and we can use it with the analytics controller.
  • Further to that we could have our analytics system in a separate reusable package, then our client apps could define their own set of events to use with the system.

Our analytics code is now open for extension, but does not need to be modified to support new events. Unlike our enum example.