SOLID Principles Applied to Swift: Practical Examples for iOS Developers


SOLID principles were defined for object-oriented languages in the early 2000s. Swift isn’t purely OOP — it’s protocol-oriented, value-type-first, and functional-friendly. But SOLID’s core insights translate directly, and understanding them will make your Swift code dramatically more maintainable.

In this post, you’ll see each of the five SOLID principles applied to real Swift code, with a concrete “before” (violation) and “after” (applied) example for each one. We won’t rehash the academic definitions — if you’ve been writing iOS code for a few years, you’ve already felt these problems. The goal is to show how Swift’s protocol system and value types let you apply SOLID more cleanly than classic OOP ever could.

This guide assumes you’re comfortable with protocols and structs and classes.

Contents

The Five Principles

SOLID is an acronym coined by Robert C. Martin:

  • Single Responsibility — a type should have one reason to change
  • Open/Closed — open for extension, closed for modification
  • Liskov Substitution — subtypes must be substitutable for their base types
  • Interface Segregation — clients shouldn’t depend on methods they don’t use
  • Dependency Inversion — depend on abstractions, not concretions

Each principle targets a specific category of coupling that makes code brittle. Violate all five in a single class and you’ve built a monolith that no one wants to touch. Apply them thoughtfully and you get a codebase where changing one thing doesn’t break five others.

S — Single Responsibility Principle

A type should have one reason to change. In practice this means one type shouldn’t simultaneously own networking, caching, display formatting, analytics, and notification delivery.

SRP Violation

Consider a PixarFilmManager that does everything related to films:

// ❌ Violates SRP — this class has five reasons to change
final class PixarFilmManager {
    private let session = URLSession.shared
    private var cache: [String: PixarFilm] = [:]

    // Networking
    func fetchFilm(id: String) async throws -> PixarFilm {
        if let cached = cache[id] { return cached }
        let url = URL(string: "https://api.pixar.io/films/\(id)")!
        let (data, _) = try await session.data(from: url)
        let film = try JSONDecoder().decode(PixarFilm.self, from: data)
        cache[id] = film
        return film
    }

    // Display formatting
    func formattedTitle(for film: PixarFilm) -> String {
        "\(film.title) (\(film.year)) — \(film.studio.uppercased())"
    }

    // Analytics
    func trackFilmViewed(_ film: PixarFilm) {
        print("[Analytics] Film viewed: \(film.title)")
        // POST to analytics endpoint...
    }

    // Push notifications
    func scheduleReleaseNotification(for film: PixarFilm) {
        // Register with UNUserNotificationCenter...
        print("[Notification] Reminder set for \(film.title)")
    }
}

This class has at least four distinct reasons to change: the API changes its response format, the display design requires a different title style, the analytics team switches providers, or the notification strategy changes. Each change risks breaking unrelated functionality.

SRP Applied

Split the responsibilities into focused types:

// ✅ Each type has exactly one reason to change

// Owns networking and caching
final class FilmRepository {
    private let session: URLSession
    private var cache: [String: PixarFilm] = [:]

    init(session: URLSession = .shared) {
        self.session = session
    }

    func film(id: String) async throws -> PixarFilm {
        if let cached = cache[id] { return cached }
        let url = URL(string: "https://api.pixar.io/films/\(id)")!
        let (data, _) = try await session.data(from: url)
        let film = try JSONDecoder().decode(PixarFilm.self, from: data)
        cache[id] = film
        return film
    }
}

// Owns display formatting — pure, stateless, easily testable
struct FilmFormatter {
    func formattedTitle(for film: PixarFilm) -> String {
        "\(film.title) (\(film.year)) — \(film.studio.uppercased())"
    }
}

// Owns analytics tracking
struct AnalyticsService {
    func trackFilmViewed(_ film: PixarFilm) {
        print("[Analytics] Film viewed: \(film.title)")
    }
}

// Owns notification scheduling
struct FilmNotificationScheduler {
    func scheduleReleaseReminder(for film: PixarFilm) {
        print("[Notification] Reminder set for \(film.title)")
    }
}

