Software Dev

SOLID Principles: A Complete Engineering Guide with Anti-Patterns & Trade-offs

SOLID principles are the backbone of maintainable object-oriented design — but blindly applying all five in every context is just as harmful as ignoring them. This guide goes beyond textbook definitions: you will see real anti-patterns, understand the behavioral contracts behind each principle, and learn exactly when applying SOLID creates value versus when it creates unnecessary complexity.

Md Sanwar Hossain April 10, 2026 18 min read Software Dev
SOLID Principles Engineering Guide

TL;DR

SOLID principles are design guidelines, not laws. Apply them to reduce change friction; skip them when they add needless complexity. In microservices, SRP maps to bounded context, DIP maps to API contracts, and OCP maps to event-driven extension points.

Table of Contents

  1. Why SOLID Still Matters in 2026
  2. Single Responsibility Principle — One Reason to Change
  3. Open/Closed Principle — Extend Without Modifying
  4. Liskov Substitution Principle — The Behavioral Contract
  5. Interface Segregation Principle — Fat Interfaces Are Debt
  6. Dependency Inversion Principle — Depend on Abstractions
  7. SOLID in Microservices Architecture
  8. Common SOLID Mistakes Senior Engineers Make
  9. When NOT to Apply SOLID
  10. Interview Insights
  11. FAQ
  12. Conclusion & Key Takeaways

1. Why SOLID Still Matters in 2026

The word "SOLID" is thrown around in every Java interview, but its real value is rarely articulated beyond acronym recitation. The genuine cost of SOLID violations shows up as untestable God services that require 14 mocks in a unit test, deployment coupling where a one-line change in a utility triggers a full regression cycle, and perpetual merge conflict hotspots where three teams edit the same file simultaneously.

Research from Google's internal code health programme found that files with poor separation of concerns were 3.5× more likely to contain bugs than well-separated equivalents. The table below captures the practical engineering difference between compliant and non-compliant code.

Trait SOLID-Compliant Code SOLID-Violated Code
Test setup Single @MockBean 8+ @MockBean mocks
Deployment risk Low — 1 class changed High — cascading deps
Team conflicts Rare Constant merge conflicts
Onboarding time Hours Days to weeks
SOLID Principles Complete Guide — S O L I D Diagram
SOLID Principles — Engineering for Change — mdsanwarhossain.me

2. Single Responsibility Principle — One Reason to Change

Robert Martin's precise definition is often misquoted. SRP does not mean "a class does only one thing." It means a class has only one reason to change, where "reason" corresponds to a business actor — the person or team that requests the change. An authentication team, a profile team, and a notifications team are three distinct actors. Code that serves all three in one class will change whenever any of those teams has a requirement update.

// BAD: UserManager serves three business actors
@Service
public class UserManager {
    // Auth actor
    public void login(String email, String password) { /* ... */ }
    public void resetPassword(String email) { /* ... */ }
    // Profile actor
    public void updateProfile(Long userId, ProfileDto dto) { /* ... */ }
    public void uploadAvatar(Long userId, MultipartFile file) { /* ... */ }
    // Notifications actor
    public void sendWelcomeEmail(User user) { /* ... */ }
    public void sendPasswordResetEmail(String email) { /* ... */ }
}

// GOOD: Three services, each owned by one actor
@Service
public class AuthService {
    public void login(String email, String password) { /* ... */ }
    public void resetPassword(String email) { /* ... */ }
}

@Service
public class UserProfileService {
    public void updateProfile(Long userId, ProfileDto dto) { /* ... */ }
    public void uploadAvatar(Long userId, MultipartFile file) { /* ... */ }
}

@Service
public class UserNotificationService {
    public void sendWelcomeEmail(User user) { /* ... */ }
    public void sendPasswordResetEmail(String email) { /* ... */ }
}
Engineering insight: In a CI/CD pipeline, if three teams deploy independently, each service can be tested, containerised, and released without touching the others. A single UserManager creates an implicit deployment coupling between all three teams.

3. Open/Closed Principle — Extend Without Modifying

