SOLID Principles of OOP
with examples in Swift

  Feb 21, 2025 -   read
  oop, swift, solid

SOLID Principles applied in Swift programming

Let’s explore how to apply the SOLID principles of Object-Oriented Programming (OOP) when designing a Swift SDK for an iOS app, specifically one for Oracle to manage customer cloud services. SOLID stands for Single Responsibility Principle (SRP), Open/Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP). Below, I’ll provide practical examples tailored to an Oracle Cloud Services SDK in Swift.

Assume the SDK handles tasks like authentication, fetching customer data, managing cloud resources (e.g., databases, compute instances), and logging usage analytics.


1. Single Responsibility Principle (SRP)

Definition: A class should have only one reason to change, meaning it should have a single responsibility.

Example: Separate authentication logic from resource management.

// Bad: One class handling both authentication and resource fetching
class CloudManager {
    func authenticate(username: String, password: String) { /* ... */ }
    func fetchResources() { /* ... */ }
}

// Good: Split responsibilities
class AuthenticationManager {
    func authenticate(username: String, password: String) -> Result<String, Error> {
        // Authenticate with Oracle Cloud API and return token
        return .success("auth_token")
    }
}

class ResourceManager {
    private let authToken: String
    
    init(authToken: String) {
        self.authToken = authToken
    }
    
    func fetchResources() -> [CloudResource] {
        // Fetch resources using authToken
        return [CloudResource(id: "db1", type: .database)]
    }
}
  • Why it’s SRP: AuthenticationManager is responsible only for authentication, and ResourceManager handles resource-related tasks. If authentication logic changes (e.g., OAuth vs. API key), only AuthenticationManager is affected.

2. Open/Closed Principle (OCP)

Definition: Classes should be open for extension but closed for modification.

Example: Allow adding new cloud resource types (e.g., database, compute) without modifying existing code.

// Bad: Hardcoding resource types in a switch
class ResourceHandler {
    func processResource(type: String) {
        switch type {
        case "database": print("Processing database")
        case "compute": print("Processing compute")
        default: break
        }
    }
}

// Good: Use protocol and extensions
protocol CloudResource {
    var id: String { get }
    func process()
}

struct DatabaseResource: CloudResource {
    let id: String
    func process() {
        print("Processing database: \(id)")
    }
}

struct ComputeResource: CloudResource {
    let id: String
    func process() {
        print("Processing compute: \(id)")
    }
}

class ResourceHandler {
    func processResource(_ resource: CloudResource) {
        resource.process()
    }
}

// Usage
let handler = ResourceHandler()
let db = DatabaseResource(id: "db1")
let compute = ComputeResource(id: "comp1")
handler.processResource(db)      // "Processing database: db1"
handler.processResource(compute) // "Processing compute: comp1"
  • Why it’s OCP: New resource types (e.g., StorageResource) can be added by conforming to CloudResource without changing ResourceHandler.

3. Liskov Substitution Principle (LSP)

Definition: Subtypes must be substitutable for their base types without altering the correctness of the program.

Example: Ensure a base CloudService protocol can be swapped with specific implementations.

protocol CloudService {
    func start() throws
    func stop() throws
}

class DatabaseService: CloudService {
    func start() throws {
        print("Starting database service")
    }
    
    func stop() throws {
        print("Stopping database service")
    }
}

class ComputeService: CloudService {
    func start() throws {
        print("Starting compute service")
    }
    
    func stop() throws {
        print("Stopping compute service")
    }
}

class CloudController {
    func manageService(_ service: CloudService) throws {
        try service.start()
        try service.stop()
    }
}

// Usage
let controller = CloudController()
let dbService = DatabaseService()
let computeService = ComputeService()
try controller.manageService(dbService)       // Works with DatabaseService
try controller.manageService(computeService)  // Works with ComputeService
  • Why it’s LSP: DatabaseService and ComputeService can substitute CloudService without breaking CloudController. If a subtype violated this (e.g., throwing unexpected errors), it would break LSP.

4. Interface Segregation Principle (ISP)

Definition: Clients should not be forced to depend on interfaces they don’t use.

Example: Split a broad CloudManager protocol into smaller, focused protocols.

// Bad: One large protocol
protocol CloudManager {
    func authenticate()
    func fetchResources()
    func logAnalytics()
}

// Good: Segregated protocols
protocol Authenticator {
    func authenticate()
}

protocol ResourceFetcher {
    func fetchResources()
}

