Software Development is full of principles like YAGNI, DRY, and KISS, to name a few. While these can be applied to the full spectrum of development, there is an acronym used in Object-Oriented Programming called SOLID, which specifies five guidelines for making robust, reusable code.
Assumptions
- Basic OOP knowledge
What Are SOLID Principles?
SOLID is composed of five OOP principles, each with an aim to help developers avoid the "big ball of mud" situation where a program lacks structure, usually signaling that the program will be difficult to extend and maintain. Following SOLID principles makes code more reader-friendly, meaning there is less likelihood of errors, and developers will have an easier time debugging.
The five principles are:
- Single Responsibility
- Open-Closed
- Liskov Substitution
- Interface Segregation
- Dependency Inversion
When used together, the five principles help create software that is, well.., solid!
Single Responsibility Principle
The single responsibility principle states that a class should only ever have one responsibility, and one reason to change. If a class only ever has one reason to exist, then the code will become loosely coupled, making it easier to test, implement, and extend.
If a class does some action that isn't specifically related to the purpose of the class, then it might be violating the single responsibility principle.
For example, if you had a class representing a player in a game, then you might structure it like this:
class PlayerCharacter {
String playerName;
// Some image class to contain our character sprite.
Image playerSprite;
// Boilerplate code...
// Set the players sprite.
public void setSprite(Image sprite) {
playerSprite = sprite;
}
// Get the players sprite.
public Image getSprite() {
return playerSprite;
}
}
The responsibility of this class is to maintain the state of the character. Now let's say we decided to add a method that renders the character image on the screen at specified coordinates:
class PlayerCharacter {
//...
// (This breaks single responsibility!)
public void renderToScreen(Renderer renderer, int xCoord, int yCoord) {
// Some arbitrary method call
renderer.toScreen(playerSprite, xCoord, yCoord);
//...
}
}
Placing this method in the player class violates the single responsibility principle because we have given the class another job; rendering the character to the screen.
Instead, it would make more sense to create another class that handles the rendering:
class ScreenRenderer {
// Some arbitrary renderer class.
Renderer renderer;
// Displays a sprite on the screen at a given coordinate.
displaySprite(Image sprite, int xCoord, int yCoord) {
renderer.toScreen(sprite, xCoord, yCoord);
}
//...
}
Now that we've fixed the single responsibility violation, our client code will be much easier to extend and reason:
static void main(String[] args) {
// Let's say the path to our image is an argument:
Image playerSprite = new Image(args[0]);
// Create our player:
PlayerCharacter player = new PlayerCharacter();
// Set the sprite:
player.setSprite(playerSprite);
// Render the player to the screen:
ScreenRenderer screen = new ScreenRenderer();
screen.displaySprite(player.getSprite(),128, 128);
}
By following the single responsibility rule, we avoid creating monolith classes that are difficult to manage, and instead end up with a simple solution that lets all types of entities to be rendered to a screen!
Open-Closed Principle
A class that follows the open-closed principle should be open for extension, but closed for modification. What this means is that we can extend the functionality of a class in a sub-class, but we should avoid making changes to the parent class.
> The idea behind this principle is to keep programmers from accidentally introducing new bugs in the program by modifying code that worked previously.
Building on the previous example, let's say that we wanted our player to have a magic stat; while we could insert new code into the PlayerCharacter class, it could cause a lot of unwarranted issues, especially if we've already made a lot of code that depends on the player class. Plus, maybe not every character is a magic character!
Instead, we'll extend the character class:
class MagicPlayerCharacter extends PlayerCharacter {
int magicLevel;
// Related methods and/or overrides for a magic player.
}
> Sometimes, observing this principle religiously leads to code bloat and over engineering. As the developer, it is up to you to assess the design and if it's worth modifying a class and risk introducing more bugs if it means that the overall design is simpler and easier to understand; when in doubt, keep it simple!
Liskov Substitution Principle
The Liskov Substitution principle states that, if a class S is a subtype of T, then objects of type T can be used interchangeably by objects of type S without the program causing any unintended effects.
This might sound like a lot to take in all at once, so let's model it. Consider our player character; say we want to give the player shoes to equip, but we want a few different kinds of shoes that allow different travel speeds:
// Our base class for the shoe.
// Think type "T"
abstract class Shoe {
public abstract double travel();
}
// Implements the shoe.
// Think type "S"
class BareFeet extends Shoe {
public override double travel() {
// We'll walk 1 m/s.
return 1.0;
}
}
// A little faster.
class WalkingShoe extends Shoe {
public override double travel() {
// Jogging at 1.5 m/s.
return 1.5;
}
}
// Fastest shoes!
class RunningShoe extends Shoe {
public override double travel() {
// Running at 2.5 m/s.
return 2.5;
}
}
Now that we have our Shoe base class ( T ) and our shoe sub-classes ( S ), we can see the Liskov substitution principle in practice:
static void main(String[] args) {
String shoeType = args[0];
Shoe shoe; // T
if(shoeType == "None") {
// S
shoe = new BareFeet();
}
else if(shoeType == "Walking") {
// S
shoe = new WalkingShoe();
}
else if(shoeType == "Running") {
// S
shoe = new RunningShoe();
}
print(shoe.travel());
}
In this example, we can see that no matter what type of shoe subclass that we use, any of them can be substituted for an object of type Shoe and it will still behave in a way that doesn't break the application.
Interface Segregation Principle
In general, the more tasks you have to focus on, the less tasks you can do excellently. The same is generally true with interfaces in software development; an interface with lots of methods loses flexibility, and often bottlenecks itself into specific use cases.
The interface segregation principle says that you should try to break up "heavy" interfaces into smaller interfaces to keep the design flexible to changes. Continuing on with our player character example, lets have character's and entities that can take damage and give damage. If we put this into a single interface, it would look like this:
// Poor design!
interface IDealAndTakeDamage {
int dealDamage();
void takeDamage(int damage);
}
While this might work for a majority of entities in the game such as enemies and companions, what about something that can deal damage but not take damage, and vice versa?
For instance, a flower pot isn't able to deal damage, but it should be able to take damage from a player to destroy it. While we could make a special case for it, our design would be much more flexible overall if the interface was split into more specific use cases:
interface IDealDamage {
int dealDamage();
}
interface ITakeDamage {
void takeDamage(int damage);
}
// Example classes
class Enemy implements IDealDamage, ITakeDamage {
int health;
int strength;
int dealDamage() {
return strength;
}
void takeDamage(int damage) {
health = health - damage;
}
}
class FlowerPot implements ITakeDamage {
int health;
void takeDamage(int damage) {
health = health - damage;
}
}
By splitting up the interfaces, we've made our application much more flexible!
> If you're 110% sure that there will never be an edge case in the design of the application, then it isn't always necessary to adhere to this principle for the sake of following it.
Dependency Inversion Principle
<div style="width:100%; margin:auto;text-align:center;"> <img src="https://www.devmaking.com/img/topics/designpatterns/SolidPrinciples_01.png" alt="dependency inversion principle" style="max-width:95%;"> </div>
The dependency inversion principle tries to decouple classes by making sure that classes generally rely on abstractions and not concretions. What this means is that if we have a WalkingShoe
class, we want to depend on an abstract Shoe
type instead of the concrete WalkingShoe
concretion.
Additionally, by relying on abstractions, classes begin to rely on dependency injection to fuel their dependencies. If you are not familiar with the term "dependency injection", it might help to think "passing a variable into a class method".
For example, if we were to have our character have running shoes, instead of using a concrete class like WalkingShoe
, we want to depend on an abstraction; Shoe
. Using dependency injection, we can inject the concrete dependency into the character class.
// Our character class revisited:
class PlayerCharacter {
String playerName;
Shoe shoe;
//...
// We are injecting the Shoe dependency into the class.
public PlayerCharacter(String playerName, Shoe shoe) {
this.playerName = playerName;
this.shoe = shoe;
}
}
public static void main(String[] args) {
// instantiating the player with dependency injection:
Shoe shoe = new RunningShoe();
String name = args[0];
PlayerCharacter player = new PlayerCharacter(name, shoe);
}
In this example, we make the player character depend on the abstract shoe, but we inject a concrete instance of RunningShoe
into the player character class.
Principles in Practice
While the term "principles" may make SOLID seem infallible, the truth is that they are really more of guidelines for creating robust, flexible software; in some cases, it makes more sense to violate a principle for the sake of keeping an application simple and readable.
> For example, a <a href="https://www.devmaking.com/learn/design-patterns/factory-pattern/" target="_blank" style="color:inherit;">simple factory</a> is a commonly used pattern which violates the open close principle, but can still make an application much simpler to understand under the right conditions.
This doesn't mean throw SOLID principles in the back seat and be cavalier on your wild ride with programming destiny; the simple factory has a good reason for violating the principle; it seeks to violate it only once to make the program simpler.
If you're faced in a situation where you may need to violate a SOLID principle, think it over and consider if it is truly worth going against the grain. If so, then go ahead, but still take caution that the design might suffer as a result!
> Generally, throwing the Liskov Substitution principle out the window is not the best idea unless you really know what you're doing.
Recommended Resources
- <a target="_blank" href="https://www.amazon.com/gp/product/0596007124/ref=as_li_tl?ie=UTF8&camp=1789&creative=9325&creativeASIN=0596007124&linkCode=as2&tag=devmaking-20&linkId=bc32087110669f75d93b216df79816f0" style="color:#fff;border-radius:3px;background-color:#888;padding:1px 5px">Head First Design Patterns: A Brain-Friendly Guide</a> : An excellent primer for learning design patterns in a pragmatic way.
- <a target="_blank" href="https://www.amazon.com/gp/product/0201633612/ref=as_li_tl?ie=UTF8&camp=1789&creative=9325&creativeASIN=0201633612&linkCode=as2&tag=devmaking-20&linkId=ae25d94c4ea49870eb3115e0e4d2de90" style="color:#fff;border-radius:3px;background-color:#888;padding:1px 5px">Design Patterns: Elements of Reusable Object-Oriented Software</a> : The reference guide made famous by Gang of Four, still widely used today.