Crypto News

Enhance Your Code Architecture With SOLID Principles (with Swift Examples)

Good architecture is not just about writing working code, but about creating code that is easy to extend, test, and maintain. This is exactly why the SOLID principles were formulated – five key foundations of object-oriented design proposed by Robert C. Martin (Robert C. Martin).

Following these principles helps achieve several important benefits:

  • Reduces code coupling, making modifications easier.
  • Minimizes the risk of errors when making changes.
  • Makes the system more flexible and easily extensible.
  • Simplifies testing and automation.

What is SOLID?

SOLID is an acronym consisting of five principles:

  1. S – Single Responsibility Principle – Each object should perform only one task and have only one reason to change.
  2. O – Open/Closed Principle – Code should be open for extension but closed for modification.
  3. L – Liskov Substitution Principle – Subclasses must fully replace the parent class without altering its behavior.
  4. I – Interface Segregation Principle – Classes should not be forced to implement methods they do not need.
  5. D – Dependency Inversion Principle – Modules should depend on abstractions rather than concrete implementations.

In this article, we will break down each of these principles using Swift code examples, explore common mistakes that violate these principles, and learn how to fix them. The examples will be easy to understand, so no complex language constructs will be used.

Grab some coffee, cookies, and let’s dive into SOLID! 🚀

S – Single Responsibility Principle (SRP)

According to this principle, each object should have only one responsibility, and that responsibility should be encapsulated within it.

Violation of SRP: The User class contains both user logic and email-sending logic.

final class User {
    let name: String
    let email: String

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

    func sendEmail(message: String) {
        print("Sending email to \(email) with message: \(message)")
    }
}

let user = User(name: "Tim", email: "[email protected]")
user.sendEmail(message: "Hello!")

How to fix it?

Separate responsibilities between User and EmailService.

final class User {
    let name: String
    let email: String

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

final class EmailService {
    func sendEmail(to user: User, message: String) {
        print("Sending email to \(user.email) with message: \(message)")
    }
}

let user = User(name: "Tim", email: "[email protected]")
let emailService = EmailService()
emailService.sendEmail(to: user, message: "Hello!")

Now User is responsible only for user data, while EmailService handles email sending. This makes the code easier to maintain and extend.


O – Open/Closed Principle (OCP)

This principle states that code should be open for extension but closed for modification.

Violation of OCP: The function uses conditional checks to determine the payment method. Adding a new payment method requires modifying this code.

final class PaymentProcessor {
    func processPayment(type: String, amount: Double) {
        if type == "credit_card" {
            print("Pay amount: \(amount) with credit card")
        } else if type == "paypal" {
            print("Pay amount: \(amount) via PayPal")
        } else {
            print("Unknown payment method")
        }
    }
}

let processor = PaymentProcessor()
processor.processPayment(type: "credit_card", amount: 100.0)
processor.processPayment(type: "paypal", amount: 200.0)

How to fix it?

Instead of using if-else statements, we can use abstraction – the PaymentMethod protocol. This allows adding new payment methods without modifying PaymentProcessor.

protocol PaymentMethod {
    func pay(amount: Double)
}

final class CreditCardPayment: PaymentMethod {
    func pay(amount: Double) {
        print("Pay amount: \(amount) with credit card")
    }
}

final class PayPalPayment: PaymentMethod {
    func pay(amount: Double) {
        print("Pay amount: \(amount) via PayPal")
    }
}

final class PaymentProcessor {
    func processPayment(method: PaymentMethod, amount: Double) {
        method.pay(amount: amount)
    }
}

let processor = PaymentProcessor()
let creditCard = CreditCardPayment()
let paypal = PayPalPayment()

processor.processPayment(method: creditCard, amount: 100.0)
processor.processPayment(method: paypal, amount: 200.0)

Now, if a new payment method (e.g., ApplePayPayment) is introduced, we do not need to modify PaymentProcessor – we just add a new class implementing PaymentMethod.


L – Liskov Substitution Principle (LSP)

Subclasses should replace the parent class without changing the program’s logic.

Violation of LSP: The base class Car has a refuel() method, but if we create ElectricCar, it should not be refueled with gasoline – it should be charged instead.

final class Car {
    func drive() {
        print("Drive")
    }

    func refuel() {
        print("Refuel")
    }
}

class GasolineCar: Car { }

class ElectricCar: Car {
    override func refuel() {
        fatalError("Can't fuel an electric car with gasoline!")
    }
}

func refuelCar(_ car: Car) {
    car.refuel()
}

let tesla = ElectricCar()
refuelCar(tesla) // ❌ Runtime error

Why is this a problem?

Liskov Substitution Principle (LSP) states that a subclass should fully replace its parent class without breaking functionality. Here, the function refuelCar(_:) expects any Car to be refueled, but ElectricCar violates this rule.

How to fix?

Separate interfaces for refuelable and chargeable vehicles.

class Car {
    func drive() {
        print("Drive")
    }
}

protocol Fuelable {
    func refuel()
}

protocol Chargeable {
    func charge()
}

final class GasolineCar: Car, Fuelable {
    func refuel() {
        print("Refuel")
    }
}

final class ElectricCar: Car, Chargeable {
    func charge() {
        print("Charge")
    }
}

// We have a function that only works with fuelable cars
func refuelCar(_ car: Fuelable) {
    car.refuel()
}

let bmw = GasolineCar()
refuelCar(bmw) // ✅

let tesla = ElectricCar()
// refuelCar(tesla) // ❌ Compile-time error, which is good! Now we can't mistakenly pass an electric car.

Now there is no LSP violation:

