Skip to content

Instantly share code, notes, and snippets.

@mjhassan
Last active October 29, 2025 02:04
Show Gist options
  • Select an option

  • Save mjhassan/2a61c16c68d66fd43f24306a07900bc6 to your computer and use it in GitHub Desktop.

Select an option

Save mjhassan/2a61c16c68d66fd43f24306a07900bc6 to your computer and use it in GitHub Desktop.
An easy and simple explanation of SOLID principles with Swift examples

Overview

SOLID represents 5 principles of object-oriented programming: Single responsibility, Open-closed, Liskov Substitution, Interface Segregation and Dependency Inversion.

These principles is to solve the main problems of a bad architecture:

  • Fragility: Unable to change.
  • Immobility: Unable to reuse, because of tightly coupled dependencies.
  • Rigidity: Single change affects several parts of the project.

Principles

Single Responsibility Principle (SRP)

A class should have one, and only one, reason to change.

Let's check the following example:

class SomeAPIClass {
  func updateLocalData() {
    let data = requestDataFromAPI()
    let array = parse(data: data)
    saveToDB(array: array)
  }

  private func requestDataFromAPI() -> Data {
   // send API request and wait the response
  }

  private func parse(data: Data) -> [String] {
   // parse the data and create the array
  }

  private func saveToDB(array: [String]) {
   // save the array in a database (CoreData/Realm/...)
  }
}

How many responsibilities does this class have?

SomeAPIClass (1) retrieves the data from the API, (2) parses the API response and creating an array of String, (3) and saves the array in a database.

If SRP is applied to this class, it would be like:

class SomeAPIClass {
  private let apiHandler: APIHandler
  private let parseHandler: ParseHandler
  private let dbHandler: DBHandler

  init(apiHandler: APIHandler, parseHandler: ParseHandler, dbHandler: DBHandler) {
    self.apiHandler = apiHandler
    self.parseHandler = parseHandler
    self.dbHandler = dbHandler
  }

  func updateLocalData() {
    let data = apiHandler.requestDataToAPI()
    let array = parseHandler.parse(data: data)
    dbHandler.saveToDB(array: array)
  }      
}

// separeted classes based on SRP
class APIHandler {
  func requestDataToAPI() -> Data {
    // send API request and wait the response
  }
}

class ParseHandler {
  func parse(data: Data) -> [String] {
    // parse the data and create the array
  }
}

class DBHandler {
  func saveToDB(array: [String]) {
    // save the array in a database (CoreData/Realm/...)
  }
}

Open-closed Principle

Entities(classes, modules, functions, etc.) should be open for extension, but closed for modification.

Take a look at the following code :

class Rectangle {
  private let width: Double
  private let height: Double

  init(width: Double, height: Double) {
    self.width = width
    self.height = height
  }
}

class Circle {
  private let radius: Double

  init(radius: Double) {
    self.radius = radius
  }
}

class CostManager {
  public func calculate(shape: Any) -> Double? {
    private let costPerUnit = 1.5

    var area: Double? = nil
    if let rectangle = shape as? Rectangle {
      area = rectangle.width * rectangle.height
    } 
    else if let circle = shape as? Circle {
      area = pow(circle.width, 2) * Double.pi
    }

    return costPerUnit * area
  }
}

let circle = Circle(5)
let rect = Rectangle(8, 5)
let costManager = CostManager()
print("Cost of circle: \(costManager.calculate(circle) ?? 0)")

Now, If we want to calculate the area for Square we have to modify calculate method in CostManager class. It breaks the open-closed principle. According to this principle, we can not modify we can extend.

So, how we can fix this problem see the following code :

protocol AreaInterface {
  public function calculateArea() -> Double
}

class Rectangle: AreaInterface {
  private let width: Double
  private let height: Double

  init(width: Double, height: Double) {
    self.width = width
    self.height = height
  }

  func calculateArea() -> Double {
	return self.width * self.height
  }
}

