Understanding SOLID Principles
Here's an in-depth guide on implementing SOLID principles in Swift, which can be applied to any programming language. Understanding these principles is crucial for writing production-grade software.
Importance of SOLID Principles
Mastering SOLID principles will make you a better developer. You'll be able to write clean, well-structured code that's easy for team members to understand and future developers to modify without causing unintended issues.
Strive to become a developer who writes clean, well-structured code that is easy for team members to understand and for future developers to modify without causing unintended issues.
Before we begin, you should be familiar with protocols, interfaces, or abstract classes.
These constructs define methods and/or properties that a class, struct, or enum must implement if they conform to them.
That being said, let's dive in.
SOLID is an acronym for five design principles that help make your code more maintainable and scalable:
Single Responsibility Principle (SRP)
Open/Closed Principle (OCP)
Liskov Substitution Principle (LSP)
Interface Segregation Principle (ISP)
Dependency Inversion Principle (DIP)
Single Responsibility Principle (SRP)
A class should have only one reason to change, meaning it should have only one job or responsibility.
Instead of combining multiple responsibilities (e.g. functions) into one class, split them into separate classes or structs to keep your code clean and focused.
Let's take a look at an example:
Unfortunately, this struct violates the Single Responsibility Principle because it's responsible for multiple things like printing and saving invoices.
Ideally, we want our struct to be responsible for one thing, with all other functionality abstracted to their own structs or classes. So let's refactor our previous example.
There is a lot more code than the previous example, but this benefits us because our code now is more organized and robust.
We've essentially created an API. The Invoice struct provides a public interface with functions like printInvoice() and saveInvoice().
The logic of our invoice printing and saving functions are abstracted to their own structs which takes the invoice itself as a parameter when initialized.
Finally, our Invoice struct only is responsible for one thing: modeling the required properties of an invoice, and exposing an API of it's functionality.
Open/Closed Principle (OCP)
Classes and structs should be open for extension but closed for modification.
This means you should be able to add new functionality to your classes or structs without changing their existing code. Use protocols and extensions to add new features.
Other programming languages use Interfaces or Abstract Classes.
So let's use our InvoicePersistence struct created earlier as an example.
We've modified it to handle saving data both locally and in a database.
But what happens if we need to use a different database? We would have to create a new function to handle that.
This is where protocols and interfaces shine.
We can recognize that all databases need to save something. So we can abstract this functionality to its own protocol or interface.
Now we can go ahead and create separate structs for each type of storage we want to save to.
By having our structs conform to the InvoicePersistable protocol, we can implement the save() function however we want.
Going back to our original InvoicePersistence, we no longer need to implement those functions anymore.
Instead, we can create a single property that conforms to the InvoicePersistable protocol, and call the save() function regardless of the persistence method.
If this stuff isn't clicking quite yet, no worries. Try typing it out in your IDE using your language of choice. It will be easier for you to follow the return values and see how everything connects.
Now that we've refactored our InvoicePersistence struct, implementing this pattern becomes super clean.
Initialize the persistence method you want to use (e.g. Core Data)
Initialize the InvoicePersistence struct and pass in the persistence method. (Remember that it takes any data type that conforms to the InvoicePersistable protocol)
Call save() on the invoice you wish to persist
So there you have it. A clean implementation that's easy to understand and maintain.
Moving on, we'll continue to leverage the benefits of protocols/interfaces.
Liskov Substitution Principle (LSP)
This principle means that you should be able to use a child class (or subclass) wherever you use a parent class (or superclass) without any issues.
In other words, a subclass should work perfectly in place of its superclass without causing any errors or unexpected results.
Let's see this in action using two examples. The first example is pretty basic and should get the point across. The second example is a real-world case you will often find in production, and might require a bit of thinking.
In our first example, we have a simple function called chirp(), which takes a parameter of type Bird.
We also have a class called Bird, and a subclass called Sparrow that inherits from the Bird class.
In Object-Oriented Programming, inheritance is a key concept. The Liskov Substitution Principle relies on polymorphism, which allows objects of different types to be treated as instances of the same type through a common interface.
By leveraging polymorphism, a Sparrow can be used in place of a Bird when calling the chirp() function, and it will still make the right sound.
Now let's look at our second example, which simulates the real-world scenario of fetching a user. We create a custom Enum type that handles different reasons our function can fail.
We also have a MockService struct that contains the fetchUser() function, which can throw an error.
In this case we are forcefully throwing the error.
We can either use our custom APIError type, the Error type itself, or ANY data type that inherits from Error.
Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use.
Instead of creating large, monolithic interfaces, break them down into smaller, more specific interfaces that provide exactly what the client needs.
Here's an example of a protocol/interface that looks like a good idea, but isn't that scalable.
The issue is related to how interfaces and protocols actually work. To demonstrate this, let's make a button that conforms to our GestureProtocol.
This "super button" is able to handle each type of gesture interaction. So far so good.
If we were to make another type of button that handles only double tap gestures, we'd end up with something like this:
The code will work just fine, however this button is forced to adopt two functions it will never need. And so the GestureProtocol is violating the Interface Segregation Principle.
Now we've separated our original protocol into three specific protocols for each gesture. This gives classes and structs the ability to conform only the protocols that are relevant to them.
Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on protocols or interfaces.
A module is just another way of referring to classes, structs or enums.
Use dependency injection to provide what your module needs from the outside. This makes your code more flexible and easier to manage.
To illustrate this better, let's make a PaymentMethod protocol and some payment methods that conform to it.
So far so good. Now let's make a Payment struct that holds each payment method.
This implementation does not conform to the Dependency Inversion Principle because this "high-level" module depends on "low-level" modules (e.g. debitCardPayment, stripePayment, applePayment).
Some programming languages (JavaScript, Python, and C) do not have optional types, and instead use NULL or NONE.
In the case of Swift, we made each payment method optional because there is a use case where they might not hold a value.
We can see how this scenario might play out in the example below.
As you can see, we explicitly put nil for the stripePayment and applePayment properties. In Swift, we can leave those properties out, but it's easier to demonstrate the issue this way.
After the payment variable is created, we can now run the execute() function without each payment method, but only through the use of optional chaining.
Knowing now that we can do better, let's go back to the Payment struct and make use of the PaymentMethod protocol.
This approach is much better as it allows you to easily swap out the payment type, since they all conform to the same protocol.
That's basically wraps up!
By adhering to these principles, you can write cleaner, more maintainable, and scalable code.
I hope this helps! Let me know if you found it helpful, and feel free to share any topics you'd like me to explore in more detail.