FilmFormatter is a pure value type with zero dependencies — trivial to unit test. FilmRepository owns its own lifecycle and can be swapped or mocked independently. Each type changes for exactly one reason.

Tip: A useful heuristic for SRP is to describe a type in one sentence without using “and.” If you find yourself writing “fetches data and formats it and logs analytics,” that’s three types.

O — Open/Closed Principle

Software entities should be open for extension but closed for modification. Adding new behavior should mean adding new code, not editing existing code and potentially introducing regressions.

OCP Violation

A PixarRenderEngine that uses a switch statement must be modified every time a new render mode is introduced:

// ❌ Violates OCP — adding PathTracingRenderer means editing this switch
enum RenderMode {
    case rasterization
    case rayTracing
    // Adding .pathTracing here forces changes below
}

final class PixarRenderEngine {
    func render(scene: PixarScene, mode: RenderMode) -> UIImage {
        switch mode {
        case .rasterization:
            return renderRasterized(scene)
        case .rayTracing:
            return renderRayTraced(scene)
        // Every new mode adds another case here — and a new private method
        }
    }

    private func renderRasterized(_ scene: PixarScene) -> UIImage { UIImage() }
    private func renderRayTraced(_ scene: PixarScene) -> UIImage { UIImage() }
}

Every time Pixar’s pipeline team ships a new rendering algorithm, PixarRenderEngine must change. The switch statement becomes a maintenance burden, and each edit is an opportunity for regression.

OCP Applied

Define a protocol so the engine depends on an abstraction, not a concrete renderer:

// ✅ Engine is closed for modification — open for extension via new renderers

protocol PixarRenderer {
    func render(scene: PixarScene) -> UIImage
}

// New renderers are added as new types — existing code is untouched
struct RasterizationRenderer: PixarRenderer {
    func render(scene: PixarScene) -> UIImage {
        // Rasterize geometry for real-time preview
        UIImage()
    }
}

struct RayTracingRenderer: PixarRenderer {
    func render(scene: PixarScene) -> UIImage {
        // Physically accurate lighting — slow but beautiful
        UIImage()
    }
}

struct PathTracingRenderer: PixarRenderer {
    func render(scene: PixarScene) -> UIImage {
        // Global illumination — used in final renders
        UIImage()
    }
}

// The engine never needs to change when a new renderer ships
final class PixarRenderEngine {
    private let renderer: any PixarRenderer

    init(renderer: any PixarRenderer) {
        self.renderer = renderer
    }

    func render(scene: PixarScene) -> UIImage {
        renderer.render(scene: scene)
    }
}

Adding a SubsurfaceScatteringRenderer for Merida’s hair in Brave means creating a new type that conforms to PixarRenderer. Nothing in PixarRenderEngine changes.

Note: The any keyword in any PixarRenderer is Swift 5.7+ syntax for existential types. It makes the boxing behavior explicit. For performance-critical paths, consider using generics (<R: PixarRenderer>) to avoid the existential overhead.

L — Liskov Substitution Principle

Subtypes must be substitutable for their base types without altering the correctness of the program. This is the principle most commonly violated through inheritance, and it’s one of the strongest arguments for Swift’s composition-over-inheritance philosophy.

LSP Violation

A ReadOnlyFilmStore that inherits from FilmStore but throws a fatal error on save() breaks the parent’s contract:

// ❌ Violates LSP — ReadOnlyFilmStore cannot substitute FilmStore
class FilmStore {
    private var films: [PixarFilm] = []

    func fetch(id: String) -> PixarFilm? {
        films.first { $0.id == id }
    }

    func save(_ film: PixarFilm) {
        films.append(film)
    }
}

// Any code holding a FilmStore reference can call save()
// ReadOnlyFilmStore claims to BE a FilmStore but explodes when save() is called
class ReadOnlyFilmStore: FilmStore {
    override func save(_ film: PixarFilm) {
        fatalError("ReadOnlyFilmStore does not support saving")
    }
}

func importFilm(_ film: PixarFilm, into store: FilmStore) {
    store.save(film) // 💥 Crashes at runtime with ReadOnlyFilmStore
}

This is a classic LSP violation: ReadOnlyFilmStore passes as a FilmStore at compile time but crashes at runtime. The inheritance hierarchy is lying about what operations are valid.

