Skip to main content

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:

swift
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:

swift
// 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:

swift
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:

swift
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.

swift
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:

swift
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:

swift
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:

swift
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:

swift
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:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

Bad Practice Example

Consider a notification system with direct dependencies:

swift
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:

swift
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:

swift
// 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

Exercises

  1. SRP Exercise: Refactor a UserManager class that currently handles user authentication, profile updates, and notification preferences into separate classes.

  2. 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.

  3. LSP Exercise: Design a hierarchy of banking accounts (checking, savings, fixed deposit) where each type can be used in place of a generic account.

  4. 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.

  5. 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! :)