SOLID Principles in Simple Terms

SOLID principles explained the easy way — with real-life examples and simple code to back it up.

Intro #

The SOLID principles are simple, but many sources make them look hard. This post gives a clear and easy explanation of SOLID that anyone can understand, even if they are new to programming.

I will use real-world analogies to make the ideas easier to follow.

By the end of this post, you will see that SOLID is a simple and useful idea that can greatly improve your daily work as a developer.

Single Responsibility Principle (SRP) #

Let’s start our journey with the letter S from SOLID. The letter S stands for the Single Responsibility Principle.

Real-World Analogy #

Imagine any IT company with developers, designers, HR, and other roles. Each employee has clear responsibilities:

  • A developer writes code.
  • A designer handles UI/UX.
  • HR hires people.

A developer doesn’t handle UI/UX or hire people. A developer only writes code.

Similarly, in code, you should split your program into small pieces where each part has only one responsibility.

Check out the code examples below to see what I mean.

Code Example Without SRP #

Here is an example that violates the Single Responsibility Principle by handling both API requests and text conversion within the same entity.

struct ContentManager {
    func fetchNews() {
        print("Fetching news from the server...")
    }
    
    func convertTextToUppercase(_ text: String) -> String {
        return text.uppercased()
    }
}

// Usage
let manager = ContentManager()
manager.fetchNews()
let uppercasedText = manager.convertTextToUppercase("Hello, World!")
print(uppercasedText) // Outputs: HELLO, WORLD!

Issues:

  • ContentManager handles both network operations and text conversion.
  • Changes in one functionality require modifying the same entity.

Example With SRP #

How do we fix the code above? Simple! Just split the code into separate entities.

// Responsible for API requests
struct NetworkService {
    func fetchNews() {
        print("Fetching news from the server...")
    }
}

// Responsible for text conversions
struct TextConverter {
    func convertTextToUppercase(_ text: String) -> String {
        return text.uppercased()
    }
}

// Usage
let networkService = NetworkService()
networkService.fetchNews()

let textConverter = TextConverter()
let uppercasedText = textConverter.convertTextToUppercase("Hello, World!")
print(uppercasedText) // Outputs: HELLO, WORLD!

Benefits:

  • Separation of Concerns: Each entity has a single responsibility.
  • Maintainability: It is easier to manage and update code.
  • Reusability: Entities can be reused independently in different parts of the application.

Definition #

Now that we’ve explained the principle, here’s the definition: Each part of your code should do one thing only.

By following the Single Responsibility Principle, your code becomes cleaner, more organized, and easier to maintain.

Open–Closed Principle (OCP) #

The letter O in SOLID stands for the Open-Closed Principle.

Real-World Analogy #

A great real-world analogy for the Open-Closed Principle is the idea of plugging new appliances into an electrical outlet.

Here’s how it works:

  • Electrical Outlet (Open): The outlet is designed to allow different appliances (like a toaster, blender, or vacuum cleaner) to plug into it and work. The outlet itself doesn’t need to change when you use a different appliance — it’s “open” to new devices.

  • Appliance (Closed): The appliance is “closed” because its internal design doesn’t need to change when you want to use it with the outlet. You simply plug it in, and the appliance works as intended.

In this analogy:

  • The outlet represents an entity that is open for extension (you can plug new devices in or extend the functionality of the system without modifying the outlet).
  • The appliance represents the new functionality or feature you want to add. The appliance, like a new feature, can be added without altering the core outlet (existing code). This follows the idea of being “open for extension, but closed for modification.”

Thus, you can add new appliances (features) to the same outlet (system) without needing to modify the outlet itself.

Code Example Without OCP #

Imagine you’re developing a bank application. Consider a PaymentProcessor class that handles different payment methods:

class PaymentProcessor {
    func processPayment(method: String) {
        if method == "CreditCard" {
            // Process credit card payment
        } else if method == "PayPal" {
            // Process PayPal payment
        }
        // Additional payment methods...
    }
}

Issues:

Every time you introduce a new payment method, you need to modify the processPayment method. This approach makes the code fragile and harder to maintain, which violates the Open–Closed Principle.

Code Example With OCP #

To follow OCP, use a protocol to define a payment method and implement each method in separate classes:

// Define a protocol for payment methods
protocol PaymentMethod {
    func processPayment()
}

// Implement Credit Card payment
class CreditCardPayment: PaymentMethod {
    func processPayment() {
        // Logic to process credit card payment
        print("Processing credit card payment.")
    }
}

// Implement PayPal payment
class PayPalPayment: PaymentMethod {
    func processPayment() {
        // Logic to process PayPal payment
        print("Processing PayPal payment.")
    }
}