LSP Applied

Separate the read and write capabilities into distinct protocols:

// ✅ Protocols accurately describe capability — no false promises

protocol FilmReadable {
    func fetch(id: String) -> PixarFilm?
}

protocol FilmWritable: FilmReadable {
    func save(_ film: PixarFilm)
}

// Full read-write store — conforms to both
final class FilmStore: FilmWritable {
    private var films: [PixarFilm] = []

    func fetch(id: String) -> PixarFilm? {
        films.first { $0.id == id }
    }

    func save(_ film: PixarFilm) {
        films.append(film)
    }
}

// Read-only store — only promises what it can deliver
final class ReadOnlyFilmStore: FilmReadable {
    private let films: [PixarFilm]

    init(films: [PixarFilm]) {
        self.films = films
    }

    func fetch(id: String) -> PixarFilm? {
        films.first { $0.id == id }
    }
}

// Call sites declare exactly the capability they need
func importFilm(_ film: PixarFilm, into store: any FilmWritable) {
    store.save(film) // Compiler guarantees save() exists
}

func displayFilm(id: String, from store: any FilmReadable) {
    guard let film = store.fetch(id: id) else { return }
    print(film.title)
}

ReadOnlyFilmStore now conforms to FilmReadable only. The compiler prevents any code from calling save() on it. The contract is honest, and substitution is safe.

Tip: When you reach for subclassing to add a “restricted” version of a type, that’s a signal to use protocol composition instead. Swift’s value-type-first design means most use cases that required inheritance in Objective-C can be expressed more safely with protocol conformance.

I — Interface Segregation Principle

Clients shouldn’t be forced to depend on methods they don’t use. A fat protocol with fifteen methods means every mock implementation must stub fourteen methods it doesn’t care about.

Violation

A monolithic FilmServiceProtocol that bundles every film-related operation:

// ❌ Violates ISP — most clients use 2-3 of these 8 methods
protocol FilmServiceProtocol {
    func fetchFilm(id: String) async throws -> PixarFilm
    func fetchAllFilms() async throws -> [PixarFilm]
    func searchFilms(query: String) async throws -> [PixarFilm]
    func favoriteFilm(_ film: PixarFilm) async throws
    func unfavoriteFilm(_ film: PixarFilm) async throws
    func fetchFavorites() async throws -> [PixarFilm]
    func rateFilm(_ film: PixarFilm, rating: Int) async throws
    func fetchRatings() async throws -> [FilmRating]
}

// A mock for the home screen only needs fetchAllFilms()
// but must implement all 8 methods
struct MockFilmService: FilmServiceProtocol {
    func fetchFilm(id: String) async throws -> PixarFilm { fatalError("not used") }
    func fetchAllFilms() async throws -> [PixarFilm] { [.stub] }
    func searchFilms(query: String) async throws -> [PixarFilm] { fatalError("not used") }
    func favoriteFilm(_ film: PixarFilm) async throws { fatalError("not used") }
    func unfavoriteFilm(_ film: PixarFilm) async throws { fatalError("not used") }
    func fetchFavorites() async throws -> [PixarFilm] { fatalError("not used") }
    func rateFilm(_ film: PixarFilm, rating: Int) async throws { fatalError("not used") }
    func fetchRatings() async throws -> [FilmRating] { fatalError("not used") }
}

Mock objects littered with fatalError("not used") are a reliable sign you’ve violated ISP. Each one is a maintenance trap — rename a method in the protocol and every mock breaks.

After ISP Applied

Split the fat protocol into focused, composable capabilities:

// ✅ Small protocols — clients depend only on what they use

protocol FilmFetchable {
    func fetchFilm(id: String) async throws -> PixarFilm
    func fetchAllFilms() async throws -> [PixarFilm]
}

protocol FilmSearchable {
    func searchFilms(query: String) async throws -> [PixarFilm]
}

protocol FilmFavoritable {
    func favoriteFilm(_ film: PixarFilm) async throws
    func unfavoriteFilm(_ film: PixarFilm) async throws
    func fetchFavorites() async throws -> [PixarFilm]
}

