Sitemap
Prithukathet

Prithukathet

Software Engineer

SOLID Principles for Developers

7 min readNov 26, 2025

--

Press enter or click to view image in full size

As developers, we’ve all encountered that codebase, the one where every change feels like walking through a minefield, where adding a simple feature requires modifying dozens of classes, and where fixing one bug creates two new ones. What if I told you there’s a better way? Enter SOLID principles, the foundation of maintainable, scalable object-oriented design.

What are SOLID Principles?

SOLID is an acronym coined by Robert C. Martin (Uncle Bob) that represents five fundamental principles for writing clean, maintainable software. While these principles apply to any object-oriented language, they’re particularly crucial for Java developers working on enterprise-scale applications.

Let me show you how these principles can transform your code from brittle to brilliant.

1. Single Responsibility Principle (SRP): The Focused Specialist

The Problem: The “God Class” Anti-Pattern

We’ve all seen (and probably written) classes like this:

public class EmployeeService {
// Handles business logic
public void calculateSalary(Employee emp) { /* ... */ }
public void promoteEmployee(Employee emp) { /* ... */ }

// Handles database operations
public void saveEmployee(Employee emp) { /* ... */ }
public Employee findEmployeeById(Long id) { /* ... */ }

// Handles notification logic
public void sendEmailNotification(Employee emp, String message) { /* ... */ }
public void generateReport(Employee emp) { /* ... */ }
}

This class has too many reasons to change. If the database schema changes, if email templates change, or if business logic changes — each modification touches the same class.

The SRP Solution: Separation of Concerns

// Business logic
@Service
public class EmployeeService {
private final EmployeeRepository repository;
private final NotificationService notification;

public void promoteEmployee(Employee emp) {
// Business logic here
repository.save(emp);
notification.sendPromotionNotification(emp);
}
}

// Data access
@Repository
public class EmployeeRepository {
public void save(Employee emp) { /* JPA implementation */ }
public Employee findById(Long id) { /* JPA implementation */ }
}

// Notification handling
@Service
public class NotificationService {
public void sendPromotionNotification(Employee emp) { /* Email logic */ }
}

Real-World Benefit: When your company switches from SMTP to a new notification service, only the NotificationService needs modification. Your business logic and data access layers remain untouched.

2. Open/Closed Principle (OCP): The Extensible Foundation

The Problem: The Never-Ending If-Else Chain

public class DiscountCalculator {
public double calculateDiscount(Customer customer, double amount) {
if (customer.getType().equals("REGULAR")) {
return amount * 0.1;
} else if (customer.getType().equals("VIP")) {
return amount * 0.2;
} else if (customer.getType().equals("PREMIUM")) {
return amount * 0.3;
}
// What about new customer types?
return 0;
}
}

Every new customer type requires modifying this class, violating the “closed for modification” part of OCP.

The OCP Solution: Strategy Pattern with Interfaces

public interface DiscountStrategy {
double calculateDiscount(double amount);
boolean supports(CustomerType type);
}

@Component
public class RegularDiscount implements DiscountStrategy {
public double calculateDiscount(double amount) { return amount * 0.1; }
public boolean supports(CustomerType type) { return type == CustomerType.REGULAR; }
}

@Component
public class VipDiscount implements DiscountStrategy {
public double calculateDiscount(double amount) { return amount * 0.2; }
public boolean supports(CustomerType type) { return type == CustomerType.VIP; }
}

@Service
public class DiscountCalculator {
private final List<DiscountStrategy> strategies;

public DiscountCalculator(List<DiscountStrategy> strategies) {
this.strategies = strategies;
}

public double calculateDiscount(Customer customer, double amount) {
return strategies.stream()
.filter(strategy -> strategy.supports(customer.getType()))
.findFirst()
.map(strategy -> strategy.calculateDiscount(amount))
.orElse(0.0);
}
}

Real-World Benefit: Adding a new PremiumDiscount strategy? Just create a new class implementing DiscountStrategy and Spring will automatically inject it. No changes to existing code!

3. Liskov Substitution Principle (LSP): The Behavioral Contract

The Problem: Square-Rectangle Dilemma

public 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 getArea() { return width * height; }
}

public class Square extends Rectangle {
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width); // Violates rectangle behavior!
}

@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height); // Violates rectangle behavior!
}
}

// This breaks LSP - you can't substitute Square for Rectangle
public void testRectangle(Rectangle rectangle) {
rectangle.setWidth(5);
rectangle.setHeight(4);
assert rectangle.getArea() == 20; // Fails for Square!
}

The LSP Solution: Proper Inheritance Hierarchy

public interface Shape {
int getArea();
}

public class Rectangle implements Shape {
private int width;
private int height;

// constructors, getters, setters
public int getArea() { return width * height; }
}

public class Square implements Shape {
private int side;

// constructor, getter, setter
public int getArea() { return side * side; }
}

Real-World Benefit: Your code becomes predictable. Any method accepting a Shape can safely work with any implementation without surprises.

4. Interface Segregation Principle (ISP): The Focused Contract

The Problem: The “Kitchen Sink” Interface

public interface Worker {
void code();
void test();
void deploy();
void design();
void holdMeeting();
void writeDocumentation();
}