  • Subclasses of Car are compatible.
  • GasolineCar can be refueled with gasoline.
  • ElectricCar does not inherit the unnecessary refuel() method but uses charge() instead.

If you have classes that behave differently, it’s better to separate them through interfaces rather than forcing them to inherit methods they don’t need. This not only adheres to the Liskov Substitution Principle (LSP) but also improves code readability and maintainability.


I – Interface Segregation Principle (ISP)

The principle states that classes should not be forced to implement methods they don’t need.

Violation of ISP: We have a Vehicle interface that contains methods for all types of transport – drive()sail(), and fly(). However, a car cannot fly, and a boat cannot drive on the road.

protocol Vehicle {
    func drive()
    func sail()
    func fly()
}

class Car: Vehicle {
    func drive() {
        print("Drive")
    }

    func sail() {
        fatalError("A car cannot sail.")
    }

    func fly() {
        fatalError("A car cannot fly.")
    }
}

class Boat: Vehicle {
    func drive() {
        fatalError("A boat cannot drive.")
    }

    func sail() {
        print("Sail")
    }

    func fly() {
        fatalError("A boat cannot fly.")
    }
}

class Airplane: Vehicle {
    func drive() {
        fatalError("An airplane cannot drive.")
    }

    func sail() {
        fatalError("An airplane cannot sail.")
    }

    func fly() {
        print("Fly")
    }
}

The problem:

  • Car is forced to implement sail() and fly(), even though it doesn’t need them.
  • Boat must have a drive() method, but boats don’t drive on roads.
  • Airplane is also required to implement irrelevant methods.

Why is this bad?

  • If an interface forces a class to implement unnecessary methods, the code becomes fragile: any change to the interface will affect all classes, even if they don’t use the new methods.
  • The code is harder to read: a developer seeing Car.sail() might wonder if a car can actually sail, reducing code predictability.
  • If we later add a submarineseaplane, or bicycle, we will have to modify the interface again.

How to fix?

We separate interfaces into distinct responsibilities.

protocol Drivable {
    func drive()
}

protocol Sailable {
    func sail()
}

protocol Flyable {
    func fly()
}

final class Car: Drivable {
    func drive() {
        print("Drive")
    }
}

final class Boat: Sailable {
    func sail() {
        print("Sail")
    }
}

final class Airplane: Flyable {
    func fly() {
        print("Fly")
    }
}

Now the code follows ISP: classes implement only the methods they need. Adding new types of transport (e.g., Seaplane) no longer breaks existing code.

final class Seaplane: Flyable, Sailable {
    func fly() {
        print("Fly")
    }

    func sail() {
        print("Sail")
    }
}

By separating interfaces according to their purpose, we make the code flexible, maintainable, and logical.

The difference between Liskov Substitution Principle (LSP) and Interface Segregation Principle (ISP) is:

  • LSP deals with inheritance and requires that subclasses can replace the parent class without changing its behavior.
  • ISP deals with interfaces and ensures that classes are not forced to implement methods they don’t need.

D – Dependency Inversion Principle (DIP)

The principle states that:

  1. High-level modules should not depend on low-level modules.
  2. Both should depend on abstractions (protocols, interfaces).

Violation of DIP: The OrderService class directly depends on MySQLDatabase. If we want to switch to a different database, we will have to modify the OrderService code.

final class MySQLDatabase {
    func saveOrder(order: String) {
        print("MySQL save order: \(order)")
    }
}

final class OrderService {
    let database = MySQLDatabase() // 🚨 Strong dependency

    func createOrder(order: String) {
        print("Creating order: \(order)")
        database.saveOrder(order: order) // Strong relation with MySQLDatabase
    }
}

// Using
let service = OrderService()
service.createOrder(order: "#123")

The problem:

  • If we need to replace MySQLDatabase with PostgreSQLDatabasewe must modify the OrderService code.
  • This code is hard to test since we cannot substitute a fake database for testing.

How to fix?

We introduce a protocol (abstraction) so that OrderService depends on an interface rather than a specific database implementation.

protocol Database {
    func saveOrder(order: String)
}

final class MySQLDatabase: Database {
    func saveOrder(order: String) {
        print("MySQL save order: \(order)")
    }
}

final class PostgreSQLDatabase: Database {
    func saveOrder(order: String) {
        print("PostgreSQL save order: \(order)")
    }
}

final class OrderService {
    let database: Database // 💡 Now it's an abstraction

    init(database: Database) {
        self.database = database
    }

    func createOrder(order: String) {
        print("Creating order: \(order)")
        database.saveOrder(order: order) // Working with an abstraction
    }
}

// Using
let mysqlDB = MySQLDatabase()
let postgreDB = PostgreSQLDatabase()

let service1 = OrderService(database: mysqlDB)
service1.createOrder(order: "#123") // MySQL

let service2 = OrderService(database: postgreDB)
service2.createOrder(order: "#456") // PostgreSQL

Benefits:

Now we can easily switch from MySQLDatabase to PostgreSQLDatabase without modifying OrderService.

The code is now more flexible and easier to test – we can pass a mock database for testing.

OrderService no longer depends on a specific database implementation but works with an abstraction (Database).

Key takeaway: Depend on interfaces (protocols), not concrete classes.


Conclusion

By applying SOLID principles, you create code that remains stable, adaptable, and resistant to unnecessary complexity. These principles help prevent projects from turning into unmanageable chaos as they grow. Writing code with SOLID in mind ensures better organization, making it easier to maintain, test, and extend over time.

This leads to a more structured and predictable development process.

Although SOLID is not a rigid set of rules, it helps developers design scalable and flexible architectures. As a result, teams spend less time fixing issues and more time innovating. 🚀

Related Articles

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button