class Circle: AreaInterface {
  private let radius: Double

  init(radius: Double) {
    self.radius = radius
  }

  func calculateArea() -> Double {
    return pow(self.width, 2) * Double.pi
  }
}

class Square: AreaInterface {
  private let size: Double

  init(size: Double) {
    self.size = size
  }

  func calculateArea() -> Double {
    return pow(self.size, 2)
  }
}

class CostManager {
  private let costPerUnit = 1.5

  func calculate(shape: AreaInterface) -> Double {
    return shape.calculateArea() * costPerUnit
  }
}

let circle = Circle(5)
let rect = Rectangle(8, 5)
let costManager = CostManager()
print("Cost of circle: \(costManager.calculate(circle) ?? 0)")

Liskov Substitution Principle

Subclass/derived class should be substitutable for their base/parent class.

This principle is the extension of Open-Closed principle and it means that we must make sure that the new derived classes are extending the base class without changing their behaviour.

A code snippet to show how violates LSP and how we can fix it :

class Handler {
  func save(string: String) {
	// Save string in somewhere
  }
}

class FilteredHandler: Handler {
  override func save(string: String) {
    guard string.characters.count > 5 else { return }

    super.save(string: string)
  }
}

This example breaks LSP because, in the subclass, we add the precondition that string must have a length greater than 5. A client of Handler doesn’t expect that FilteredHandler has a different precondition, since it should be the same for Handler and all its subclasses.

We can solve this problem getting rid of FilteredHandler and adding a new parameter to inject the minimum length of characters to filter:

class Handler {
  func save(string: String, minChars: Int = 0) {
    guard string.characters.count >= minChars else { return }
      
    // Save string in somewhere
  }
}

Let's check another example of bird protocol, as follows:

protocol Bird {
  var altitudeToFly: Double? {get}

  func setLocation(longitude: Double , latitude: Double)
  mutating func setAltitude(altitude: Double)
}

This is a sound protocol for bird; but is not applicable for the birds who can't fly. Let's say we want a Penguin class confirming Bird protocol.

class Penguin: Bird {
  @override func setAltitude(altitude: Double) {
    //Altialtitude can't be set because penguins can't fly
    //throw exception
  }
}

If an override method does nothing or just throws an exception, then you’re probably violating the LSP.

How to overcome from this problem? Modularize the protocol into common group and confirm to appropriate classes.

protocol Bird {
  var altitudeToFly: Double? {get}

  func setLocation(longitude: Double , latitude: Double)
}

protocol Flying {
  mutating func setAltitude(altitude: Double)
}

protocol FlyingBird: Bird, Flying {}

struct Owl: FlyingBird {
  var altitudeToFly: Double?
 
  mutating func setAltitude(altitude: Double) {
  	altitudeToFly = altitude
  }

  func setLocation(longitude: Double, latitude: Double) {
  	//Set location value
  }
}

struct Penguin: Bird {
  var altitudeToFly: Double?
  func setLocation(longitude: Double, latitude: Double) {
  	//Set location value
  }
}

Interface Segregation Principle

A Client should not be forced to implement an interface that it doesn’t use.

Fat Interface (Protocol)

We start with the protocol GestureProtocol with a method didTap:

protocol GestureProtocol {
  func didTap()
}

After some time, you have to add new gestures to the protocol and it becomes:

protocol GestureProtocol {
  func didTap()
  func didDoubleTap()
  func didLongPress()
}

Our SuperButton is happy to implement the methods which it needs:

class SuperButton: GestureProtocol {
  func didTap() {
    // send tap action
  }

  func didDoubleTap() {
    // send double tap action
  }

  func didLongPress() {
    // send long press action
  }
}

The problem is that our app has also a PoorButton which needs just didTap. It must implement methods which it doesn’t need, breaking ISP:

class PoorButton: GestureProtocol {
  func didTap() {
    // send tap action
  }