public class Developer implements Worker {
public void code() { /* OK */ }
public void test() { /* OK */ }
public void deploy() { /* OK */ }
public void design() { /* Not my job! */ }
public void holdMeeting() { /* Not my job! */ }
public void writeDocumentation() { /* Ugh */ }
}

Developers are forced to implement methods they don’t need or use.

Get Prithukathet’s stories in your inbox

Join Medium for free to get updates from this writer.

The ISP Solution: Role-Specific Interfaces

public interface Coder {
void code();
}

public interface Tester {
void test();
}

public interface DeploymentSpecialist {
void deploy();
}

public interface Designer {
void design();
}

// Developers can implement only what they need
public class Developer implements Coder, Tester, DeploymentSpecialist {
public void code() { /* */ }
public void test() { /* */ }
public void deploy() { /* */ }
}

public class Architect implements Designer, Coder {
public void design() { /* */ }
public void code() { /* */ }
}

Real-World Benefit: Your interfaces become more descriptive, and classes only depend on methods they actually use. This is particularly valuable when working with large teams and microservices.

5. Dependency Inversion Principle (DIP): The Decoupling Magic

The Problem: Tight Coupling

public class OrderService {
private final MySQLOrderRepository repository = new MySQLOrderRepository();
private final SmtpNotifier notifier = new SmtpNotifier();

public void processOrder(Order order) {
repository.save(order);
notifier.sendConfirmation(order);
}
}

This class is tightly coupled to specific implementations, making testing difficult and changes painful.

The DIP Solution: Dependency Injection

public interface OrderRepository {
void save(Order order);
}

public interface Notifier {
void sendConfirmation(Order order);
}

@Service
public class OrderService {
private final OrderRepository repository;
private final Notifier notifier;

public OrderService(OrderRepository repository, Notifier notifier) {
this.repository = repository;
this.notifier = notifier;
}

public void processOrder(Order order) {
repository.save(order);
notifier.sendConfirmation(order);
}
}

// Spring Configuration
@Configuration
public class AppConfig {
@Bean
public OrderRepository orderRepository() {
return new JpaOrderRepository(); // Easy to switch to MongoDB later
}

@Bean
public Notifier notifier() {
return new EmailNotifier(); // Easy to switch to Slack notifier
}
}

Real-World Benefit:

  • Testing becomes trivial: You can easily mock dependencies
  • Flexibility: Switching databases or notification services requires minimal changes
  • Spring Integration: This principle is why Spring’s dependency injection works so well

Putting It All Together: A SOLID Java Application

Let’s see how these principles work together in a typical Spring Boot service:

// ISP: Focused interfaces
public interface PaymentProcessor {
PaymentResult process(PaymentRequest request);
}

public interface PaymentValidator {
ValidationResult validate(PaymentRequest request);
}

// SRP: Each class has single responsibility
@Service
public class CreditCardProcessor implements PaymentProcessor {
private final PaymentValidator validator;

public CreditCardProcessor(@Qualifier("creditCardValidator") PaymentValidator validator) {
this.validator = validator; // DIP: Depends on abstraction
}

public PaymentResult process(PaymentRequest request) {
// Implementation
}
}

// OCP: Easy to add new payment methods
@Service
public class PayPalProcessor implements PaymentProcessor {
private final PaymentValidator validator;

public PayPalProcessor(@Qualifier("paypalValidator") PaymentValidator validator) {
this.validator = validator;
}

public PaymentResult process(PaymentRequest request) {
// Implementation
}
}

// LSP: All validators are substitutable
@Service
public class CreditCardValidator implements PaymentValidator {
public ValidationResult validate(PaymentRequest request) {
// Credit card specific validation
}
}

@Service
public class PayPalValidator implements PaymentValidator {
public ValidationResult validate(PaymentRequest request) {
// PayPal specific validation
}
}

The Business Impact of SOLID Principles

Why should your team care about SOLID principles?

  1. Reduced Bug Count: Changes are localized, reducing unexpected side effects
  2. Faster Development: New features can be added without modifying existing code
  3. Easier Testing: Dependencies can be mocked, making unit tests simpler
  4. Better Team Collaboration: Clear boundaries between components
  5. Smoother Onboarding: Code is self-documenting through its structure

Getting Started with SOLID

  1. Start Small: Refactor one class at a time
  2. Use Spring’s Help: Leverage @Service, @Repository, and @Component
  3. Embrace Interfaces: Program to interfaces, not implementations
  4. Think Before Inheriting: Ask “is this really an ‘is-a’ relationship?”
  5. Review Regularly: Conduct SOLID-focused code reviews

Conclusion

SOLID principles aren’t about perfection — they’re about progress. They guide us toward code that can evolve gracefully as requirements change. In the fast-paced world of Java development, where microservices, cloud deployment, and continuous delivery are the norms, SOLID principles provide the foundation for building software that can stand the test of time.

The next time you’re about to create that “quick fix” that violates these principles, ask yourself: will this make the code easier or harder to work with six months from now? Your future self — and your teammates — will thank you for choosing SOLID.

References:

https://www.geeksforgeeks.org/system-design/solid-principle-in-programming-understand-with-real-life-examples/

--

--