Software entities should be open for extension but closed for modification. The practical target is code that evolves through addition of new classes rather than editing of existing ones. Every time you edit an existing class to support a new case, you risk breaking the existing cases that already work and are already tested.

// BAD: adding Apple Pay requires editing PaymentService
@Service
public class PaymentService {
    public void process(String type, PaymentRequest request) {
        if ("VISA".equals(type)) { /* visa logic */ }
        else if ("PAYPAL".equals(type)) { /* paypal logic */ }
        else if ("STRIPE".equals(type)) { /* stripe logic */ }
        // Must edit here to add Apple Pay — risky!
    }
}

// GOOD: strategy pattern — adding Apple Pay = new class only
public interface PaymentStrategy {
    boolean supports(String type);
    void process(PaymentRequest request);
}

@Component
public class VisaPaymentStrategy implements PaymentStrategy {
    @Override public boolean supports(String type) { return "VISA".equals(type); }
    @Override public void process(PaymentRequest request) { /* visa logic */ }
}

@Component
public class StripePaymentStrategy implements PaymentStrategy {
    @Override public boolean supports(String type) { return "STRIPE".equals(type); }
    @Override public void process(PaymentRequest request) { /* stripe logic */ }
}

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

    public PaymentService(List<PaymentStrategy> strategyList) {
        this.strategies = strategyList.stream()
            .collect(Collectors.toMap(s -> s.getClass().getSimpleName(), s -> s));
    }

    public void process(String type, PaymentRequest request) {
        strategies.values().stream()
            .filter(s -> s.supports(type))
            .findFirst()
            .orElseThrow(() -> new UnsupportedPaymentTypeException(type))
            .process(request);
    }
}

Spring automatically discovers all @Component-annotated strategies and injects them as a List into the service. Adding Apple Pay is a new class with zero changes to the dispatcher — the textbook definition of OCP in action.

4. Liskov Substitution Principle — The Behavioral Contract

LSP states that objects of a subclass must be substitutable for objects of the superclass without altering the correctness of the program. This is a behavioral contract, not just a syntactic one. A subclass can weaken preconditions (accept more) and strengthen postconditions (guarantee more), but it must never narrow what the base class promised.

// BAD: ReadOnlyList extends ArrayList but breaks add() contract
public class ReadOnlyList<T> extends ArrayList<T> {
    @Override
    public boolean add(T element) {
        throw new UnsupportedOperationException("Read-only list");
        // Callers expecting ArrayList.add() to work are now broken!
    }
}

// GOOD: separate interface hierarchy — no broken contract
public interface ReadableList<T> {
    T get(int index);
    int size();
    List<T> toList();
}

public interface WritableList<T> extends ReadableList<T> {
    void add(T element);
    void remove(int index);
}

// Spring Boot: read/write repository split
public interface OrderReadRepository {
    Optional<Order> findById(Long id);
    List<Order> findByCustomerId(Long customerId);
}

public interface OrderWriteRepository {
    Order save(Order order);
    void delete(Long id);
}

@Repository
public class JpaOrderRepository implements OrderReadRepository, OrderWriteRepository {
    // full JPA implementation
}
Interview trap: Interviewers often describe the classic Square/Rectangle problem. A Square that overrides setWidth to also set height breaks every piece of code that calls setWidth and expects only width to change. The fix is an immutable Shape hierarchy, not inheritance.

5. Interface Segregation Principle — Fat Interfaces Are Debt

ISP states that no client should be forced to depend on methods it does not use. Fat interfaces force implementors to provide empty or stub implementations for irrelevant methods, creating noise and a maintenance burden. The solution is interface decomposition: small, cohesive role interfaces that clients compose as needed.

// BAD: IUserRepository forces 20 methods on every implementor
public interface IUserRepository {
    User findById(Long id);
    List<User> findAll();
    User save(User user);
    void deleteById(Long id);
    List<User> searchByName(String name);
    List<User> searchByEmail(String email);
    long countActiveUsers();
    List<User> findByRole(String role);
    // 12 more methods...
    // A read-only cache store must stub 15 of these with UnsupportedOperationException!
}

// GOOD: split into focused role interfaces
public interface UserReader {
    Optional<User> findById(Long id);
    List<User> findAll();
}