  func didDoubleTap() { }
  func didLongPress() { }
}

We can solve the problem using little protocols instead of a big one:

protocol TapProtocol {
  func didTap()
}

protocol DoubleTapProtocol {
  func didDoubleTap()
}

protocol LongPressProtocol {
  func didLongPress()
}

class SuperButton: TapProtocol, DoubleTapProtocol, LongPressProtocol {
  func didTap() {
  	// send tap action
  }

  func didDoubleTap() {
  	// send double tap action
  }

  func didLongPress() {
  	// send long press action
  }
}

class PoorButton: TapProtocol {
  func didTap() {
  	// send tap action
  }
}
Fat Interface (Class)

We can use, as an example, an application which has a collection of playable videos. This app has the class Video which represents a video of the user’s collection:

class Video {
  var title: String = "My Video"
  var description: String = "This is a beautiful video"
  var author: String = "Marco Santarossa"
  var url: String = "https://marcosantadev.com/my_video"
  var duration: Int = 60
  var created: Date = Date()
  var update: Date = Date()
}

And we inject it in the video player:

func play(video: Video) {
  // load the player UI

  // load the content at video.url

  // add video.title to the player UI title

  // update the player scrubber with video.duration
}

Unfortunately, we are injecting too much information in the method play, since it needs just url, title and duration.

You can solve this problem using a protocol Playable with just the information the player needs:

protocol Playable {
  var title: String { get }
  var url: String { get }
  var duration: Int { get }
}

class Video: Playable {
  var title: String = "My Video"
  var description: String = "This is a beautiful video"
  var author: String = "Marco Santarossa"
  var url: String = "https://marcosantadev.com/my_video"
  var duration: Int = 60
  var created: Date = Date()
  var update: Date = Date()
}

func play(video: Playable) {
  // load the player UI

  // load the content at video.url

  // add video.title to the player UI title

  // update the player scrubber with video.duration
}

This approach is very useful also for the unit test. We can create a stub class which implements the protocol Playable:

class StubPlayable: Playable {
  private(set) var isTitleRead = false

  var title: String {
    self.isTitleRead = true
    return "My Video"
  }

  var duration = 60
  var url: String = "https://marcosantadev.com/my_video"
}

func test_Play_IsUrlRead() {
  let stub = StubPlayable(
  play(video: stub)
  XCTAssertTrue(stub.isTitleRead)
}

Dependency Inversion Principle (DIP)

Abstractions should not depend on details. Details should depend on abstractions.

or simply

Depend on Abstractions not on concretions

DIP is very similar to Open-Closed Principle; the approach to use, to have a clean architecture, is decoupling the dependencies. You can achieve it thanks to abstract layers.

Let’s consider a company management system in which we have the Management class which is a high-level class, and we have low level class called Employee.

struct Employee {
  func work() {
	  // ....working
  }
}

struct Management {
  let worker: Employee

  init (worker: Employee) {
  	self.worker = worker
  }

  func manage() {
  	worker.work()
  }
}

Let’s assume the Management class is quite complex, containing very complex logic. And now we have to change it in order to introduce the new Employer.

We can solve this problem using DIP principle.

protocol IEmployee {
	func work()
}

struct Employee: IEmployee {
	func work() {
		print("Employee working...") //Employee working...
	}
}

struct Employer: IEmployee {
	func work() {
		print("Employer working...") //Employer working...
	}
}


struct Management {
	let employee: IEmployee;

	init (e: IEmployee) {
		employee = e;
	}

	func manage() {
		employee.work();
	}
}

//Let’s give it a try,
let employee = Employee()
let management = Management(e:employee)
management.manage() // Output - Employee working...

let employer = Employer()
let highLevelManagement = Management(e:employer)
highLevelManagement.manage() // Output - Employer working...
@mehdi-S
Copy link

mehdi-S commented Feb 7, 2025

🙌

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment