Logo
Published on

The SOLID Principles with Practical Examples in Swift

Authors
  • Name
    Twitter

In software engineering, SOLID is an acronym for five design principles that aim to make object-oriented designs more modular, maintainable, readable, flexible, scalable and reusable.

The Solid principle was introduced by a famous American software engineer Robert C. Martin (a.k.a Uncle Bob).

The five design principles are a set of rules or best practices that every developer should follow. Following the SOLID acronym they are:

  1. Single Responsibility Principle
  2. Open-Closed Principle
  3. Liskov Substitution Principle
  4. Interface Segregation Principle
  5. Dependency Inversion Principle

Now, Let's grab a cup of coffee and dive into each principle one by one with real-world examples!

Single Responsibility Principle (SRP)

It states that “a class or module should have only one reason to change”. In other words, a class should only be responsible for one thing, and not have multiple responsibilities.

This principle helps to keep your code as clean as possible. Also, it has many advantages:

  1. If your project is managed by different teams and changes the same class for different reasons leading to bugs, SRP will prevent this as the class should have only a single reason to change.

  2. It makes version control easier - for example, if any logic of a feature needs to be updated it would only change related files. There will be less merge conflicts as the responsibility of each class is different.

Let's see with a practical example: consider we have an e-commerce app that sells only books. We have to generate an invoice and save that invoice.

Let’s create the invoice class:

class  InvoiceWithoutSRP {

let book: Book
let quantity: Int
let discountPercentage: Double
var total: Double{
calculateTotal()
}

init(book: Book, quantity: Int, discountPercentage: Double) {
self.book = book
self.quantity = quantity
self.discountPercentage = discountPercentage
}

func  calculateTotal() -> Double {
let bookPrice = book.price
let totalBookPrice = bookPrice *  Double(quantity)
let discountAmount = totalBookPrice * discountPercentage/100
let total = totalBookPrice - discountAmount
return total
}

func  printInvoice(){
print("\(quantity) x \(book.name) $\(book.price)")
print("Discount percentage \(discountPercentage)%")
print("Total :$\(total)")
}

func  saveInvoiceToFile(){
//Create a file and save the invoice
}
}

If you look into the class it does multiple things:

  1. calculateTotal() - this function calculates the total amount.
  2. printInvoice() - this function prints the invoice.
  3. saveInvoice() - this function saves the invoice in the file.

Now let's assume the government levies tax on the books sold from our app, so we need to change the business logic in the calculateTotal() function. Also, If we want to change the invoice printing format we would need to change the printInvoice() function. Both functions are in the same class. This clearly violates SRP.

Also, the saveInvoice() function violates SRP. We should never mix business logic with persistence logic.

We can create two more classes called InvoicePrinter and InvoicePersistence to separate printing and saving invoice logic so that we will no longer need to modify the same class. It supports SRP.

Our code should look like this:

class  Invoice {
let book: Book
let quantity: Int
let discountPercentage: Double
let taxRate: Double
var total: Double{
calculateTotal()
}

init(book: Book, quantity: Int, discountPercentage: Double, taxRate: Double) {
self.book = book
self.quantity = quantity
self.discountPercentage = discountPercentage
self.taxRate = taxRate
}

func  calculateTotal() -> Double {
let bookPrice = book.price
let totalBookPrice = bookPrice *  Double(quantity)
let discountAmount = totalBookPrice * discountPercentage/100
let amountWithDiscount = totalBookPrice - discountAmount
let taxAmount = amountWithDiscount * taxRate/100
let total = amountWithDiscount + taxAmount
return total
}
}
class  InvoicePrinter {
let invoice: Invoice
init(invoice: Invoice) {
self.invoice = invoice
}
func  printInvoice(){
print("Q.\(invoice.quantity) x \(invoice.book.name) $\(invoice.book.price)")
print("Discount percentage :\(invoice.discountPercentage)%")
print("Tax rate :\(invoice.taxRate)%")
print("Total Amount :$\(invoice.total)")
}
}
class InvoicePersistence {
    let invoice: Invoice
    init(invoice: Invoice) {
        self.invoice = invoice
    }
    func saveInvoiceToFile(){
        //Create a file and save the invoice
    }
}

Open/Closed Principle (OCP)

It states that “software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification”. In other words, we can add additional functionality (extension) without touching the existing code(modification) of a class.

This principle helps us to reduce potential bugs as OCP does not allow us to modify the existing code which is already tested and production ready. OCP can be followed with the help of protocol.

