Swift SOLID Principles
In software development, writing clean and maintainable code isn't just about getting your program to work—it's about creating something that can evolve over time without breaking. The SOLID principles are five design principles that help developers create more maintainable, understandable, and flexible software.
What are SOLID Principles?
SOLID is an acronym representing five key principles of object-oriented programming and design:
- S: Single Responsibility Principle
- O: Open/Closed Principle
- L: Liskov Substitution Principle
- I: Interface Segregation Principle
- D: Dependency Inversion Principle
Each principle addresses a specific aspect of software design, and together they form a comprehensive framework for creating robust applications. Let's explore each principle with Swift examples.
Single Responsibility Principle (SRP)
The Concept
The Single Responsibility Principle states that a class should have only one reason to change, meaning it should have only one job or responsibility.
Bad Practice Example
Here's a class that violates SRP:
class User {
let name: String
let email: String
init(name: String, email: String) {
self.name = name
self.email = email
}
func saveToDatabase() {
// Code to save user to database
print("Saving user \(name) to database")
}
func sendEmail(message: String) {
// Code to send email
print("Sending email to \(email): \(message)")
}
func generateUserReport() -> String {
// Generate a report for the user
return "Report for user: \(name)"
}
}
// Usage
let user = User(name: "John Doe", email: "[email protected]")
user.saveToDatabase()
user.sendEmail(message: "Welcome!")
let report = user.generateUserReport()
This User
class has multiple responsibilities: storing user data, database operations, email communication, and report generation.
Good Practice Example
Let's refactor to follow SRP:
// User class only handles user data
class User {
let name: String
let email: String
init(name: String, email: String) {
self.name = name
self.email = email
}
}
// Handles database operations
class UserStorage {
func saveUser(_ user: User) {
// Code to save user to database
print("Saving user \(user.name) to database")
}
}
// Handles email communication
class EmailService {
func sendEmail(to email: String, message: String) {
// Code to send email
print("Sending email to \(email): \(message)")
}
}
// Handles report generation
class ReportGenerator {
func generateUserReport(for user: User) -> String {
// Generate a report for the user
return "Report for user: \(user.name)"
}
}
// Usage
let user = User(name: "John Doe", email: "[email protected]")
let storage = UserStorage()
let emailService = EmailService()
let reportGenerator = ReportGenerator()
storage.saveUser(user)
emailService.sendEmail(to: user.email, message: "Welcome!")
let report = reportGenerator.generateUserReport(for: user)
Now each class has a single responsibility, making the code more maintainable and easier to test.
Open/Closed Principle (OCP)
The Concept
The Open/Closed Principle states that software entities should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing code.
Bad Practice Example
Consider a shape calculator that calculates the area of different shapes:
enum ShapeType {
case rectangle
case circle
}
class Shape {
let type: ShapeType
let width: Double
let height: Double
init(type: ShapeType, width: Double, height: Double = 0) {
self.type = type
self.width = width
self.height = height
}
}
class AreaCalculator {
func calculateArea(shape: Shape) -> Double {
switch shape.type {
case .rectangle:
return shape.width * shape.height
case .circle:
return Double.pi * shape.width * shape.width // width is radius
}
}
}
// Usage
let rectangle = Shape(type: .rectangle, width: 5, height: 10)
let circle = Shape(type: .circle, width: 7)
let calculator = AreaCalculator()
print("Rectangle area: \(calculator.calculateArea(shape: rectangle))")
print("Circle area: \(calculator.calculateArea(shape: circle))")
If we want to add a new shape, we need to modify both the ShapeType
enum and the calculateArea
method, violating OCP.
Good Practice Example
Let's refactor using protocols and inheritance:
protocol Shape {
func area() -> Double
}
class Rectangle: Shape {
let width: Double
let height: Double
init(width: Double, height: Double) {
self.width = width
self.height = height
}
func area() -> Double {
return width * height
}
}
class Circle: Shape {
let radius: Double
init(radius: Double) {
self.radius = radius
}
func area() -> Double {
return Double.pi * radius * radius
}
}
class AreaCalculator {
func calculateArea(shape: Shape) -> Double {
return shape.area()
}
}
// Usage
let rectangle = Rectangle(width: 5, height: 10)
let circle = Circle(radius: 7)
let calculator = AreaCalculator()
print("Rectangle area: \(calculator.calculateArea(shape: rectangle))") // Output: Rectangle area: 50.0
print("Circle area: \(calculator.calculateArea(shape: circle))") // Output: Circle area: 153.93804002589985
Now if we want to add a new shape, we just need to create a new class that conforms to the Shape
protocol, without modifying existing code.
class Triangle: Shape {
let base: Double
let height: Double
init(base: Double, height: Double) {
self.base = base
self.height = height
}
func area() -> Double {
return 0.5 * base * height
}
}
// Usage
let triangle = Triangle(base: 4, height: 6)
print("Triangle area: \(calculator.calculateArea(shape: triangle))") // Output: Triangle area: 12.0
Liskov Substitution Principle (LSP)
The Concept
The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.
Bad Practice Example
Consider a Bird
hierarchy where not all birds can fly:
class Bird {
func fly() {
print("I can fly!")
}
}
class Sparrow: Bird {
// Sparrows can fly, so this is fine
}
class Ostrich: Bird {
// Problem: Ostriches can't fly, but inherit the fly method
override func fly() {
fatalError("Ostriches cannot fly")
}
}
// Usage
func makeBirdFly(_ bird: Bird) {
bird.fly()
}
let sparrow = Sparrow()
makeBirdFly(sparrow) // Output: I can fly!
let ostrich = Ostrich()
makeBirdFly(ostrich) // Crashes with: Fatal error: Ostriches cannot fly
This violates LSP because an Ostrich
cannot be substituted for a Bird
without breaking the program.
Good Practice Example
Let's refactor the hierarchy:
protocol Bird {
func eat()
}
protocol FlyingBird: Bird {
func fly()
}
class Sparrow: FlyingBird {
func eat() {
print("Sparrow is eating")
}
func fly() {
print("Sparrow is flying")
}
}
class Ostrich: Bird {
func eat() {
print("Ostrich is eating")
}
// No fly method for Ostrich
}
// Usage
func makeEat(_ bird: Bird) {
bird.eat()
}
func makeFly(_ bird: FlyingBird) {
bird.fly()
}
let sparrow = Sparrow()
makeEat(sparrow) // Output: Sparrow is eating
makeFly(sparrow) // Output: Sparrow is flying
let ostrich = Ostrich()
makeEat(ostrich) // Output: Ostrich is eating
// makeFly(ostrich) // Compilation error - Ostrich doesn't conform to FlyingBird
Now we have a proper hierarchy where FlyingBird
is a specialization of Bird
, and we can't accidentally make an ostrich fly.
Interface Segregation Principle (ISP)
The Concept
The Interface Segregation Principle states that clients should not be forced to depend on interfaces they do not use. In other words, it's better to have multiple specific interfaces than one general-purpose interface.
Bad Practice Example
Here's a monolithic printer interface:
protocol MultiFunctionDevice {
func print(document: String)
func scan() -> String
func fax(document: String)
func copy()
}
class AllInOnePrinter: MultiFunctionDevice {
func print(document: String) {
print("Printing: \(document)")
}
func scan() -> String {
return "Scanned content"
}
func fax(document: String) {
print("Faxing: \(document)")
}
func copy() {
print("Making a copy")
}
}
class BasicPrinter: MultiFunctionDevice {
func print(document: String) {
print("Printing: \(document)")
}
func scan() -> String {
fatalError("Basic printer cannot scan")
}
func fax(document: String) {
fatalError("Basic printer cannot fax")
}
func copy() {
fatalError("Basic printer cannot copy")
}
}
The BasicPrinter
has to implement methods it doesn't support, violating ISP.
Good Practice Example
Let's split the interfaces:
protocol Printer {
func print(document: String)
}
protocol Scanner {
func scan() -> String
}
protocol Fax {
func fax(document: String)
}
protocol Copier {
func copy()
}
class AllInOnePrinter: Printer, Scanner, Fax, Copier {
func print(document: String) {
Swift.print("Printing: \(document)")
}
func scan() -> String {
return "Scanned content"
}
func fax(document: String) {
Swift.print("Faxing: \(document)")
}
func copy() {
Swift.print("Making a copy")
}
}
class BasicPrinter: Printer {
func print(document: String) {
Swift.print("Printing: \(document)")
}
}
// Usage
func printDocument(printer: Printer, document: String) {
printer.print(document: document)
}
let allInOne = AllInOnePrinter()
let basic = BasicPrinter()
printDocument(printer: allInOne, document: "Report")
printDocument(printer: basic, document: "Letter")
Now each class implements only the interfaces it needs. The BasicPrinter
isn't forced to implement methods it doesn't support.
Dependency Inversion Principle (DIP)
The Concept
The Dependency Inversion Principle states that:
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Abstractions should not depend on details. Details should depend on abstractions.
Bad Practice Example
Consider a notification system with direct dependencies:
class EmailNotifier {
func sendNotification(message: String, to user: String) {
print("Sending email to \(user): \(message)")
}
}
class NotificationService {
private let emailNotifier = EmailNotifier()
func notify(user: String, message: String) {
emailNotifier.sendNotification(message: message, to: user)
}
}
// Usage
let service = NotificationService()
service.notify(user: "[email protected]", message: "Hello!")
The NotificationService
directly depends on the EmailNotifier
class, making it impossible to use a different notification method without modifying the service.
Good Practice Example
Let's apply dependency inversion:
protocol NotificationSender {
func sendNotification(message: String, to user: String)
}
class EmailNotifier: NotificationSender {
func sendNotification(message: String, to user: String) {
print("Sending email to \(user): \(message)")
}
}
class SMSNotifier: NotificationSender {
func sendNotification(message: String, to user: String) {
print("Sending SMS to \(user): \(message)")
}
}
class NotificationService {
private let notifier: NotificationSender
init(notifier: NotificationSender) {
self.notifier = notifier
}
func notify(user: String, message: String) {
notifier.sendNotification(message: message, to: user)
}
}
// Usage
let emailService = NotificationService(notifier: EmailNotifier())
emailService.notify(user: "[email protected]", message: "Hello via email!")
// Output: Sending email to [email protected]: Hello via email!
let smsService = NotificationService(notifier: SMSNotifier())
smsService.notify(user: "+1234567890", message: "Hello via SMS!")
// Output: Sending SMS to +1234567890: Hello via SMS!
Now NotificationService
depends on the NotificationSender
abstraction, not the concrete implementations. This makes it easy to switch between different notification types without changing the service.
Real-World Application: Building a Music Player App
Let's apply all SOLID principles to create a simple music player app structure:
// Single Responsibility Principle - Each class has one responsibility
class Song {
let title: String
let artist: String
let duration: Double
init(title: String, artist: String, duration: Double) {
self.title = title
self.artist = artist
self.duration = duration
}
}
class Playlist {
private var songs: [Song] = []
func add(song: Song) {
songs.append(song)
}
func removeSong(at index: Int) {
guard index >= 0 && index < songs.count else { return }
songs.remove(at: index)
}
func getSongs() -> [Song] {
return songs
}
}
// Open/Closed Principle - Different audio formats through protocol extension
protocol AudioPlayable {
func play()
func stop()
}
class MP3Player: AudioPlayable {
func play() {
print("Playing MP3")
}
func stop() {
print("Stopping MP3")
}
}
class WAVPlayer: AudioPlayable {
func play() {
print("Playing WAV")
}
func stop() {
print("Stopping WAV")
}
}
// Liskov Substitution Principle - All players can be used interchangeably
func playAudio(_ player: AudioPlayable) {
player.play()
}
// Interface Segregation Principle - Specific interfaces for different capabilities
protocol AudioPlayer {
func play()
func stop()
}
protocol VolumeControl {
func increaseVolume()
func decreaseVolume()
}
protocol DisplayInfo {
func showInfo()
}
class SmartPlayer: AudioPlayer, VolumeControl, DisplayInfo {
func play() {
print("Playing audio")
}
func stop() {
print("Stopping audio")
}
func increaseVolume() {
print("Volume increased")
}
func decreaseVolume() {
print("Volume decreased")
}
func showInfo() {
print("Displaying track information")
}
}
class BasicPlayer: AudioPlayer {
func play() {
print("Playing audio")
}
func stop() {
print("Stopping audio")
}
}
// Dependency Inversion Principle - High-level module depends on abstraction
protocol MusicRepository {
func fetchAllSongs() -> [Song]
func saveSong(_ song: Song)
}
class LocalMusicRepository: MusicRepository {
func fetchAllSongs() -> [Song] {
// Fetch songs from local storage
return [Song(title: "Local Song", artist: "Local Artist", duration: 180)]
}
func saveSong(_ song: Song) {
print("Saving song to local storage: \(song.title)")
}
}
class CloudMusicRepository: MusicRepository {
func fetchAllSongs() -> [Song] {
// Fetch songs from cloud
return [Song(title: "Cloud Song", artist: "Cloud Artist", duration: 240)]
}
func saveSong(_ song: Song) {
print("Saving song to cloud: \(song.title)")
}
}
class MusicService {
private let repository: MusicRepository
init(repository: MusicRepository) {
self.repository = repository
}
func getAllSongs() -> [Song] {
return repository.fetchAllSongs()
}
func addSong(_ song: Song) {
repository.saveSong(song)
}
}
// Usage of our music player app
let localRepo = LocalMusicRepository()
let cloudRepo = CloudMusicRepository()
let localMusicService = MusicService(repository: localRepo)
let cloudMusicService = MusicService(repository: cloudRepo)
let mp3Player = MP3Player()
let wavPlayer = WAVPlayer()
playAudio(mp3Player) // Output: Playing MP3
playAudio(wavPlayer) // Output: Playing WAV
let smartPlayer = SmartPlayer()
smartPlayer.play() // Output: Playing audio
smartPlayer.increaseVolume() // Output: Volume increased
smartPlayer.showInfo() // Output: Displaying track information
let song = Song(title: "New Hit", artist: "Popular Artist", duration: 210)
localMusicService.addSong(song) // Output: Saving song to local storage: New Hit
cloudMusicService.addSong(song) // Output: Saving song to cloud: New Hit
Summary
The SOLID principles help us create better software by encouraging practices that lead to more maintainable, flexible, and robust code:
- Single Responsibility Principle: Each class should have only one reason to change.
- Open/Closed Principle: Software entities should be open for extension but closed for modification.
- Liskov Substitution Principle: Subtypes must be substitutable for their base types.
- Interface Segregation Principle: Many client-specific interfaces are better than one general-purpose interface.
- Dependency Inversion Principle: Depend on abstractions, not on concretions.
Adopting SOLID principles can lead to:
- Lower coupling between classes
- Higher cohesion within classes
- Better testability
- Increased maintainability
- More reusable components
- Greater scalability
Additional Resources
- Swift Documentation
- SOLID Principles in Swift
- Clean Code by Robert C. Martin
- iOS Design Patterns in Swift
Exercises
-
SRP Exercise: Refactor a
UserManager
class that currently handles user authentication, profile updates, and notification preferences into separate classes. -
OCP Exercise: Create a drawing application that can work with different shapes. Start with circles and rectangles, then extend it to include triangles without modifying existing code.
-
LSP Exercise: Design a hierarchy of banking accounts (checking, savings, fixed deposit) where each type can be used in place of a generic account.
-
ISP Exercise: Create interfaces for different types of electronic devices (phones, cameras, music players) ensuring that no device needs to implement methods it doesn't use.
-
DIP Exercise: Design a logging system that can output logs to different destinations (console, file, database) while allowing easy swapping between them.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)