SOLID principles in Swift — practical review
Five design principles, each with an interview-ready explanation and a concrete Swift-oriented example.
S — Single Responsibility
A type or module should have one reason to change. Keep networking, persistence and presentation responsibilities in separate components, instead of combining everything in one ViewModel.
protocol OrderReading {
func loadOrders() async throws -> [Order]
}
struct LoadOrdersUseCase {
let repository: any OrderReading
func execute() async throws -> [Order] {
try await repository.loadOrders()
}
}O — Open/Closed
Extend behavior through protocols and new implementations without repeatedly changing stable code. PaymentMethod implementations should not require a growing switch for each provider.
protocol PaymentMethod {
func charge(_ amount: Decimal) async throws
}
struct CardMethod: PaymentMethod {
func charge(_ amount: Decimal) async throws {
// Charge using the card provider.
}
}
struct TransferMethod: PaymentMethod {
func charge(_ amount: Decimal) async throws {
// Charge using bank transfer.
}
}L — Liskov Substitution
A substitute implementation must honor the contract's observable behavior. A mock repository that silently converts an error into an empty list may break a caller's expectations.
struct Order { let id: String }
protocol OrderReading {
func load() async throws -> [Order]
}
enum OrderError: Error { case unavailable }
struct FailingOrders: OrderReading {
func load() async throws -> [Order] {
throw OrderError.unavailable
}
}
// Returning [] instead would hide an error expected by callers.I — Interface Segregation
Favor small interfaces tailored to consumers. Splitting an OrderReading protocol from OrderWriting avoids forcing callers to depend on unused functions.
struct Order { let id: String }
protocol OrderReading {
func loadOrders() async throws -> [Order]
}
protocol OrderWriting {
func save(_ order: Order) async throws
}
struct OrderList {
let reader: any OrderReading
}D — Dependency Inversion
High-level application logic should depend on abstractions rather than concrete infrastructure. A use case accepts an OrderReading protocol and resolves the implementation at the composition root.
struct Order { let id: String }
protocol OrderReading {
func loadOrders() async throws -> [Order]
}
struct LoadOrdersUseCase {
let repository: any OrderReading
func execute() async throws -> [Order] {
try await repository.loadOrders()
}
}| Principle | Key question | Practical hint |
|---|---|---|
| SRP | How many reasons to change? | Separate API, persistence and UI. |
| OCP | Can behavior be added without editing stable logic? | Add protocol-conforming strategies. |
| LSP | Can an implementation replace another safely? | Preserve success and error semantics. |
| ISP | Does the caller depend on unused methods? | Use focused protocols. |
| DIP | Does domain logic know concrete infrastructure? | Inject abstractions at boundaries. |
Trade-off: protocols are useful for stable boundaries and testing, but a protocol for every concrete implementation adds indirection without necessarily improving maintainability.
Personal study notes. Check details against your target Swift/iOS version and official documentation.