protocol FilmRatable {
    func rateFilm(_ film: PixarFilm, rating: Int) async throws
    func fetchRatings() async throws -> [FilmRating]
}

// The concrete service conforms to all of them
final class LiveFilmService: FilmFetchable, FilmSearchable, FilmFavoritable, FilmRatable {
    func fetchFilm(id: String) async throws -> PixarFilm { /* ... */ PixarFilm.stub }
    func fetchAllFilms() async throws -> [PixarFilm] { [] }
    func searchFilms(query: String) async throws -> [PixarFilm] { [] }
    func favoriteFilm(_ film: PixarFilm) async throws { /* ... */ }
    func unfavoriteFilm(_ film: PixarFilm) async throws { /* ... */ }
    func fetchFavorites() async throws -> [PixarFilm] { [] }
    func rateFilm(_ film: PixarFilm, rating: Int) async throws { /* ... */ }
    func fetchRatings() async throws -> [FilmRating] { [] }
}

// Home screen ViewModel only depends on FilmFetchable
@Observable
final class HomeViewModel {
    private let filmService: any FilmFetchable
    var films: [PixarFilm] = []

    init(filmService: any FilmFetchable) {
        self.filmService = filmService
    }

    func loadFilms() async {
        films = (try? await filmService.fetchAllFilms()) ?? []
    }
}

// Mock for HomeViewModel tests is trivially simple
struct MockFilmFetchable: FilmFetchable {
    var stubbedFilms: [PixarFilm] = []

    func fetchFilm(id: String) async throws -> PixarFilm { stubbedFilms[0] }
    func fetchAllFilms() async throws -> [PixarFilm] { stubbedFilms }
}

HomeViewModel’s mock only needs two methods. The search screen’s mock only needs one. Protocol composition (FilmFetchable & FilmSearchable) lets you combine capabilities when a type genuinely needs both.

Tip: A good rule of thumb — if your mock has more than three or four methods and half of them call fatalError, split the protocol.

D — Dependency Inversion Principle

High-level modules shouldn’t depend on low-level modules. Both should depend on abstractions. This is the foundation of dependency injection and the principle most directly tied to testability.

DIP Violation

A FilmDetailViewModel that creates its concrete dependencies directly:

// ❌ Violates DIP — ViewModel is locked to specific implementations
@Observable
final class FilmDetailViewModel {
    var film: PixarFilm?
    var isLoading = false

    // Concrete dependency created inline — impossible to replace for testing
    private let repository = LiveFilmRepository(
        session: .shared,
        baseURL: URL(string: "https://api.pixar.io")!
    )
    private let analytics = ConcreteAnalyticsService(apiKey: "prod-key-abc123")

    func loadFilm(id: String) async {
        isLoading = true
        film = try? await repository.film(id: id)
        if let film {
            analytics.trackFilmViewed(film)
        }
        isLoading = false
    }
}

You cannot test FilmDetailViewModel without hitting the live API. You cannot verify that analytics events fire correctly without inspecting network traffic. The ViewModel owns its dependencies instead of receiving them.

After DIP Applied

Invert the dependency — the ViewModel depends on abstractions, not concretions:

// ✅ Depends on protocols — concrete implementations injected at construction

protocol FilmRepository {
    func film(id: String) async throws -> PixarFilm
}

protocol AnalyticsService {
    func trackFilmViewed(_ film: PixarFilm)
}

@Observable
final class FilmDetailViewModel {
    var film: PixarFilm?
    var isLoading = false
    var errorMessage: String?

    // Dependencies declared as protocol types — injected, never constructed here
    private let repository: any FilmRepository
    private let analytics: any AnalyticsService

    init(repository: any FilmRepository, analytics: any AnalyticsService) {
        self.repository = repository
        self.analytics = analytics
    }

    func loadFilm(id: String) async {
        isLoading = true
        errorMessage = nil
        do {
            film = try await repository.film(id: id)
            if let film {
                analytics.trackFilmViewed(film)
            }
        } catch {
            errorMessage = "Could not load film: \(error.localizedDescription)"
        }
        isLoading = false
    }
}

