SOLID Principles in Java Spring Boot
Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

Software Dev March 22, 2026 16 min read Clean Code Engineering Series

SOLID Principles in Java: Real-World Refactoring Patterns for Spring Boot Microservices

SOLID principles are not academic exercises — they are the difference between a codebase that scales gracefully and one that collapses under its own weight. In a microservices world where services evolve independently and teams work in parallel, violating SOLID leads to cascading failures, untestable services, and deployment anxiety. This guide walks through every principle with real Spring Boot code: the violation, the smell, and the refactored solution you can apply to your own codebase today.

Table of Contents

  1. The Problem: God Classes and Brittle Code in Microservices
  2. Single Responsibility Principle — Real Violation & Fix in Spring Boot Services
  3. Open/Closed Principle — Extending Without Modifying Payment Processors
  4. Liskov Substitution Principle — When Inheritance Goes Wrong
  5. Interface Segregation — The Fat Interface Problem in Repository Layers
  6. Dependency Inversion — Spring's IoC as SOLID in Action
  7. Production Failure: The God Service Anti-Pattern
  8. Trade-offs and When NOT to Over-Apply SOLID
  9. Key Takeaways

1. The Problem: God Classes and Brittle Code in Microservices

Every experienced Java developer has inherited a "God Class" — a service that does everything: validates input, calls external APIs, sends emails, updates the database, publishes events, and logs every step. In monoliths this is bad enough, but in microservices it is catastrophic. When a single class has 1,500 lines and 40 methods, every small feature request becomes a risky surgery that touches critical paths.

The root cause is almost always a violation of one or more SOLID principles. The five principles — Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — are a cohesive design system, not a checklist. Each principle addresses a distinct axis of change, and when all five are respected, the result is code that is easy to extend, test in isolation, and deploy independently. In Spring Boot microservices, SOLID maps directly to practical concerns: testability with @MockBean, extension through @Component strategies, and loose coupling through @Autowired interfaces.

Context: All examples target Java 17+ and Spring Boot 3.x. The same patterns apply to Spring Boot 2.7 with minor annotation differences.

2. Single Responsibility Principle — Real Violation & Fix in Spring Boot Services

The Single Responsibility Principle states that a class should have one — and only one — reason to change. "Reason to change" means one business actor: the marketing team changing email templates should not force a re-deployment of your order processing logic. In Spring Boot services, the most common SRP violation is an OrderService that simultaneously orchestrates the order lifecycle, validates payment, sends confirmation emails, updates inventory, and publishes domain events.

This violation means a change to the email template requires re-testing the entire order placement flow. A bug in payment validation can corrupt the email service. Unit tests become impossible without mocking six different collaborators. The fix is vertical decomposition: give each concern its own service, inject them into OrderService as dependencies, and let Spring manage the wiring.

// VIOLATION: OrderService doing too much
@Service
public class OrderService {
    public void placeOrder(Order order) {
        // Payment validation — belongs in PaymentValidator
        if (order.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Invalid amount");
        }
        // Database save — fine here
        orderRepository.save(order);
        // Email notification — belongs in EmailNotificationService
        String html = "<h1>Your order " + order.getId() + " is confirmed!</h1>";
        emailClient.send(order.getUserEmail(), "Order Confirmed", html);
        // Inventory update — belongs in InventoryService
        inventoryRepository.decrementStock(order.getProductId(), order.getQuantity());
    }
}
// FIX: Each class has one reason to change

@Service
public class PaymentValidator {
    public void validate(Order order) {
        if (order.getAmount().compareTo(BigDecimal.ZERO) <= 0)
            throw new IllegalArgumentException("Invalid payment amount");
    }
}

@Service
public class EmailNotificationService {
    private final JavaMailSender mailSender;
    public void sendOrderConfirmation(Order order) {
        SimpleMailMessage msg = new SimpleMailMessage();
        msg.setTo(order.getUserEmail());
        msg.setSubject("Order " + order.getId() + " Confirmed");
        msg.setText("Thank you for your order!");
        mailSender.send(msg);
    }
}

