Good architecture is not just about writing working code, but about creating code that is easy to extend, test, and maintain. This is exactly why the SOLID principles were formulated - five key foundations of object-oriented design proposed by Robert C. Martin (Robert C. Martin).
Following these principles helps achieve several important benefits:
SOLID is an acronym consisting of five principles:
In this article, we will break down each of these principles using Swift code examples, explore common mistakes that violate these principles, and learn how to fix them. The examples will be easy to understand, so no complex language constructs will be used.
Grab some coffee, cookies, and let's dive into SOLID! 🚀
According to this principle, each object should have only one responsibility, and that responsibility should be encapsulated within it.
Violation of SRP: The User class contains both user logic and email-sending logic.
final class User {
let name: String
let email: String
init(name: String, email: String) {
self.name = name
self.email = email
}
func sendEmail(message: String) {
print("Sending email to \(email) with message: \(message)")
}
}
let user = User(name: "Tim", email: "ceo@apple.com")
user.sendEmail(message: "Hello!")
How to fix it?
Separate responsibilities between User
and EmailService
.
final class User {
let name: String
let email: String
init(name: String, email: String) {
self.name = name
self.email = email
}
}
final class EmailService {
func sendEmail(to user: User, message: String) {
print("Sending email to \(user.email) with message: \(message)")
}
}
let user = User(name: "Tim", email: "ceo@apple.com")
let emailService = EmailService()
emailService.sendEmail(to: user, message: "Hello!")
Now User
is responsible only for user data, while EmailService
handles email sending. This makes the code easier to maintain and extend.
This principle states that code should be open for extension but closed for modification.
Violation of OCP: The function uses conditional checks to determine the payment method. Adding a new payment method requires modifying this code.
final class PaymentProcessor {
func processPayment(type: String, amount: Double) {
if type == "credit_card" {
print("Pay amount: \(amount) with credit card")
} else if type == "paypal" {
print("Pay amount: \(amount) via PayPal")
} else {
print("Unknown payment method")
}
}
}
let processor = PaymentProcessor()
processor.processPayment(type: "credit_card", amount: 100.0)
processor.processPayment(type: "paypal", amount: 200.0)
How to fix it?
Instead of using if-else
statements, we can use abstraction - the PaymentMethod
protocol. This allows adding new payment methods without modifying PaymentProcessor
.
protocol PaymentMethod {
func pay(amount: Double)
}
final class CreditCardPayment: PaymentMethod {
func pay(amount: Double) {
print("Pay amount: \(amount) with credit card")
}
}
final class PayPalPayment: PaymentMethod {
func pay(amount: Double) {
print("Pay amount: \(amount) via PayPal")
}
}
final class PaymentProcessor {
func processPayment(method: PaymentMethod, amount: Double) {
method.pay(amount: amount)
}
}
let processor = PaymentProcessor()
let creditCard = CreditCardPayment()
let paypal = PayPalPayment()
processor.processPayment(method: creditCard, amount: 100.0)
processor.processPayment(method: paypal, amount: 200.0)
Now, if a new payment method (e.g., ApplePayPayment
) is introduced, we do not need to modify PaymentProcessor
- we just add a new class implementing PaymentMethod
.
Subclasses should replace the parent class without changing the program's logic.
Violation of LSP: The base class Car
has a refuel()
method, but if we create ElectricCar
, it should not be refueled with gasoline - it should be charged instead.
final class Car {
func drive() {
print("Drive")
}
func refuel() {
print("Refuel")
}
}
class GasolineCar: Car { }
class ElectricCar: Car {
override func refuel() {
fatalError("Can't fuel an electric car with gasoline!")
}
}
func refuelCar(_ car: Car) {
car.refuel()
}
let tesla = ElectricCar()
refuelCar(tesla) // ❌ Runtime error
Why is this a problem?
Liskov Substitution Principle (LSP) states that a subclass should fully replace its parent class without breaking functionality. Here, the function refuelCar(_:)
expects any Car
to be refueled, but ElectricCar
violates this rule.
How to fix?
Separate interfaces for refuelable and chargeable vehicles.
class Car {
func drive() {
print("Drive")
}
}
protocol Fuelable {
func refuel()
}
protocol Chargeable {
func charge()
}
final class GasolineCar: Car, Fuelable {
func refuel() {
print("Refuel")
}
}
final class ElectricCar: Car, Chargeable {
func charge() {
print("Charge")
}
}
// We have a function that only works with fuelable cars
func refuelCar(_ car: Fuelable) {
car.refuel()
}
let bmw = GasolineCar()
refuelCar(bmw) // ✅
let tesla = ElectricCar()
// refuelCar(tesla) // ❌ Compile-time error, which is good! Now we can't mistakenly pass an electric car.
Now there is no LSP violation:
Car
are compatible.GasolineCar
can be refueled with gasoline.ElectricCar
does not inherit the unnecessary refuel()
method but uses charge()
instead.
If you have classes that behave differently, it's better to separate them through interfaces rather than forcing them to inherit methods they don’t need. This not only adheres to the Liskov Substitution Principle (LSP) but also improves code readability and maintainability.
The principle states that classes should not be forced to implement methods they don’t need.
Violation of ISP: We have a Vehicle
interface that contains methods for all types of transport - drive()
, sail()
, and fly()
. However, a car cannot fly, and a boat cannot drive on the road.
protocol Vehicle {
func drive()
func sail()
func fly()
}
class Car: Vehicle {
func drive() {
print("Drive")
}
func sail() {
fatalError("A car cannot sail.")
}
func fly() {
fatalError("A car cannot fly.")
}
}
class Boat: Vehicle {
func drive() {
fatalError("A boat cannot drive.")
}
func sail() {
print("Sail")
}
func fly() {
fatalError("A boat cannot fly.")
}
}
class Airplane: Vehicle {
func drive() {
fatalError("An airplane cannot drive.")
}
func sail() {
fatalError("An airplane cannot sail.")
}
func fly() {
print("Fly")
}
}
The problem:
Car
is forced to implement sail()
and fly()
, even though it doesn’t need them.Boat
must have a drive()
method, but boats don’t drive on roads.Airplane
is also required to implement irrelevant methods.
Why is this bad?
Car.sail()
might wonder if a car can actually sail, reducing code predictability.
How to fix?
We separate interfaces into distinct responsibilities.
protocol Drivable {
func drive()
}
protocol Sailable {
func sail()
}
protocol Flyable {
func fly()
}
final class Car: Drivable {
func drive() {
print("Drive")
}
}
final class Boat: Sailable {
func sail() {
print("Sail")
}
}
final class Airplane: Flyable {
func fly() {
print("Fly")
}
}
Now the code follows ISP: classes implement only the methods they need. Adding new types of transport (e.g., Seaplane
) no longer breaks existing code.
final class Seaplane: Flyable, Sailable {
func fly() {
print("Fly")
}
func sail() {
print("Sail")
}
}
By separating interfaces according to their purpose, we make the code flexible, maintainable, and logical.
The difference between Liskov Substitution Principle (LSP) and Interface Segregation Principle (ISP) is:
The principle states that:
Violation of DIP: The OrderService
class directly depends on MySQLDatabase
. If we want to switch to a different database, we will have to modify the OrderService
code.
final class MySQLDatabase {
func saveOrder(order: String) {
print("MySQL save order: \(order)")
}
}
final class OrderService {
let database = MySQLDatabase() // 🚨 Strong dependency
func createOrder(order: String) {
print("Creating order: \(order)")
database.saveOrder(order: order) // Strong relation with MySQLDatabase
}
}
// Using
let service = OrderService()
service.createOrder(order: "#123")
The problem:
MySQLDatabase
with PostgreSQLDatabase
, we must modify the OrderService
code.
How to fix?
We introduce a protocol (abstraction) so that OrderService
depends on an interface rather than a specific database implementation.
protocol Database {
func saveOrder(order: String)
}
final class MySQLDatabase: Database {
func saveOrder(order: String) {
print("MySQL save order: \(order)")
}
}
final class PostgreSQLDatabase: Database {
func saveOrder(order: String) {
print("PostgreSQL save order: \(order)")
}
}
final class OrderService {
let database: Database // 💡 Now it's an abstraction
init(database: Database) {
self.database = database
}
func createOrder(order: String) {
print("Creating order: \(order)")
database.saveOrder(order: order) // Working with an abstraction
}
}
// Using
let mysqlDB = MySQLDatabase()
let postgreDB = PostgreSQLDatabase()
let service1 = OrderService(database: mysqlDB)
service1.createOrder(order: "#123") // MySQL
let service2 = OrderService(database: postgreDB)
service2.createOrder(order: "#456") // PostgreSQL
Benefits:
Now we can easily switch from MySQLDatabase
to PostgreSQLDatabase
without modifying OrderService
.
The code is now more flexible and easier to test – we can pass a mock database for testing.
OrderService
no longer depends on a specific database implementation but works with an abstraction (Database
).
Key takeaway: Depend on interfaces (protocols), not concrete classes.
By applying SOLID principles, you create code that remains stable, adaptable, and resistant to unnecessary complexity. These principles help prevent projects from turning into unmanageable chaos as they grow. Writing code with SOLID in mind ensures better organization, making it easier to maintain, test, and extend over time.
This leads to a more structured and predictable development process.
Although SOLID is not a rigid set of rules, it helps developers design scalable and flexible architectures. As a result, teams spend less time fixing issues and more time innovating. 🚀