public interface UserWriter {
    User save(User user);
    void deleteById(Long id);
}

public interface UserSearcher {
    List<User> searchByName(String name);
    List<User> searchByEmail(String email);
    long countActiveUsers();
    List<User> findByRole(String role);
}

// A read-model query service only depends on the interface it actually uses
@Service
public class UserQueryService {
    private final UserReader userReader;
    private final UserSearcher userSearcher;

    public UserQueryService(UserReader userReader, UserSearcher userSearcher) {
        this.userReader = userReader;
        this.userSearcher = userSearcher;
    }
    // no dependency on UserWriter at all
}

6. Dependency Inversion Principle — Depend on Abstractions

High-level modules should not depend on low-level modules. Both should depend on abstractions. This is the principle that Spring's entire IoC container is built on. When OrderService directly instantiates EmailClient, it is glued to that specific implementation — you cannot swap it for an SMS sender, a Slack notifier, or a test double without modifying OrderService.

// BAD: high-level module hardwires its dependency
@Service
public class OrderService {
    private final EmailClient emailClient = new EmailClient(); // direct instantiation!

    public void placeOrder(Order order) {
        processOrder(order);
        emailClient.sendConfirmation(order); // impossible to swap or mock
    }
}

// GOOD: depend on an abstraction; Spring injects the right implementation
public interface NotificationService {
    void sendOrderConfirmation(Order order);
}

@Component
public class EmailNotificationService implements NotificationService {
    @Override
    public void sendOrderConfirmation(Order order) {
        // SMTP send logic
    }
}

@Component
public class SmsNotificationService implements NotificationService {
    @Override
    public void sendOrderConfirmation(Order order) {
        // SMS send logic
    }
}

@Service
public class OrderService {
    private final NotificationService notificationService;

    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void placeOrder(Order order) {
        processOrder(order);
        notificationService.sendOrderConfirmation(order);
    }
}

// @Configuration showing DIP via IoC
@Configuration
public class NotificationConfig {
    @Bean
    @ConditionalOnProperty(name = "notification.channel", havingValue = "email")
    public NotificationService emailNotificationService() {
        return new EmailNotificationService();
    }

    @Bean
    @ConditionalOnProperty(name = "notification.channel", havingValue = "sms")
    public NotificationService smsNotificationService() {
        return new SmsNotificationService();
    }
}

7. SOLID in Microservices Architecture

SOLID was conceived for class-level design, but its mental models translate directly to service-level architecture. Understanding these mappings helps architects justify microservice boundaries using familiar design vocabulary.

SRP  →  Bounded Context / Single Business Capability per Service
         Each microservice owns one business domain and deploys independently.

OCP  →  Event-Driven Architecture
         Services publish domain events. New consumers extend behavior
         without modifying the producer. Zero redeployment of the source.

LSP  →  Service Contract Compatibility
         Consumers must work with any compatible version of a service.
         Semantic versioning + backward-compatible API changes enforce LSP.

ISP  →  Backend for Frontend (BFF) Pattern
         Expose only what each consumer needs. A mobile BFF returns lean
         payloads; a dashboard BFF returns aggregated views.

DIP  →  API Contracts & Service Mesh
         Services depend on API contracts (OpenAPI specs), not on
         concrete service implementations. Istio/Envoy provides the
         "injection" layer at the network level.
SOLID Principle Microservice Equivalent Pattern / Tool
SRP Single bounded context Domain-Driven Design
OCP Event-driven extension Kafka / EventBridge
LSP API backward compatibility Semantic versioning
ISP Consumer-specific APIs BFF pattern, GraphQL
DIP Depend on contracts OpenAPI, service mesh

8. Common SOLID Mistakes Senior Engineers Make

Even experienced engineers make predictable SOLID misapplication errors. These mistakes tend to create the appearance of good design while adding friction:

9. When NOT to Apply SOLID

SOLID is a cost-benefit trade-off. The cost is indirection, abstraction layers, and more files to navigate. The benefit is reduced change friction over time. When code is short-lived, never changes, or lives in an isolated script, the cost exceeds the benefit.