@Service
public class InventoryService {
    private final InventoryRepository inventoryRepository;
    public void decrementStock(UUID productId, int qty) {
        inventoryRepository.decrementStock(productId, qty);
    }
}

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentValidator paymentValidator;
    private final EmailNotificationService emailNotificationService;
    private final InventoryService inventoryService;

    public void placeOrder(Order order) {
        paymentValidator.validate(order);
        orderRepository.save(order);
        inventoryService.decrementStock(order.getProductId(), order.getQuantity());
        emailNotificationService.sendOrderConfirmation(order);
    }
}
SRP Test: If you can describe a class with the word "and" (e.g., "it validates AND sends emails AND updates inventory"), it has more than one responsibility. Split it.

3. Open/Closed Principle — Extending Without Modifying Payment Processors

The Open/Closed Principle states that software entities should be open for extension but closed for modification. In practice, this means that adding a new payment method — say, Apple Pay — should not require you to modify your existing PaymentProcessor class and risk breaking the VISA and Mastercard flows that are already running in production.

The classic violation is a chain of if/else or switch statements that branch on payment type. Every time business adds a new provider, a developer edits the same method, re-runs all regression tests, and deploys a new version. In high-traffic systems this is a deployment risk and a merge conflict hotspot. The OCP-compliant solution introduces a PaymentStrategy interface and registers each provider as a Spring @Component. Spring collects all implementations automatically, and the dispatcher selects the right one at runtime without any conditional logic.

// VIOLATION: switch on type — must be modified for every new provider
@Service
public class PaymentProcessor {
    public PaymentResult process(PaymentRequest request) {
        switch (request.getType()) {
            case "VISA":       return processVisa(request);
            case "MASTERCARD": return processMastercard(request);
            case "PAYPAL":     return processPaypal(request);
            default: throw new UnsupportedOperationException("Unknown type: " + request.getType());
        }
    }
}
// FIX: Strategy interface — add providers without touching existing code
public interface PaymentStrategy {
    String supportedType();
    PaymentResult process(PaymentRequest request);
}

@Component
public class VisaPaymentStrategy implements PaymentStrategy {
    @Override public String supportedType() { return "VISA"; }
    @Override public PaymentResult process(PaymentRequest request) {
        // VISA-specific logic
        return PaymentResult.success("VISA-" + UUID.randomUUID());
    }
}

@Component
public class PaypalPaymentStrategy implements PaymentStrategy {
    @Override public String supportedType() { return "PAYPAL"; }
    @Override public PaymentResult process(PaymentRequest request) {
        // PayPal REST SDK call
        return PaymentResult.success("PP-" + UUID.randomUUID());
    }
}

@Service
public class PaymentProcessor {
    private final Map<String, PaymentStrategy> strategies;

    public PaymentProcessor(List<PaymentStrategy> strategyList) {
        this.strategies = strategyList.stream()
            .collect(Collectors.toMap(PaymentStrategy::supportedType, s -> s));
    }

    public PaymentResult process(PaymentRequest request) {
        PaymentStrategy strategy = strategies.get(request.getType());
        if (strategy == null) throw new UnsupportedPaymentTypeException(request.getType());
        return strategy.process(request);
    }
}
Adding Apple Pay: Simply create @Component public class ApplePayStrategy implements PaymentStrategy. Spring auto-discovers it. Zero changes to PaymentProcessor.

4. Liskov Substitution Principle — When Inheritance Goes Wrong

The Liskov Substitution Principle states that objects of a subclass should be substitutable for objects of their superclass without altering the correctness of the program. In simpler terms: if your code works with a Bird, it should work equally well when given a Penguin (if Penguin extends Bird). The moment a subclass weakens a precondition, strengthens a postcondition, or throws an exception for a method defined by the supertype, you have an LSP violation.

The canonical Java example is a Bird base class with a fly() method, and a Penguin subclass that overrides fly() by throwing UnsupportedOperationException. Any code iterating over a List<Bird> and calling fly() will blow up at runtime. The fix is to model capability through interfaces rather than inheritance hierarchies, keeping the type system honest.

// VIOLATION: Penguin cannot fly — LSP broken
public class Bird {
    public void fly() { System.out.println("Flying..."); }
}
public class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins cannot fly!");
    }
}
// This crashes at runtime:
List<Bird> birds = List.of(new Eagle(), new Penguin());
birds.forEach(Bird::fly); // UnsupportedOperationException!
// FIX: Model capability with interfaces
public interface Flyable {
    void fly();
}
public interface Swimmable {
    void swim();
}