// You can easily add more payment methods without changing existing code
class ApplePayPayment: PaymentMethod {
    func processPayment() {
        // Logic to process Apple Pay payment
        print("Processing Apple Pay payment.")
    }
}

Updated PaymentProcessor using OCP:

class PaymentProcessor {
    func process(paymentMethod: PaymentMethod) {
        paymentMethod.processPayment()
    }
}

Usage Example:

let processor = PaymentProcessor()

let creditCard = CreditCardPayment()
processor.process(paymentMethod: creditCard)
// Output: Processing credit card payment.

let payPal = PayPalPayment()
processor.process(paymentMethod: payPal)
// Output: Processing PayPal payment.

let applePay = ApplePayPayment()
processor.process(paymentMethod: applePay)
// Output: Processing Apple Pay payment.

Benefits:

  • Extensibility: Add new payment methods by creating new classes that conform to PaymentMethod without modifying PaymentProcessor.
  • Maintainability: There is less risk of introducing bugs since existing code remains unchanged.
  • Scalability: Easily scale the application by adding new functionalities through extensions.

Definition #

The official definition sounds like: It states that software entities should be open for extension but closed for modification.

But in simple terms we can say that like: Write your code to allow adding new functionality without altering existing code.

Liskov Substitution Principle (LSP) #

The letter L in SOLID stands for the Liskov Substitution Principle.

It dictates that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.

Real-World Analogy #

Imagine you’re renting a car. You rent a standard sedan, and you know it has four doors, seats five, and drives smoothly. You then rent a sports car as a replacement. It’s smaller, faster, and has a sleek design, but it still behaves like a car—four wheels, steering, and drives you from point A to point B.

Now, if the rental company offers you a bicycle as a replacement, even though it’s a form of transport, it no longer behaves like a car. It doesn’t have four wheels, it can’t drive on highways, and it requires pedaling. This would break the expectations you had when you rented a car.

In this case:

  • The standard sedan is the base class.
  • The sports car is a subclass that behaves like a sedan, just with added features (faster, sleeker).
  • The bicycle is a subclass that doesn’t fulfill the same expectations as a car.

This illustrates the Liskov Substitution Principle: you should be able to substitute a subclass (like the sports car) for the base class (sedan) without disrupting the behavior you’d expect.

In simple words, LSP is about two things:

  • If a parent class or type says “you can do X,” then a child class shouldn’t break that promise!
  • Using the child instead of the parent must not crash or change the app’s behavior unexpectedly.

Code Example Without LSP #

Consider a banking application with different account types: regular accounts and fixed-term deposit accounts.

Base Class: BankAccount with methods deposit() and withdraw().

Derived Class: FixedTermDepositAccount where withdraw() is restricted until the term ends.

// Base class representing a general bank account
class BankAccount {
    var balance: Double
    
    init(balance: Double) {
        self.balance = balance
    }
    
    func deposit(amount: Double) {
        balance += amount
        print("Deposited \(amount). New balance is \(balance).")
    }
    
    func withdraw(amount: Double) {
        if amount <= balance {
            balance -= amount
            print("Withdrew \(amount). New balance is \(balance).")
        } else {
            print("Insufficient funds.")
        }
    }
}

// Derived class representing a fixed-term deposit account
class FixedTermDepositAccount: BankAccount {
    let termEndDate: Date
    
    init(balance: Double, termEndDate: Date) {
        self.termEndDate = termEndDate
        super.init(balance: balance)
    }
    
    override func withdraw(amount: Double) {
        let currentDate = Date()
        if currentDate >= termEndDate {
            super.withdraw(amount: amount)
        } else {
            print("Cannot withdraw before the term ends on \(termEndDate).")
        }
    }
}

// Usage
let regularAccount: BankAccount = BankAccount(balance: 1000)
regularAccount.withdraw(amount: 200) // Works fine

let fixedAccount: BankAccount = FixedTermDepositAccount(balance: 1000, termEndDate: Date().addingTimeInterval(60*60*24*30)) // 30 days from now
fixedAccount.withdraw(amount: 200) // Unexpected behavior: Withdrawal might be restricted

This setup violates LSP because substituting a FixedTermDepositAccount for a BankAccount can cause unexpected behavior when withdraw() is called.

Code Example With LSP #

Redesign the class hierarchy to reflect the specific behaviors of each account type, ensuring that substituting subclasses does not lead to unexpected behavior.

import Foundation

// Protocol defining deposit functionality
protocol Depositable {
    var balance: Double { get set }
    func deposit(amount: Double)
}

// Protocol defining withdrawal functionality
protocol Withdrawable: Depositable {
    func withdraw(amount: Double)
}

// Base class for accounts that can be deposited and withdrawn
class BankAccount: Withdrawable {
    var balance: Double
    
