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
- S — Single Responsibility Principle
- O — Open/Closed Principle
- L — Liskov Substitution Principle
- I — Interface Segregation Principle
- D — Dependency Inversion Principle
- When to Use (and When Not To)
- Summary
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
anykeyword inany PixarRendereris 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
@ObservablewithObservableObject+@Published. The DIP pattern itself is identical — only the observation mechanism changes.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| You need to add a new behavior without touching existing code | Apply OCP with a protocol — create a new conforming type |
| A class has 3+ unrelated reasons to change | Apply SRP — decompose into focused types |
A subclass overrides a method to throw fatalError | Apply LSP — switch to protocol composition |
| A mock requires 10+ stub methods when you only use 2 | Apply ISP — split the protocol |
A type creates its own dependencies with = ConcreteType() | Apply DIP — inject via protocol |
| A tiny utility struct with one clear job | Don’t over-engineer — not everything needs five protocols |
| A prototype or exploration code | Ignore SOLID until the shape of the problem is clear — premature abstraction is expensive |
| A one-off script or build tool helper | SOLID 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
PixarFilmManagerintoFilmRepository,FilmFormatter,AnalyticsService, andFilmNotificationScheduler. - OCP: Depend on
PixarRendererprotocol — add new renderers as new types. The engine never changes. - LSP: Protocols compose honestly.
ReadOnlyFilmStoreconforms toFilmReadable, not to aFilmStorebase class that promisessave(). - ISP: Small protocols (
FilmFetchable,FilmSearchable,FilmFavoritable) make mocks trivial. Fat protocols make mocks painful. - DIP:
FilmDetailViewModeldepends onany FilmRepositoryandany 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.