public abstract class Bird {
    public abstract void makeSound();
}

public class Eagle extends Bird implements Flyable {
    @Override public void makeSound() { System.out.println("Screech"); }
    @Override public void fly() { System.out.println("Eagle soaring"); }
}

public class Penguin extends Bird implements Swimmable {
    @Override public void makeSound() { System.out.println("Squawk"); }
    @Override public void swim() { System.out.println("Penguin swimming"); }
}

// Now LSP-safe — only Flyable birds get fly() called:
List<Flyable> flyingBirds = List.of(new Eagle(), new Hawk());
flyingBirds.forEach(Flyable::fly); // always safe
Spring Boot LSP Example: An ReadOnlyUserRepository extending JpaRepository but throwing on save() and delete() is a real production LSP violation. Use separate read/write interfaces instead.

5. Interface Segregation — The Fat Interface Problem in Repository Layers

The Interface Segregation Principle states that clients should not be forced to depend on interfaces they do not use. A fat interface forces every implementing class to provide stub implementations for methods it doesn't need, creates unnecessary coupling, and makes mocking in tests cumbersome — you have to mock 15 methods when you only need 2.

In repository layers this manifests as a single UserRepository interface that mixes read operations (findById, findByEmail, search, countActiveUsers, getTopSpenders) with write operations (save, update, delete, bulkDelete, archive). A read-only analytics service that only needs query methods is forced to depend on destructive write operations it will never call. The ISP fix splits the interface along actual usage patterns, resulting in UserQueryRepository and UserCommandRepository. Each consumer depends only on what it uses.

// VIOLATION: Fat interface — analytics service forced to depend on delete/archive
public interface UserRepository {
    Optional<User> findById(UUID id);
    Optional<User> findByEmail(String email);
    List<User> findActiveUsers();
    List<User> searchByName(String name);
    long countActiveUsers();
    List<User> getTopSpenders(int limit);
    User save(User user);
    User update(User user);
    void deleteById(UUID id);
    void bulkDelete(List<UUID> ids);
    void archiveUser(UUID id);
}
// FIX: Segregated interfaces
public interface UserQueryRepository {
    Optional<User> findById(UUID id);
    Optional<User> findByEmail(String email);
    List<User> findActiveUsers();
    List<User> searchByName(String name);
    long countActiveUsers();
    List<User> getTopSpenders(int limit);
}

public interface UserCommandRepository {
    User save(User user);
    User update(User user);
    void deleteById(UUID id);
    void bulkDelete(List<UUID> ids);
    void archiveUser(UUID id);
}

// JPA implementation implements both:
@Repository
public class UserJpaRepository implements UserQueryRepository, UserCommandRepository {
    // ... full implementation
}

// Analytics service only depends on what it needs:
@Service
@RequiredArgsConstructor
public class UserAnalyticsService {
    private final UserQueryRepository userQueryRepository; // no access to delete/archive
}

// Admin service gets write access:
@Service
@RequiredArgsConstructor
public class UserAdminService {
    private final UserQueryRepository userQueryRepository;
    private final UserCommandRepository userCommandRepository;
}

6. Dependency Inversion — Spring's IoC as SOLID in Action

The Dependency Inversion Principle has two rules: high-level modules should not depend on low-level modules — both should depend on abstractions; and abstractions should not depend on details, but details should depend on abstractions. Spring Framework is essentially a Dependency Inversion machine. Every time you inject an interface via @Autowired or constructor injection, you are applying DIP automatically.

The violation is a service class that directly instantiates its dependencies with new, or uses concrete class types in injection points. This makes the class impossible to unit test without the actual infrastructure, and means swapping an implementation (e.g., from MySQL to MongoDB) requires changing business logic code. Spring's @Service, @Repository, and @Component annotations with interface-based injection is the canonical DIP pattern in the Java ecosystem.

// VIOLATION: High-level service depends on concrete low-level class
public class NotificationService {
    // Direct instantiation — impossible to test, impossible to swap
    private final SmtpEmailSender emailSender = new SmtpEmailSender("smtp.example.com", 587);

