Is Your Code Becoming Harder to?Handle?
Niruksha Akshith Sandeepa
Full Stack Developer | Computer Science & Engineering Undergraduate | University of Moratuwa
Master SOLID Principles; Build Better Code, Step by Step
When you’re writing code, have you ever felt like it’s getting harder to manage or change? Maybe you’re constantly fixing bugs that pop up in one part of the code whenever you make changes somewhere else. SOLID principles are here to save you from that! These five simple rules make your code easier to understand, change, and scale up. Created by Robert C. Martin (known as “Uncle Bob”), they can help you write cleaner, more reliable code. Let’s break down each of these principles, using Java code examples to make things clear.
Single Responsibility Principle
Keep Classes Focused on Just One Job
The Single Responsibility Principle (SRP) says that every class should have just one job or responsibility. Think of a class as a person at work?—?it’s much easier for that person to stay focused and effective if they only have one job to do, right? If we pile on extra tasks, they’re bound to get overwhelmed. In code, this principle helps prevent classes from getting too big and complex.
Let’s say we have a User class that not only holds user details like name and email but also saves that information to the database. That’s two jobs: managing user data and handling data storage.
Without Single Responsibility Principle:
class User {
private String name;
private String email;
// Getters and setters
public void saveToDatabase() {
// Code to save user details to a database
}
}
With Single Responsibility Principle:
class User {
private String name;
private String email;
// Getters and setters
}
class UserRepository {
public void saveUser(User user) {
// Code to save user details to a database
}
}
By creating a UserRepository class to handle database saving, our User class now focuses only on user details. If we need to change how we save data, we only have to modify UserRepository—and the User class remains untouched. This keeps our code simple and easier to work with.
Open/Closed Principle
Add New Features Without Messing with Old Code
The Open/Closed Principle (OCP) says that classes should be open to new features but closed to modifications. This means you should be able to add new functionality to a class without changing its existing code. Why? Because changing old code can introduce bugs in places you didn’t expect. By following OCP, you keep the old code stable and only add new code when extending features.
Imagine you’re building a shape calculator with a Shape class. If you decide to add more shapes (like a rectangle or a circle), you don’t want to keep rewriting the Shape class. Instead, you can use inheritance to create new shape classes without touching the original Shape class.
Without Open/Closed Principle:
class Shape {
public double calculateArea(String shapeType, double radius, double length, double width) {
if (shapeType.equals("circle")) {
return Math.PI * radius * radius;
} else if (shapeType.equals("rectangle")) {
return length * width;
}
return 0;
}
}
With Open/Closed Principle:
abstract class Shape {
public abstract double calculateArea();
}
class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
class Rectangle extends Shape {
private double length;
private double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override
public double calculateArea() {
return length * width;
}
}
Here, we create subclasses like Circle and Rectangle, each with its own calculateArea method. Now, to add a new shape, you just add a new subclass without changing the original Shape class at all.
Liskov Substitution Principle
Subclasses Shouldn’t Surprise You
The Liskov Substitution Principle (LSP) sounds complex, but it’s straightforward. It says that a subclass should work in any place its parent class is used without any weird behavior. Imagine you’re ordering a vegetarian dish, and it suddenly comes with meat?—?that’s a surprise you don’t want. LSP is about ensuring that subclasses behave as expected and don’t break anything in the code.
Let’s say we have a Rectangle class and decide to create a Square subclass. A square is a rectangle, but it has a unique feature: all sides are equal. If we’re not careful, using Square as a Rectangle can lead to unexpected results.
Breaking Liskov Substitution Principle:
class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int calculateArea() {
return width * height;
}
}
class Square extends Rectangle {
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width); // Both width and height change
}
@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height); // Both height and width change
}
}
If you use Square as a Rectangle, you might be surprised to see that changing the width of a square also changes its height. This breaks LSP, and it’s often a hint that you need to rethink the inheritance.
Interface Segregation Principle
Only Implement What You Need
The Interface Segregation Principle (ISP) says that a class shouldn’t have to implement methods it doesn’t use. If you create one big, general-purpose interface, it could force classes to implement methods they don’t actually need, leading to unnecessary code.
Imagine an Animal interface with methods like run(), fly(), and swim(). A dog class should only need run() and swim(), while a bird class might only need fly(). We solve this by creating separate interfaces for each action, so each class only implements what it truly needs.
Without Interface Segregation Principle:
interface Animal {
void run();
void fly();
void swim();
}
class Dog implements Animal {
public void run() { /*...*/ }
public void fly() { /* Not relevant to Dog */ }
public void swim() { /*...*/ }
}
With Interface Segregation Principle:
interface Runnable {
void run();
}
interface Flyable {
void fly();
}
interface Swimmable {
void swim();
}
class Dog implements Runnable, Swimmable {
public void run() { /*...*/ }
public void swim() { /*...*/ }
}
With ISP, each class only has the methods it needs, making it cleaner and easier to understand.
Dependency Inversion Principle
Depend on Abstractions, Not Concrete Classes
The Dependency Inversion Principle (DIP) says that high-level modules (important parts of your code) shouldn’t depend on low-level modules (specific implementations). Instead, both should rely on abstractions like interfaces. This way, you can change parts of your code easily without breaking other parts.
Imagine you have a PaymentProcessor that depends directly on a PayPalService. If you want to switch to another service, like Stripe, you’d have to rewrite PaymentProcessor. With DIP, you create an interface PaymentService that both PayPalService and StripeService implement. Now PaymentProcessor depends on PaymentService, making it easy to swap payment providers.
Without Dependency Inversion Principle:
class PayPalService {
public void processPayment(double amount) {
// PayPal payment processing code
}
}
class PaymentProcessor {
private PayPalService payPalService;
public PaymentProcessor() {
this.payPalService = new PayPalService();
}
public void process(double amount) {
payPalService.processPayment(amount);
}
}
With Dependency Inversion Principle:
interface PaymentService {
void processPayment(double amount);
}
class PayPalService implements PaymentService {
public void processPayment(double amount) {
// PayPal payment processing code
}
}
class StripeService implements PaymentService {
public void processPayment(double amount) {
// Stripe payment processing code
}
}
class PaymentProcessor {
private PaymentService paymentService;
public PaymentProcessor(PaymentService paymentService) {
this.paymentService = paymentService;
}
public void process(double amount) {
paymentService.processPayment(amount);
}
}
Now, PaymentProcessor only knows about PaymentService, not the specific payment service provider. This makes it easy to switch services and keeps our code flexible.
Why SOLID Principles Matter
Incorporating SOLID principles is like putting up guardrails in your code. They don’t just make things prettier; they help prevent bugs, make your code easier to work with and ensure that it can grow without turning into a tangled mess. By following SOLID principles, you’ll end up with code that’s simpler to understand and adapt?—?whether you’re working on it solo or as part of a big team.