Let's see with an example: If you remember the InvoicePersistence class in the previous example, we are saving the invoice in the file. Now our boss has come and asked to save the invoice in Core Data so that we can search them easily.

We can simply add one more function called saveInvoiceToCoreData to the InvoicePersistence class:

class  InvoicePersistence {
let invoice: Invoice
init(invoice: Invoice) {
self.invoice = invoice
}
func  saveInvoiceToFile(){
//Create a file and save the invoice
}
func  saveInvoiceToCoreData(){
//Saves invoice to core data
}
}

But, there is a design problem in the class. We haven’t designed the classes to be easily extendable in the future. We have modified the existing class InvoicePersistence in order to add additional functionality. So, it doesn’t obey the Open Closed Principle.

We can refactor the code to make it extendable and obey the principle.

Instead of using the concrete class InvoicePersistence, we can create a protocol called InvoicePersistenceProtocol that has only a save method. All persistence classes will conform to InvoicePersistenceProtocol and implement this save method.

protocol  InvoicePersistenceProtocol{
func  save(invoice: Invoice)
}
class  FilePersistence: InvoicePersistenceProtocol{
func  save(invoice: Invoice) {
//Create a file and save the invoice
}
}
class CoreDataPersistence: InvoicePersistenceProtocol{
    func save(invoice: Invoice) {
        //Saves invoice to core data
    }
}
class  InvoicePersistenceOCP: InvoicePersistenceProtocol {
let persistence: InvoicePersistenceProtocol
init(persistence: InvoicePersistenceProtocol) {
self.persistence = persistence
}
func  save(invoice: Invoice) {
persistence.save(invoice: invoice)
}
}
//calling
let coreDatapersistence =  CoreDataPersistence()
let persistenceOCP =  InvoicePersistenceOCP(persistence: coreDatapersistence)
persistenceOCP.save(invoice: invoice)

Now our persistence module is easily extendable. Again if our boss comes and asks us to give support to the Realm database, we can simply add a class called RealmDataPersistence which conforms to InvoicePersistenceProtocol.

Liskov Substitution Principle (LSP)

The principle was introduced by Barbara Liskov in 1987. according to this principle “Derived or child classes must be substitutable for their base or parent classes”.

This means, given that a class Dog is a subclass of Animal, we should be able to pass an object of class Dog to any method that expects an object of class Animal and it should not give any weird output. Assume in the Animal class, there is a function called eat(), the Dog object should also be able to call the eat() function as Dog is derived from Animal and it overrides all the functions of its parent class.

Let’s see the usage of this principle with a practical example: suppose we have to make an API call with the help of NSURLRequest and upon failure, we have to get the request data of the call.

We can do this in the following way:

let requestKey: String  =  "NSURLRequestKey"

// This is an NSError subclass that provides additional functionality
class  CustomError: NSError {
var request: NSURLRequest? {
return  self.userInfo[requestKey] as?  NSURLRequest
}
}

class  FakeAPICall{
//This is a fake API call, it will always return an error.
//notice here the return type is NSError but we have passed CustomError
//which is a subclass of NSError
func  callAPI(request: NSURLRequest) -> NSError {
let userInfo: [String:Any] = [requestKey : request]
return  CustomError(domain:"LSP.fakeAPI.com", code:999, userInfo: userInfo)
}
}

//Calling fake API
let fakeAPI =  FakeAPICall()
//Pass an empty object of NSURLRequest
let request =  NSURLRequest()
let error = fakeAPI.callAPI(request: request)
if  let customError = error as?  CustomError {
print("Request:\(customError.request)")
}

Here you can see CustomError (child class) is substitutable for Error (base class) and can be used when an Error object is required, so it follows LSP.

Interface Segregation Principle (ISP)

It states that “do not force any client to implement an interface which is irrelevant to them”. This means that classes should have specific interfaces for the functionality they provide.

This principle is similar to the first principle of SOLID (Single Responsibility Principle), instead of classes it applies to interfaces.

Instead of one fat interface multiple small interfaces are preferred, each interface serving a specific task.

Let’s see ISP with an example: suppose there is a protocol called GestureProtocol that provides tap, double tap and long press functionality. And, there is a button called SuperButton that performs all the operations. So, SuperButton should conform to GestureProtocol. Here is what the code looks like:

protocol  GestureProtocol{
func  didTap()
func  didDoubleTap()
func  didLongPress()
}

class  SuperButton : GestureProtocol{

func  didTap() {
//Perform task after a single tap
}

func  didDoubleTap() {
//Perform task after double tap
}

func  didLongPress() {
//Perform task after long press
}
}

Now, if there is a button called DoubleTapButton which only performs a double-tap operation and since we have a single protocol called GestureProtocol we end up writing the following code:

class  DoubleTapButton : GestureProtocol{

func  didTap() {
//Perform task after a single tap
}

func  didDoubleTap() {
//Perform task after double tap
}

func  didLongPress() {
//Perform task after long press
}
}

DoubleTapButton un-necessary implement didTap() and didLongPress() functions. Here it breaks the ISP. We can solve the problem using separate protocols instead of a fat one. The refactored code looks like this:

protocol  SingleTapProtocol{
func  didTap()
}
protocol  DoubleTapProtocol{
func  didDoubleTap()
}
protocol  LongPressProtocol{
func  didLongPress()
}

class  SuperButton : SingleTapProtocol, DoubleTapProtocol, LongPressProtocol {

func  didTap() {
//Perform task after a single tap
}

func  didDoubleTap() {
//Perform task after double tap
}

func  didLongPress() {
//Perform task after long press
}
}

class  DoubleTapButton : DoubleTapProtocol {

func  didDoubleTap() {
//Perform task after double tap
}

}

Now, DoubleTapButton only conforms to a specific protocol i.e. DoubleTapProtocol and it follows ISP.

Dependency Inversion Principle (DIP)

This principle states that “high-level modules should not depend on low-level modules, but should depend on abstraction”. This means the details of the implementation should depend on abstraction but not the other way around.

The above line states that if a high-level module imports any low-level module then the code becomes tight coupling, changes in one class break another class.

Let’s consider an example of an iOS app that has a payment feature. The app supports multiple payment methods such as Debit Cards, Stripe, UPI, and Apple Pay.

We can create four different classes for payment methods and the classes implement a common interface PaymentMethod.

In the Payment class if we take four separate payment methods as property then the class will somewhat look like this:

class  Payment{
var debitCardPayment: DebitCardPayment?
var stripePayment: StripePayment?
var upiPayment: UPIPayment?
var applePayPayment: ApplePayPayment?
}

This code clearly violates DIP as the Payment class which is a high-level module is dependent on DebitCardPayment which is a low-level module. But as per DIP, it should depend on abstraction/protocol. So, instead of taking concrete classes, the Payment class takes a property of type PaymentMethod protocol. This means the class now depends on abstraction. Now the refactored class looks like this:

protocol PaymentMethod{
    func pay(amount:Double)
}

class DebitCardPayment: PaymentMethod{
    func pay(amount: Double) {
        //Process Debit Card Payment
    }
}

class StripePayment: PaymentMethod{
    func pay(amount: Double) {
        //Process Stripe Payment
    }
}

class UPIPayment: PaymentMethod{
    func pay(amount: Double) {
        //Process UPI Payment
    }
}

class ApplePayPayment: PaymentMethod{
    func pay(amount: Double) {
        //Process Apple Pay Payment
    }
}

class Payment{
    private let paymentMethod : PaymentMethod
    init(paymetMethod: PaymentMethod) {
        self.paymentMethod = paymetMethod
    }
    func makePayment(amount: Double) {
        paymentMethod.pay(amount: amount)
    }
}
// calling
let stripePayment = StripePayment()
let payment = Payment(paymetMethod: stripePayment)
payment.makePayment(amount: 200.0)

The above code follows the Dependency inversion principle. Again if you want to add one more payment method e.g. Credit Card you can do so without changing the Payment method class. Also, you can reuse the Payment class in another project as well as it is now loosely coupled.

If you look closely DIP principle is similar to Open Closed Principle. Also, keep in mind that Dependency Inversion and Dependency Injection both are different concepts. People often get confused about it.

Conclusion

The use of SOLID principles helps produce an efficient architecture. It is also about separating concerns and creating a design that allows sustainable development.

But these principles, while very useful, are not rules. They’re tools. sometimes we need to make compromises, while maybe violating some of the principles to protect the integrity of the business. Everything is about balancing trade-offs and advantages.

I hope you enjoyed the Article. You can find all the codes in the GitHub link.

Thanks for reading. Since you have read the whole article, here is a message from the legend himself:

Happy Coding :)

You can check my linkedin profile.