Scenario SOLID Recommendation
Throwaway prototype Skip SOLID — it will be rewritten
<500 line script Skip — over-engineering risk
Stable, never-changed utility Skip OCP — YAGNI applies
Microservice boundary Apply SRP + DIP strongly
Domain-rich service Apply all 5 principles
High-throughput data pipeline Profile first; abstraction layers have overhead

10. Interview Insights

Q: What is the difference between SRP and ISP?

A: SRP is about classes having one reason to change — it focuses on the cohesion of a class relative to a business actor. ISP is about clients not being forced to depend on methods they do not use — it focuses on the granularity of interfaces presented to callers. SRP drives class decomposition; ISP drives interface decomposition. Both aim for cohesion but from different angles.

Q: How does Spring Boot implement DIP?

A: Spring's IoC container is a direct implementation of DIP. Classes declare dependencies on interfaces, not on concrete implementations. The container resolves the right @Bean or @Component at startup and injects it via constructor injection. @Autowired, @Qualifier, and @ConditionalOnProperty are all mechanisms for controlling which abstraction implementation gets wired in.

Q: What is a practical violation of LSP?

A: The most common production LSP violation is a subclass that throws UnsupportedOperationException for methods inherited from its parent. Any caller iterating a List and calling add() expects it to work — if the concrete type is a read-only list that throws, the caller is broken without any warning from the type system. The correct fix is to separate the readable and writable hierarchies at the interface level.

Q: Is OCP always beneficial?

A: No. For stable code that will never change, OCP adds needless abstractions. If a discount calculator has been unchanged for two years and has no business requirement for extension, wrapping it in a strategy pattern adds three files and zero value. OCP pays dividends only when the cost of future modification is real and foreseeable. Apply YAGNI first, OCP when the extension point becomes concrete.

11. FAQ

What is the SOLID principle in Java?

SOLID is an acronym coined by Robert C. Martin representing five object-oriented design principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. In Java, they guide class structure, interface design, and dependency management to produce code that is easy to test, extend, and maintain over time.

Which SOLID principle is most important?

Most senior engineers cite DIP as the most impactful because it enables testability and flexibility at the highest level. Without DIP, you cannot effectively apply the others. However, SRP is usually the first to apply because a class with a single responsibility naturally leads to smaller interfaces (ISP), cleaner inheritance (LSP), and well-focused extension points (OCP).

How do SOLID principles relate to design patterns?

Design patterns are concrete implementations of SOLID principles. Strategy implements OCP. Factory and Builder implement DIP. Decorator implements OCP. Template Method implements LSP. Adapter implements ISP. Understanding which SOLID principle a pattern implements helps you choose the right pattern for the right problem rather than applying them by rote.

Can SOLID principles cause over-engineering?

Absolutely. Applying SOLID without regard for context produces unnecessary abstraction layers, dozens of tiny one-method interfaces, and complex dependency graphs for simple problems. The remedy is to combine SOLID with YAGNI (You Aren't Gonna Need It) and KISS (Keep It Simple, Stupid). Introduce abstractions only when the need is real or imminent, not speculative.

12. Conclusion & Key Takeaways

SOLID principles are among the most enduring ideas in software engineering because they map directly onto the real costs of maintaining large codebases over time. Mastering them means knowing not only how to apply them, but when applying them creates more value than the abstraction cost they introduce.

SOLID Trade-offs and When to Apply
SOLID Trade-offs and When to Apply — mdsanwarhossain.me

Leave a Comment

Related Posts

Software Dev

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

Real-world refactoring walkthroughs applying all five SOLID principles in production Spring Boot services.

Software Dev

Code Smells & Refactoring in Java: Detecting and Fixing Anti-Patterns

Systematic approach to identifying and eliminating the most dangerous code smells in Java codebases.

Software Dev

Java Design Patterns in Production: Strategy, Factory & Builder

Concrete design patterns as implementations of SOLID principles in production Java systems.

Software Dev

Clean Code in Java & Spring Boot: Naming, Functions & SOLID Applied

Apply clean code practices alongside SOLID for readable, maintainable Spring Boot codebases.

Md Sanwar Hossain
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

All Posts
Back to Blog
Last updated: April 10, 2026