// Production conformances
final class LiveFilmRepository: FilmRepository {
    private let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func film(id: String) async throws -> PixarFilm {
        let url = URL(string: "https://api.pixar.io/films/\(id)")!
        let (data, _) = try await session.data(from: url)
        return try JSONDecoder().decode(PixarFilm.self, from: data)
    }
}

struct LiveAnalyticsService: AnalyticsService {
    func trackFilmViewed(_ film: PixarFilm) {
        print("[Analytics] Viewed: \(film.title)")
    }
}

// Test doubles — zero network, deterministic behavior
struct MockFilmRepository: FilmRepository {
    var result: Result<PixarFilm, Error>

    func film(id: String) async throws -> PixarFilm {
        try result.get()
    }
}

final class MockAnalyticsService: AnalyticsService {
    private(set) var trackedFilms: [PixarFilm] = []

    func trackFilmViewed(_ film: PixarFilm) {
        trackedFilms.append(film)
    }
}

// Composition root — production
let viewModel = FilmDetailViewModel(
    repository: LiveFilmRepository(),
    analytics: LiveAnalyticsService()
)

// Tests — no network, deterministic
let mockRepo = MockFilmRepository(result: .success(.stub))
let mockAnalytics = MockAnalyticsService()
let testViewModel = FilmDetailViewModel(
    repository: mockRepo,
    analytics: mockAnalytics
)

The ViewModel is now independently testable. You can verify loading states, error handling, and analytics event sequencing without a single network call. This is the direct connection to the patterns covered in Dependency Injection in Swift.

Apple Docs: @Observable — Observation framework (iOS 17+)

Note: For iOS 16 and below, replace @Observable with ObservableObject + @Published. The DIP pattern itself is identical — only the observation mechanism changes.

When to Use (and When Not To)

ScenarioRecommendation
You need to add a new behavior without touching existing codeApply OCP with a protocol — create a new conforming type
A class has 3+ unrelated reasons to changeApply SRP — decompose into focused types
A subclass overrides a method to throw fatalErrorApply LSP — switch to protocol composition
A mock requires 10+ stub methods when you only use 2Apply ISP — split the protocol
A type creates its own dependencies with = ConcreteType()Apply DIP — inject via protocol
A tiny utility struct with one clear jobDon’t over-engineer — not everything needs five protocols
A prototype or exploration codeIgnore SOLID until the shape of the problem is clear — premature abstraction is expensive
A one-off script or build tool helperSOLID overhead is not worth it for throwaway code

SOLID is a set of diagnostic tools, not a prescription. You don’t apply all five principles to every type — you apply the specific principle that addresses the coupling problem you’re actually facing. A FilmFormatter struct with a single formattedTitle method is already SOLID compliant without any protocols at all.

The most common mistake experienced engineers make is creating a protocol for every single type “just in case.” This is premature abstraction: you pay the cost of indirection without receiving any benefit. Introduce a protocol when you have two concrete implementations, not before.

Summary

  • SRP: One type, one reason to change. Decompose PixarFilmManager into FilmRepository, FilmFormatter, AnalyticsService, and FilmNotificationScheduler.
  • OCP: Depend on PixarRenderer protocol — add new renderers as new types. The engine never changes.
  • LSP: Protocols compose honestly. ReadOnlyFilmStore conforms to FilmReadable, not to a FilmStore base class that promises save().
  • ISP: Small protocols (FilmFetchable, FilmSearchable, FilmFavoritable) make mocks trivial. Fat protocols make mocks painful.
  • DIP: FilmDetailViewModel depends on any FilmRepository and any AnalyticsService — injected at the callsite, swapped freely in tests.

Swift’s protocol system and value types make SOLID patterns more natural to express than in classic OOP. Protocols eliminate the false promises of inheritance (LSP), and value types make single-responsibility decomposition nearly free (SRP). The principles aren’t rules to follow mechanically — they’re a vocabulary for diagnosing and fixing coupling problems as your codebase grows.

For a deeper look at the protocol-oriented angle, Protocol-Oriented Programming vs OOP explores when protocols beat classes with direct benchmarks. For the DIP injection patterns at the app scale, Dependency Injection in Swift covers constructor injection, environment injection, and factory patterns in production SwiftUI apps.