protocol AnalyticsLogger {
    func logAnalytics()
}

class OracleCloudClient: Authenticator, ResourceFetcher, AnalyticsLogger {
    func authenticate() {
        print("Authenticating with Oracle Cloud")
    }
    
    func fetchResources() {
        print("Fetching cloud resources")
    }
    
    func logAnalytics() {
        print("Logging usage data")
    }
}

// Usage: Clients only depend on what they need
func setupAuthenticator(_ auth: Authenticator) {
    auth.authenticate()
}

let client = OracleCloudClient()
setupAuthenticator(client) // Only uses Authenticator
  • Why it’s ISP: A client needing only authentication isn’t forced to implement or depend on resource fetching or analytics logging.

5. Dependency Inversion Principle (DIP)

Definition: High-level modules should not depend on low-level modules; both should depend on abstractions. Also, abstractions should not depend on details.

Example: Decouple networking logic from the SDK using a protocol.

// Bad: Direct dependency on URLSession
class ResourceManager {
    private let session = URLSession.shared
    
    func fetchResources() {
        session.dataTask(with: URL(string: "https://oraclecloud.com/resources")!) { _, _, _ in }
    }
}

// Good: Depend on an abstraction
protocol NetworkClient {
    func fetchData(from url: URL) async throws -> Data
}

class URLSessionClient: NetworkClient {
    private let session: URLSession
    
    init(session: URLSession = .shared) {
        self.session = session
    }
    
    func fetchData(from url: URL) async throws -> Data {
        let (data, _) = try await session.data(from: url)
        return data
    }
}

class ResourceManager {
    private let networkClient: NetworkClient
    
    init(networkClient: NetworkClient) {
        self.networkClient = networkClient
    }
    
    func fetchResources() async throws -> [CloudResource] {
        let data = try await networkClient.fetchData(from: URL(string: "https://oraclecloud.com/resources")!)
        // Parse data and return resources
        return [CloudResource(id: "db1", type: .database)]
    }
}

// Usage
let networkClient = URLSessionClient()
let resourceManager = ResourceManager(networkClient: networkClient)
  • Why it’s DIP: ResourceManager depends on the NetworkClient abstraction, not URLSession. This allows swapping implementations (e.g., a mock client for testing) without changing ResourceManager.

Putting It Together: Oracle Cloud SDK

Here’s a cohesive example combining these principles:

// Abstractions
protocol Authenticator {
    func authenticate() async throws -> String
}

protocol ResourceFetcher {
    func fetchResources() async throws -> [CloudResource]
}

protocol CloudResource {
    var id: String { get }
    func process()
}

// Concrete implementations
struct DatabaseResource: CloudResource {
    let id: String
    func process() { print("Processing database: \(id)") }
}

class OracleAuthenticator: Authenticator {
    private let networkClient: NetworkClient
    
    init(networkClient: NetworkClient) {
        self.networkClient = networkClient
    }
    
    func authenticate() async throws -> String {
        let data = try await networkClient.fetchData(from: URL(string: "https://oraclecloud.com/auth")!)
        return "auth_token" // Simplified
    }
}

class OracleResourceFetcher: ResourceFetcher {
    private let authToken: String
    private let networkClient: NetworkClient
    
    init(authToken: String, networkClient: NetworkClient) {
        self.authToken = authToken
        self.networkClient = networkClient
    }
    
    func fetchResources() async throws -> [CloudResource] {
        let data = try await networkClient.fetchData(from: URL(string: "https://oraclecloud.com/resources?token=\(authToken)")!)
        return [DatabaseResource(id: "db1")]
    }
}

// Usage
let networkClient = URLSessionClient()
let authenticator = OracleAuthenticator(networkClient: networkClient)
let token = try await authenticator.authenticate()
let fetcher = OracleResourceFetcher(authToken: token, networkClient: networkClient)
let resources = try await fetcher.fetchResources()
resources.forEach { $0.process() }
  • SRP: Each class has one job (authentication, fetching, processing).
  • OCP: New resource types can be added via CloudResource.
  • LSP: DatabaseResource substitutes CloudResource seamlessly.
  • ISP: Clients use only the protocols they need (Authenticator, ResourceFetcher).
  • DIP: Dependencies are injected via abstractions (NetworkClient).

This design makes the SDK modular, testable, and extensible—perfect for managing Oracle Cloud services!

Suraj Pathak
Swift Brewer