    public void notify(String userId, String message) {
        emailSender.send(userId, message);
    }
}
// FIX: Both high-level and low-level depend on abstraction
public interface MessageSender {
    void send(String recipient, String message);
}

@Component("emailSender")
public class SmtpEmailSender implements MessageSender {
    @Override
    public void send(String recipient, String message) {
        // SMTP implementation
    }
}

@Component("smsSender")
public class TwilioSmsSender implements MessageSender {
    @Override
    public void send(String recipient, String message) {
        // Twilio SMS API call
    }
}

// High-level module depends ONLY on the abstraction
@Service
@RequiredArgsConstructor
public class NotificationService {
    private final MessageSender messageSender; // injected by Spring

    public void notify(String userId, String message) {
        messageSender.send(userId, message);
    }
}

// In tests: inject a mock — zero infrastructure needed
@ExtendWith(MockitoExtension.class)
class NotificationServiceTest {
    @Mock MessageSender messageSender;
    @InjectMocks NotificationService notificationService;

    @Test
    void shouldSendNotification() {
        notificationService.notify("user123", "Your order shipped");
        verify(messageSender).send("user123", "Your order shipped");
    }
}
Spring IoC and DIP: Spring's entire ApplicationContext is built on DIP. Use @Qualifier when multiple implementations exist, or @Primary to designate a default. Constructor injection is preferred over field injection for better testability and immutability.

7. Production Failure: The God Service Anti-Pattern

A real-world incident pattern that illustrates all five SOLID violations at once: an e-commerce platform's OrderManagementService grew from 200 lines at launch to 3,400 lines over 18 months. It handled order placement, cancellation, refunds, shipping updates, fraud detection, loyalty points, email notifications, and push notifications. When the email provider changed their API, three engineers spent four days carefully dissecting which of the 40 methods touched email logic — and they still missed one, causing a null pointer exception in production on deployment day.

The blast radius: 2.5 hours of checkout being unavailable for 40% of users (email sending was synchronous and blocking the order save transaction). The root cause was technical debt compounded across all five SOLID axes simultaneously. The fix required a six-sprint refactoring project: extracting eight separate services, introducing event-driven communication via Spring ApplicationEvents, and establishing clear domain boundaries. Test coverage jumped from 34% to 87% after the refactoring, and subsequent feature additions that had previously taken weeks dropped to days.

Warning Signal: If your service class requires more than 5 constructor arguments (even after Spring injection), it is almost certainly violating SRP and needs decomposition. This is a practical SRP metric from production codebases.

8. Trade-offs and When NOT to Over-Apply SOLID

SOLID principles, taken to the extreme, produce their own pathology: over-engineered abstractions that make simple code difficult to follow. A CRUD service that will never have a second implementation does not benefit from an additional interface layer. A tiny utility class with two related helper methods does not need to be split in two to satisfy a strict reading of SRP. The principle of "you aren't gonna need it" (YAGNI) is a valid counterweight.

Apply OCP aggressively when you know a category of things will grow — payment types, notification channels, report formats. Skip it for truly one-off business rules. Apply SRP when you see test setup getting complex or when a class is the source of frequent merge conflicts. Skip it for small, stable classes. ISP matters most in public API design and when writing shared libraries; for a small internal service, one well-sized repository interface may be perfectly fine. The goal is changeability and testability, not pattern purity for its own sake.

Rule of Three: Apply SOLID extraction the second time you see a pattern, not the first. The third occurrence confirms the abstraction is real and worth the investment in an interface or a new class.

9. Key Takeaways

SOLID principles form a coherent design vocabulary for Java and Spring Boot teams. Applied with judgment, they produce systems where adding a new payment gateway is a four-line file, where swapping an email provider requires zero changes to business logic, and where unit tests run in milliseconds with no infrastructure. The key is treating them as guidelines that sharpen your design sense, not as rules that must be mechanically applied to every class in your codebase.

Discussion / Comments

Related Posts

Software Dev

Clean Architecture

Organize code into independent layers for testable, maintainable systems.

Software Dev

Hexagonal Architecture

Ports and adapters pattern to decouple business logic from infrastructure.

Software Dev

Code Review Culture

Build a culture of effective code reviews that improve quality and team skills.

Last updated: March 2026 — Written by Md Sanwar Hossain