    init(balance: Double) {
        self.balance = balance
    }
    
    func deposit(amount: Double) {
        balance += amount
        print("Deposited \(amount). New balance is \(balance).")
    }
    
    func withdraw(amount: Double) {
        if amount <= balance {
            balance -= amount
            print("Withdrew \(amount). New balance is \(balance).")
        } else {
            print("Insufficient funds.")
        }
    }
}

// Derived class for fixed-term deposit accounts that cannot be withdrawn early
class FixedTermDepositAccount: Depositable {
    var balance: Double
    let termEndDate: Date
    
    init(balance: Double, termEndDate: Date) {
        self.balance = balance
        self.termEndDate = termEndDate
    }
    
    func deposit(amount: Double) {
        balance += amount
        print("Deposited \(amount). New balance is \(balance).")
    }
    
    // Withdraw method is not available, adhering to LSP
}

// Usage
func performWithdrawal(account: Withdrawable, amount: Double) {
    account.withdraw(amount: amount)
}

let regularAccount = BankAccount(balance: 1000)
regularAccount.withdraw(amount: 200) // Works fine

let fixedAccount = FixedTermDepositAccount(balance: 1000, termEndDate: Date().addingTimeInterval(60*60*24*30)) // 30 days from now
fixedAccount.deposit(amount: 500) // Works fine
// fixedAccount.withdraw(amount: 200) // Error: 'FixedTermDepositAccount' does not conform to 'Withdrawable'

// This ensures that only accounts that support withdrawal can be used where withdrawal is needed
performWithdrawal(account: regularAccount, amount: 300)
// performWithdrawal(account: fixedAccount, amount: 300) // Compile-time error

Improvements:

  • Protocols Separation: The Withdrawable protocol inherits from Depositable. BankAccount conforms to Withdrawable, meaning it supports both deposit and withdrawal.
  • FixedTermDepositAccount: Only conforms to Depositable, meaning it supports deposit but not withdrawal.
  • Safe Usage: Functions that require withdrawal operations accept only Withdrawable types, ensuring that fixed-term accounts cannot be used in contexts where withdrawal is expected.
  • No Unexpected Behavior: Substituting FixedTermDepositAccount for BankAccount is no longer possible in contexts requiring withdrawal, thus adhering to LSP.

Definition #

Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.

Interface Segregation Principle (ISP) #

The letter I in SOLID stands for the Interface Segregation Principle.

Real-World Analogy #

Consider a device that can print, scan, and fax. Instead of one interface called IMultiFunctionDevice with methods for all three actions, ISP recommends creating separate protocols like Printer, Scanner, and Fax. A class that only needs to print would implement Printer without being forced to include scanning or faxing methods.

Code Example Without ISP #

Imagine that you are developing a bank app. In the bank app, BankOperations protocol combines functionalities for both regular customers and bank administrators. This forces any conforming class to implement all methods, even if some are irrelevant to them.

// Protocol combining customer and admin operations
protocol BankOperations {
    // Customer operations
    func viewAccountBalance(accountId: String) -> Double
    func transferFunds(from: String, to: String, amount: Double)
    
    // Admin operations
    func approveLoan(applicationId: String) -> Bool
    func generateFinancialReport() -> String
}

// Customer class conforming to BankOperations
class Customer: BankOperations {
    func viewAccountBalance(accountId: String) -> Double {
        // Implementation
        return 1000.0
    }
    
    func transferFunds(from: String, to: String, amount: Double) {
        // Implementation
    }
    
    // Unused admin methods
    func approveLoan(applicationId: String) -> Bool {
        fatalError("Customer cannot approve loans.")
    }
    
    func generateFinancialReport() -> String {
        fatalError("Customer cannot generate reports.")
    }
}

// Admin class conforming to BankOperations
class Admin: BankOperations {
    func viewAccountBalance(accountId: String) -> Double {
        // Implementation
        return 0.0
    }
    
    func transferFunds(from: String, to: String, amount: Double) {
        // Implementation
    }
    
    func approveLoan(applicationId: String) -> Bool {
        // Implementation
        return true
    }
    
    func generateFinancialReport() -> String {
        // Implementation
        return "Report"
    }
}

Code Example With ISP #

Here, the BankOperations protocol is split into smaller, more specific protocols: CustomerOperations and AdminOperations. This ensures that classes only implement the methods they actually use.

// Protocol for customer-specific operations
protocol CustomerOperations {
    func viewAccountBalance(accountId: String) -> Double
    func transferFunds(from: String, to: String, amount: Double)
}

// Protocol for admin-specific operations
protocol AdminOperations {
    func approveLoan(applicationId: String) -> Bool
    func generateFinancialReport() -> String
}

