The most effective way to break applications is to create God classes. Those are classes that contain a lot of information and have several responsibilities. They also are non-cohesive, tightly coupled and difficult to maintain or refactor. They are messy and hard to read.
Are you tired of working with code that's difficult to understand, maintain, and modify?
Do you often find yourself spending more time fixing bugs and making changes than actually developing new features?
If so, you're not alone.
Many developers have faced similar challenges, which is why the SOLID principles were created. SOLID is an acronym for five design principles that can help you write code that's easy to read, maintain, and extend.
In this article, we'll explore these principles and show you how to apply them in your own projects. By the end, you'll have a good understanding of how to write robust and scalable code, and be able to work well with others.
Whether you're a seasoned developer or just getting started, read on to discover the power of SOLID principles.
Single Responsibility
A class or function should only have one reason to change
This is a quote Uncle Bob (Robert C. Martin) came up with after studying and reformulate works of others software crafters in his early years of programming. This quote sounds good, but it begs the question ↓
What defines a reason to change?
Imagine a typical business organization. There is a CEO at the top. Reporting to that CEO are the C-level executives: the CFO, COO, and CTO among others.
→ The CFO is responsible for controlling the finances of the company.
→ The COO is responsible for managing the operations of the company.
→ The CTO is responsible for the technology infrastructure and development within the company.
Now consider this bit of Java code
// CTO, COO and CFO responsibility are grouped in one class
public class ExecutiveTask {
public BigDecimal calculatePay() {
//apply pay calculation algorithm
}
public void save() {
//apply save algorithm
}
public String reportHours() {
//apply hours reporting algorithm
}
}
- The
calculatePay
method implements the algorithms that determine how much a particular employee should be paid, this is the responsibility of the CFO - The
save
method stores the data onto the enterprise database, this is the CTO Role - The
reportHours
method returns a string which is appended to a report that auditors use to ensure that employees are working the appropriate number of hours, and this one is the COO Role.
This ExecutiveTask
is bad, terrible.
The algorithm for each C-level executive is located in the same class. If one executive were to request a change to one of their respective algorithms, it has the potential to ripple into another executive's algorithm because the class have more than one reason to change.
Another quote for the Single Responsibility Principle is:
Gather the things that change for the same reasons.
Separate those things that change for different reasons.
People have wondered whether a bug-fix qualifies a reason to change. Others have wondered whether refactoring is a reason to change.
These questions can be answered by pointing out the coupling between the term reason to change and responsibility.
What is a Responsibility?
What is that really mean in the real-world scenario?
In the context of SRP, a responsibility is a reason to change. If you can think of more than one motive for changing a class, then that class has more than one responsibility. This is sometimes hard to see because we are used to thinking of class responsibility in groups.
// We separate different responsibility by creating different contract
public interface CfoTasks {
BigDecimal calculatePay();
}
public interface CooTasks {
String reportHours();
}
public interface CtoTasks {
void save();
}
public class CFO implements CfoTasks {
public BigDecimal calculatePay() {
//implement calculate pay algorithm
}
// we can add more CFO responsibility here
}
public class COO implements CooTasks {
public String reportHours() {
//implement report hours algorithm
}
// we can add more COO responsibility here
}
public class CTO implements CtoTasks {
public void save() {
//implement save algorithm
}
// we can add more CTO responsibility here
}
However, as you think about this principle, remember that the reasons for change are people. It is people who request changes. And you don’t want to confuse those people, or yourself, by mixing the code that many different people care about for different reasons.
Later, if you want to add shared responsibilities by the objects in your system, consider using another abstraction.
// This abstraction has to be implemented by all the executives
public interface ExecutiveTask {
default void participateMeeting(String schedule) {
//implement meeting logic for everyone here
}
}
public class CTO implements CtoTasks, ExecutiveTask {
public void save() {
//implement save algorithm
}
}
public class CFO implements CfoTasks, ExecutiveTask {
public BigDecimal calculatePay() {
//implement calculate pay algorithm
}
}
//...
Here is the implementation
CTO albert = new CTO();
albert.save(); // This is his role as CTO
albert.participateMeeting("2023-04-06"); // This a shared role by all executive
CFO hannah = new CFO();
hannah.calculatePay(); // This is her role as CFO
hannah.participateMeeting("2023-04-06"); // This a shared role by all executive
Why should you consider investing more time in thinking of your design before writing code?
Imagine you took your laptop to a technician to fix a broken screen. He calls you the next day saying it’s all fixed. When you take your laptop, you find the screen now works fine, but the keyboard won’t work. It’s clear that you will not return to that technician because he’s clearly a fool 😂.
By separating the elements by reason of change, we create security about our developments, but also regarding the future changes in our code.
Open Close
As Robert C. Martin quoted:
A module should be open for extension but close for modification
That means you should be able to extend the behaviour of a system without having to modify that system.
But if you think about it, you will realize that adding a new feature would mean leaving the old code in place and only deploying the new code, perhaps in a new jar file (for Java developers).
When I was reading or watching conf videos about OCP, I always wondered if that’s even possible. In real-world projects, our designs don’t allow new features to be written, compiled and deployed separately from the rest of the system.
In reality, we add new features by making many changes throughout the body of the existing code.
Even Uncle Bob admits in this conference that it’s impossible to get a system to fully conform to this.
But what if you can get your system to work by 50% of this principle?
Every time you add a new feature, it will be a new code, not fiddling with old code.
Software like IntelliJ and VS Code are tools that can be easily extended without modifying or recompiling them by writing plugins. Plugins systems are the ultimate consummation of the Open-Close Principle.
Take a real-world case, where our boss told us to that he wanted us to use PayPal to receive payment in our system.
So, we went and coded a concrete class PaypalService
class, connecting it to the PayPal API.
public class PaypalService {
private final String credentials;
public PaypalService(String credentials) {
this.credentials = credentials;
}
public void receivePayment() {
// connect to the PayPal API
// transaction logic
// ...
}
}
2 months later, he notifies us that he wants to use Stripe because PayPal was become inefficient for the business. So, we went and coded a concrete StripeService
. But to hook it up, we're going to have to change and potentially break a lot of code.
How could we have designed that better?
Following OCP, we could define an interface
that specifies what a Payment service can do, and leave the actual implementation to be figured out separately.
The main idea of OCP is to keep the policy (abstractions) separate from the detail (concretions) to enable loose coupling.
Let’s imagine a world where developers could answer positively to those questions?
- What if the design of your systems was based around plugins, like IntelliJ, or Eclipse?
- What if you could plug in the Database, or the GUI, without any effort?
- What if you could plug in new features, and unplug old ones?
- What power would that give you? How easy would it be to add new features, or new user interfaces, or new machine/machine interfaces?
- How easy would it be to add or remove REST? How easy would it be to add or remove Spring, or Rails, or Hibernate, or Oracle, or… ?
That's what we strive for in software architecture ⚔️. Being able to design your software so that the minimum amount of code needs to change to take it from point A to point B.
To achieve this, we write interfaces and abstract classes to dictate the higher-level policy that must be implemented, and then we implement that policy using concrete classes (details).
Well, you get my meaning. When your fundamental business rules are the core of a plugin architecture, you are never bound to a particular interface, database, framework, or anything else.
Liskov Substitution
Objects of a superclass should be replaceable with an object of its subclasses without breaking the application
Simply put, we want objects of our subclasses to behave the same way as the objects of our superclass. If substituting a superclass object with a subclass object changes the program behaviour in unexpected ways, the LSP is violated.
The LSP is applicable when there’s a supertype-subtype inheritance relationship by either extending a class or implementing an interface. We can think of the methods defined in the super type as defining a contract.
Every subtype is expected to stick to this contract. If a subclass does not adhere to the superclass’s contract, it’s violating the LSP.
In Uncle Bob Clean Architecture, he says:
To build software systems from interchangeable parts, those parts must adhere to a contract that allows those parts to be substituted one for another.
This makes sense intuitively - a class’s contract tells its clients what to expect. If a subclass extends or overrides the behaviour of the superclass in unintended ways, it would break the clients.
There are several possible ways:
- Returning an object that’s incompatible with the object returned by the super class method.
- Throwing a new exception that’s not thrown by the super class method.
- Changing the semantics or introducing side effects that are not part of the super class’s contract.
Java and others statically typed languages prevent 1 (unless we use very generic classes like Object) and 2 (for checked exceptions) by flagging them at compile-time. It’s still possible to violate the LSP in these languages via the third way.
The Payment example we gave a moment ago is the best way to think about this.
Since we've defined an PaymentService
interface:
public interface PaymentService {
void pay(String company,int quantity);
}
We can implement various payment services, as long as they implement the PaymentService
interface and the required pay(String company,int quantity)
method.
class PayPal implements PaymentService {
@Override
public void pay(String company, int quantity) {
// paypal algorithm
}
}
class Stripe implements PaymentService {
@Override
public void pay(String company, int quantity) {
// stripe algorithm
}
}
class Visa implements PaymentService {
@Override
public void pay(String company, int quantity) {
// visa algorithm
}
}
And then we can inject it into our classes through Dependency Injection, making sure we refer to the interface it belongs to rather than one of the concrete implementations.
class PurchaseController {
private final PaymentService paymentService;
PurchaseController(PaymentService paymentService) { // like this
this.paymentService = paymentService;
}
public void executePayment(String company, int quantity) {
// handle API request
// ...
this.paymentService.pay(company, quantity);
}
}
Now, all of these are valid.
var paypalService = new PayPal();
var stripe = new Stripe();
var visa = new Visa();
// any of these are valid
PurchaseController paypalController = new PurchaseController(paypalService);
// or
PurchaseController stripeController = new PurchaseController(stripe);
// or
PurchaseController visaController = new PurchaseController(visa);
Because we can interchange which implementation of any PaymentService
we pass in, we're adhering to LSP.
Interface Segregation
Prevent classes from relying on things that they don't need
The ISP states that a client should not be forced to implement an interface that it doesn't use. In other words, the ISP suggests that you should split large interfaces into smaller, more specific ones that are tailored to the needs of individual clients.
The goal of the ISP is to reduce the coupling between classes and interfaces, which makes the code easier to maintain and modify.
By creating smaller, more focused interfaces, you can avoid unnecessary dependencies and ensure that clients only depend on the methods they actually use. This can also make it easier to test and debug your code, since you can isolate individual components and test them independently.
Overall, the Interface Segregation Principle encourages you to design your code with flexibility and modularity in mind so that you can adapt to changing requirements and evolve your software over time.
That means we "segregate the interfaces". And we should depend only on interfaces or abstract classes as per the Dependency Inversion Principle.
No need to repeat what we already told in SRP here, in our previous example with CEO C-level executives, we have split their responsibility by single interface, by doing so, we already applied the ISP, in case you have forgotten this a friendly reminder 😇
public class CFO implements CfoTasks { // <- This is ISP in action
public BigDecimal calculatePay() {
// ...
}
}
public class COO implements CooTasks { // <- This is ISP in action
public String reportHours() {
// ...
}
}
// ...
Dependency Inversion
Abstractions should not depend on details. Details should depend on abstractions.
To sum up what we have seen before, we can say that an abstraction is an interface or abstract class. And a detail is concretion → Concrete class.
When we say abstraction should not depend on details, it basically means you not should not do something like this
public interface PaymentService {
// refering to concrete "PayPal" from an abstraction
void pay(PayPal payPalService);
}
Instead, you should do this
class PayPalService implements PaymentService {
// concrete class relies on abstraction
@Override
public void pay(Payment payment) {
// add logic here
}
}
This is already what you've being seeing throughout this article so far! We've been referring to abstractions (interfaces and abstract classes) instead of concrete ones.
Main components / detail components. We can never reference concrete classes. That's how we actually hook things up to get stuff to happen.
If we want to use a controller to make our payment. Therefore, we need to reference those concrete PaymentController
and PaymentService
classes to hook them up. Uncle Bob calls these Main components.
They’re messy, but they’re necessary.
However, we shouldn't refer to concrete classes from another concrete class directly.
This is what gives us the ability to test code because we leave the power to the implementor to pass in a mocked dependency if we don't want to make API calls or rely on something we're not currently interested in testing.
Therefore, you should do this.
class PaymentController extends BaseController {
private final PaymentService paymentService; // ← abstraction
PaymentController(PaymentService paymentService) { // ← abstraction
this.paymentService = paymentService;
}
public void executePayment() {
// handle third party request
// ...
// make payment
String company = "randomCompany";
int quantity = (int) (Math.random() * 10);
this.paymentService.pay(company, quantity);
}
}
Not this
class PaymentController extends BaseController {
// this is a design limitation by a particlar concrete class.
private final PayPalService paypalService; // ← concretion
PaymentController(PayPalService paypalService) { // ← concretion
this.paypalService = paypalService;
}
public void executePayment() {
// handle third party request
// ...
// make payment
String company = "randomCompany";
int quantity = (int) (Math.random() * 10);
this.paymentService.pay(company, quantity);
}
}
And definitely not this
class PaymentController extends BaseController {
private final PaymentService paymentService;
// 👆🏽 This will be nearly impossible to test with Mocks
PaymentController() {
}
...
That’s all for the Dependency Injection Principle.
I hope you enjoyed reading this, and I'm curious to hear if this tutorial helped you. Please let me know your thoughts below in the comments. Don't forget to subscribe to my newsletter to avoid missing my upcoming blog posts.
You can also find me here LinkedIn • Twitter • GitHub or Medium
Closing Thoughts
Phew! 😮💨 We've covered a lot of stuff so far. Now I just want to sum up things. SOLID principles are a set of five principles for object-oriented programming that aim to make software more maintainable, scalable, and testable.
By following these principles, developers can create code that is easier to modify and extend over time. It can also lead to more modular design and better separation of concerns.
If you are keen to learn more about SOLID principles, I recommend checking out resources on Uncle Bob Blog, The SOLID Book↗ made by a friend, @KhalilStemmler.
Here's a tweet about the first principles of coding that I found and which resonates with me:
SOLID Principles are basic, they are fundamentals and one should master it to be a confident developer. Happy Coding 😁