**Table of Contents
- Single Responsibility Principle
- Open / Closed Principle
- [[#Open / Closed Principle#Example|Example]]
- Liskov Substitution Principle
- [[#Liskov Substitution Principle#Example|Example]]
- Interface Segregation Principle
- Dependency Inversion Principle
Single Responsibility Principle
A class should have one responsibility or job—consider splitting into smaller classes in the event of drifting responsibilities.
Benefits:
- Maintainability - Update a feature without breaking others
- Testability - Separation of concerns
- Readability
Open / Closed Principle
Take with a grain of salt—Officially: This principle states that software should be open to extension of features, but not modification of them. In other words, you shouldn’t need to modify code (especially when tests exist for them) to add new functionality.
In practice, this depends on code that has already been stabilized around certain assumptions and there are multiple strategies to make this principle work. The most popular which is using interfaces (instead of superclasses) to allow different implementations that you can easily substitute. This allows for loose coupling since the interfaces are independent,
Example
Let’s say we have a BasicCoffeeMachine class which can add ground coffee, and brew coffee. Then we have a caller CoffeeMachineApp which holds a BasicCoffeeMachine and can call its function.
This works great until we start adding another type of CoffeeMachine …and then another. Then we have to modify our app to handle those cases. If we went the Open/Closed Principle route, we’d extract the mandatory method: brewCoffee(selection) and rewrite the app to depend on the interface rather than a specific class.
This example is from this article which is a narrow use case that doesn’t seem very transferable at first. The main takeaway from this is to be mindful of this principle in areas where the domain is likely to grow with additional related features. It’s a topic that deserves its own notes, as there are a variety of strategies to maintain this principle such as composition, dependency injection, abstract classes, polymorphism, “strategy” design pattern, etc.
Liskov Substitution Principle
Subtypes should be substitutable for their base types. I.e. any instance of a base class should be able to be replaced by an instance of the subclass without affecting correctness.
At first this sounds confusing, but it boils down to the fact that a subclass should not completely change the behavior of the superclass, otherwise you’re blurring the lines between inheritance and just having a completely separate class.
Example
Imagine a Bird base class that mandates the existence of a fly() function. This works great for the Duck subclass, but we realize that it doesn’t work for the Penguin subclass so we just throw an UnsupportedOperationException("Can't fly"). We fundamentally changed the behavior of the fly() method for the penguin class, thus breaking the trust/contract the calling code assumes.
The solution?
It depends. In that example, we can split the hierarchy based on behavior by making a Bird interface and a FlyingBird interface that extends Bird. Alternatively, we can use composition/delegation (the Strategy Pattern) to give behavior only to those who can carry it out.
Interface Segregation Principle
Clients should not be forced to depend on interfaces they do not use, so interfaces should be small and focused.
If we have a Worker interface who can work() and eat(), we force the implementers to implement all of those functions even if they’re not relevant. Imagine a RobotWorker—we now have to throw an error or otherwise handle an action that doesn’t make sense. If we instead have two separate Eatable and Workable interfaces with distinct behaviors, we can maintain this principles.
Rule of thumb: One interface describes one behavior or responsibility.
Dependency Inversion Principle
- High level modules should not depend on low level modules → both should depend on abstractions.
- Abstractions should not depend on details → details should depend on abstractions.
You lost yet? I know I am.
First off, high level modules refer to the classes orchestrating everyone whereas the low level ones get their hands dirty with concrete tasks, algorithms, database modifications, etc. This principle wants us to decouple the two.
Take this example which couples the two:
class UserService { // high-level
Database database = new MySQLDatabase(); // low-level detail
void saveUser(User u) { database.save(u); }
}In order to maintain the dependency inversion principle, we can abstract out that low level detail via dependency injection (one of many solutions) like so:
interface Database {
void save(User u);
}
class MySQLDatabase implements Database { ... }
class PostgreSQLDatabase implements Database { ... }
// Add additional implementations here...
class UserService { // high-level
private Database database;
UserService(Database db) { this.database = db; } // inject dependency
void saveUser(User u) { database.save(u); }
}