SOLID Principles in Java, explained.


[38]
Introduction
The SOLID principles are a set of design principles that aim to make software designs more understandable, flexible, and maintainable.
The SOLID acronym stands for:
S for Single Responsibility Principle (SRP)
O for Open/Closed Principle (OCP)
L for Liskov Substitution Principle (LSP)
I for Interface Segregation Principle (ISP)
D for Dependency Inversion Principle (DIP)
While these principles are applicable across various programming languages, we'll explore each principle using Java-specific examples in the following sections.
History
SOLID principles were introduced by Robert C. Martin (also known as Uncle Bob) in the early 2000s and have since become a cornerstone of object-oriented design and programming. Robert C. Martin introduced these principles in his paper "Design Principles and Design Patterns," presented at the OOPSLA conference in 1995. They were further popularized in his book "Agile Software Development, Principles, Patterns, and Practices," published in 2002. Since then, they have become foundational concepts in software engineering and are often taught in computer science courses and used in software development practices worldwide.
Single Responsibility Principle (SRP)
Definition: A class should always have one responsibility and there should be only a single reason to change it.
Explanation
This principle suggests that a class should have only one responsibility or job. It should encapsulate one and only one aspect of functionality within the software. SRP encourages high cohesion within classes. Cohesion refers to the degree to which the elements of a module (e.g., a class) belong together. A class with high cohesion focuses on a single task or responsibility, making it easier to understand, maintain, and test. This means that if a class is a data container, like a Book class or a Student class, and it has some fields regarding that entity, it should change only when we change the data model.
It makes version control easier. For example, say we have a persistence class that handles database operations, and we see a change in that file in the GitHub commits. By following the SRP, we will know that it is related to storage or database-related stuff. Merge conflicts are another example. They appear when different teams change the same file. But if the SRP is followed, fewer conflicts will appear โ files will have a single reason to change, and conflicts that do exist will be easier to resolve.
Example
Violating SRP: In this example, the Player
class manages player data (like position and health), processes input commands to move the player, and also renders the player on screen.
public class Player {
private String name;
private int health;
private int x, y;
public Player(String name, int health) {
this.name = name;
this.health = health;
this.x = 0;
this.y = 0;
}
// Process input and move the player accordingly
public void processInput(String input) {
switch(input.toUpperCase()) {
case "UP":
y++;
break;
case "DOWN":
y--;
break;
case "LEFT":
x--;
break;
case "RIGHT":
x++;
break;
default:
System.out.println("Invalid input");
return;
}
System.out.println(name + "moved to (" + x + ", " + y + ")");
}
// Manage player health
public void takeDamage(int damage) {
health -= damage;
System.out.println(name + " took " + damage + " damage. Health: " + health);
}
// Render the player (imagine drawing on a screen)
public void render() {
System.out.println("Rendering player " + name + " at (" + x + ", " + y + ")");
}
// Getters for demonstration purposes
public String getName() { return name; }
public int getX() { return x; }
public int getY() { return y; }
}
Issue:
The Player
class above is tightly coupled, handles input processing, health management, and rendering. Any changes in rendering or input logic would force modifications in the Player
class, violating SRP.
Refactored example adhering to SRP
We refactor the responsibilities into separate classes. It made our class loosely coupled, easy to maintain, and only single reason to modify:
Player: Manages player state (data and movement logic).
PlayerInputHandler: Deals exclusively with processing player input.
PlayerRenderer: Handles rendering of the player.
class Player {
private String name;
private int health;
private int x, y;
public Player(String name, int health) {
this.name = name;
this.health = health;
this.x = 0;
this.y = 0;
}
// Update player's position by a delta value
public void move(int deltaX, int deltaY) {
x += deltaX;
y += deltaY;
System.out.println(name + " new position: (" + x + ", " + y + ")");
}
// Manage player health
public void takeDamage(int damage) {
health -= damage;
System.out.println(name + " took " + damage + " damage. Health: " + health);
}
// Getters for demonstration purposes
public String getName() { return name; }
public int getX() { return x; }
public int getY() { return y; }
}
class PlayerInputHandler {
private Player player;
public PlayerInputHandler(Player player) {
this.player = player;
}
// Process input and move the player accordingly
public void handleInput(String input) {
switch(input.toUpperCase()) {
case "UP":
player.move(0, 1);
break;
case "DOWN":
player.move(0, -1);
break;
case "LEFT":
player.move(-1, 1);
break;
case "RIGHT":
player.move(1, -1);
break;
default:
System.out.println("Invalid input");
return;
}
}
}
class PlayerRenderer {
// Render the player (imagine drawing on a screen)
public void render(Player player) {
System.out.println("Rendering player " + player.getName() + " at (" + player.getX() + ", " + player.getY() + ")");
}
}
public class GameApplication {
public static void main(String args[]) {
Player player = new Player("Hero", 100);
PlayerInputHandler inputHandler = new PlayerInputHandler(player);
PlayerRenderer renderer = new PlayerRenderer();
// Simulate user inputs
inputHandler.handleInput("DOWN");
inputHandler.handleInput("LEFT");
// Render the player's current state
renderer.render(player);
// Simulate player taking damage
player.takeDamage(20);
}
}
Output:
Hero new position: (0, -1)
Hero new position: (-1, 0)
Rendering player Hero at (-1, 0)
Hero took 20 damage. Health: 80
Open/Closed Principle (OCP)
Definition: Class should be Open for Extension but Closed for Modification.
Liskov Substitution Principle (LSP)
Definition: Child Classes should be replaceable with Parent Classes without breaking the behavior of our code.
Interface Segregation Principle (ISP)
Definition: Interface should only have methods that are applicable to all child classes. If an interface contains a method applicable to some child classes then we need to force the rest to provide dummy implementation. Move such methods to a new interface.
Dependency Inversion Principle (DIP)
Definition: Class should depend on abstractions (interface and abstract class) instead of concrete implementations. It makes our classes de-coupled with each other. If implementation changes then the class referring to it via abstraction won't change.
Subscribe to my newsletter
Read articles from Pranav Bawgikar directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Pranav Bawgikar
Pranav Bawgikar
Hiya ๐ I'm Pranav. I'm a recent computer science grad who loves punching keys, napping while coding and lifting weights. This space is a collection of my journey of active learning from blogs, books and papers.