// Customer class conforming to CustomerOperations
class Customer: CustomerOperations {
    func viewAccountBalance(accountId: String) -> Double {
        // Implementation
        return 1000.0
    }
    
    func transferFunds(from: String, to: String, amount: Double) {
        // Implementation
    }
}

// Admin class conforming to both CustomerOperations and AdminOperations if needed
class Admin: CustomerOperations, AdminOperations {
    func viewAccountBalance(accountId: String) -> Double {
        // Implementation
        return 0.0
    }
    
    func transferFunds(from: String, to: String, amount: Double) {
        // Implementation
    }
    
    func approveLoan(applicationId: String) -> Bool {
        // Implementation
        return true
    }
    
    func generateFinancialReport() -> String {
        // Implementation
        return "Report"
    }
}

Benefits:

  • Separation of Concerns: Each protocol has a clear responsibility.
  • Flexibility: Classes only implement the interfaces they need.
  • Maintainability: Easier to manage and extend specific functionalities without affecting unrelated parts.

By adhering to the Interface Segregation Principle, your code becomes more modular, easier to understand, and maintain, especially as your application grows in complexity.

Definition #

The Interface Segregation Principle (ISP) is about creating small, specific protocols instead of one large, general-purpose protocol.

Dependency Inversion Principle (DIP) #

The letter D in SOLID stands for the Dependency Inversion Principle.

Real-World Analogy #

Imagine a Lamp.

  • Lamp (High-Level Module): Needs a power source to light up.
  • Power Source (Low-Level Module): Could be a wall socket, a battery, or a solar panel.

Without DIP:

  • The lamp comes with a built-in cord that only fits into a specific type of socket (like a wall outlet). If you want to change how the lamp gets powered (say, using a battery or solar panel), you have to modify the lamp itself.

With DIP:

  • The lamp has a removable power input (a socket) where you can plug in any power source: wall plug, battery, or solar panel. The lamp doesn’t care which power source you use, as long as it fits the input (provides the right voltage).

Benefits:

  • Flexibility: You can easily switch power sources (e.g., from a wall socket to a battery) without changing the lamp.
  • Reusability: The lamp works with any power source that fits its input, making it adaptable to different environments.

In simpler terms, high-level code (like business logic) shouldn’t be tightly linked to low-level code (like data access or utilities). Instead, both should rely on abstract definitions (like protocols or abstract classes).

Code Example Without DIP #

In this example, the Lamp (high-level module) directly depends on a specific WallSocket (low-level module). This tight coupling makes it difficult to switch to a different power source without modifying the Lamp class.

// Low-Level Module
class WallSocket {
    func providePower() {
        print("Providing power through wall socket.")
    }
}

// High-Level Module
class Lamp {
    private let wallSocket: WallSocket

    init() {
        self.wallSocket = WallSocket()
    }

    func turnOn() {
        wallSocket.providePower()
        print("Lamp is now ON.")
    }
}

// Usage
let lamp = Lamp()
lamp.turnOn()

Issues:

  • The Lamp is tightly coupled to WallSocket.
  • To use a different power source (e.g., Battery), you’d need to modify the Lamp class.

Code Example With DIP #

Here, both Lamp and various power sources depend on an abstract PowerSource protocol. This decouples the high-level Lamp from the low-level power source implementations, enhancing flexibility and testability.

// Abstraction
protocol PowerSource {
    func providePower()
}

// Low-Level Modules
class WallSocket: PowerSource {
    func providePower() {
        print("Providing power through wall socket.")
    }
}

class Battery: PowerSource {
    func providePower() {
        print("Providing power through battery.")
    }
}

class SolarPanel: PowerSource {
    func providePower() {
        print("Providing power through solar panel.")
    }
}

// High-Level Module
class Lamp {
    private let powerSource: PowerSource

    init(powerSource: PowerSource) {
        self.powerSource = powerSource
    }

    func turnOn() {
        powerSource.providePower()
        print("Lamp is now ON.")
    }
}

// Usage
let wallSocket = WallSocket()
let lampWithWallSocket = Lamp(powerSource: wallSocket)
lampWithWallSocket.turnOn()

let battery = Battery()
let lampWithBattery = Lamp(powerSource: battery)
lampWithBattery.turnOn()

let solarPanel = SolarPanel()
let lampWithSolar = Lamp(powerSource: solarPanel)
lampWithSolar.turnOn()

Benefits:

  • Flexibility: Easily switch between different power sources without changing the Lamp class.
  • Reusability: The Lamp can work with any future power sources that conform to the PowerSource protocol.
  • Testability: You can create mock power sources conforming to PowerSource for unit testing the Lamp class.

Summary #

I hope that now you see that SOLID principles aren’t so hard and that applying them can greatly improve how you design, extend, and maintain your code.