SOLID Principles in Production: From Code Smells to Architectural Discipline
Key Takeaways
- →SRP violation: a controller that grows to handle validation, pricing, payment, persistence, events, and audit — every change touches six unrelated concerns, slowing review and ballooning the test surface
- →OCP violation: growing if-else chains for notification channels; every new channel means editing NotificationService and retesting all existing channels — use strategy injection instead
- →ISP violation: interfaces force implementations to depend on methods they never call; split OrderRepository into Read, Write, Admin interfaces so consumers depend only on what they use
- →DIP violation: tests require infrastructure (Stripe, database) because business logic hardcodes dependencies; inject PaymentGateway interface so tests run without real services
The
OrderController-from-hell production pattern. One controller validates requests, checks permissions, applies business rules, calls the payment gateway, writes to multiple database tables, publishes Kafka events, sends email notifications, and logs audit records. Every developer on the team has touched it. A "small" tax-calculation fix takes days to review and weeks to QA — not because the change is complex, but because six unrelated concerns are tangled together. We debugged this exact incident on multiple production codebases.
This is how codebases rot: from missing constraints. SOLID — coined by Robert C. Martin in his 2000 essay on design principles[Martin, 2000] — is five specific design rules that prevent the coupling patterns that make code expensive to change. Each principle targets one way that coupling creeps in.
Five design constraints that prevent codebase rot[Martin, 2000]: (1) SRP — one class, one reason to change; (2) OCP — extend via new code, not by modifying existing code; (3) LSP — subtypes honor parent contracts; (4) ISP — split interfaces by consumer; (5) DIP — depend on abstractions, not implementations. Apply each when the specific problem it solves starts causing pain.
- SRP: Extract when a class changes for multiple unrelated reasons
- OCP: Replace growing conditionals with strategy injection
- LSP + ISP: Use composition and interface splitting instead of deep hierarchies
- DIP: Invert so tests run without infrastructure, migrations switch providers without code changes
The quick start
| Principle | Use When | Example |
|---|---|---|
| SRP | Class changes for multiple unrelated reasons | Extract validation, pricing, payment into separate classes |
| OCP | Growing switch/if-else chains need new variants | Use strategy injection for new notification channels |
| LSP | Subtypes violate parent contract | Fix caching decorator to invalidate on save/delete |
| ISP | Consumers depend on methods they never call | Split OrderRepository into Read, Write, Admin interfaces |
| DIP | Tests require infrastructure to run | Inject PaymentGateway interface instead of hardcoding Stripe |
graph TD
Rot[Codebase rot:<br/>change cost grows over time] --> Why{Which coupling?}
Why -->|"one class<br/>many reasons to change"| SRP[SRP:<br/>one class, one axis of change]
Why -->|"new variant requires<br/>editing existing code"| OCP[OCP:<br/>extend via new types,<br/>not by editing]
Why -->|"subtype breaks<br/>parent contract"| LSP[LSP:<br/>subtypes are substitutable]
Why -->|"consumers see<br/>methods they never call"| ISP[ISP:<br/>split interfaces by consumer]
Why -->|"business logic depends<br/>on infrastructure"| DIP[DIP:<br/>depend on abstractions]
style Rot fill:#fee
style SRP fill:#efe
style OCP fill:#efe
style LSP fill:#efe
style ISP fill:#efe
style DIP fill:#efe
The diagram is the diagnostic flow: when something feels expensive to change, identify which coupling is causing the pain, then apply the principle that targets that coupling. SOLID isn't a checklist — it's five specific antidotes to five specific failure modes.
S — Single Responsibility Principle
[Martin, 2000]A class should have one reason to change. Multiple concerns coupled inside one class force a retest of all concerns when any one changes.
The fix:
// Anti-pattern: 6 responsibilities in one method
@PostMapping("/orders")
public ResponseEntity<?> createOrder(@RequestBody OrderRequest request) {
// Validation, pricing, payment, persistence, events, email — all here
// Changes to any one require testing all the others
}
// Solution: Extract each responsibility
@Service
@RequiredArgsConstructor
public class OrderService {
private final InventoryValidator inventoryValidator;
private final PricingEngine pricingEngine;
private final PaymentGateway paymentGateway;
private final OrderRepository orderRepository;
private final OrderEventPublisher eventPublisher;
@Transactional
public Order create(OrderRequest request) {
inventoryValidator.ensureAvailable(request.items());
PricingResult pricing = pricingEngine.calculate(request.items(),
request.shippingAddress());
PaymentResult payment = paymentGateway.charge(
request.paymentMethodId(), pricing.total());
Order order = orderRepository.save(
Order.from(request, pricing, payment));
eventPublisher.orderCreated(order);
return order;
}
}Each class changes for one reason. The PricingEngine changes when pricing rules change, not when the database schema changes. Test setup becomes simple — mock only what you need.
O — Open/Closed Principle
[Martin, 2000]Software should be open for extension, closed for modification. Every new notification channel should not require editing NotificationService or retesting existing channels.
The fix:
// Anti-pattern: growing switch that needs editing every quarter
if (prefs.isEmailEnabled()) {
emailClient.send(...);
}
if (prefs.isSmsEnabled()) {
twilioClient.send(...);
}
if (prefs.isSlackEnabled()) {
slackClient.post(...); // Quarter 3 change
}
// Add WhatsApp? More editing, more testing
// Solution: Strategy interface + injection
public interface NotificationChannel {
void send(UserPreferences prefs, String event, Map<String, Object> data);
}
@Component
public class EmailChannel implements NotificationChannel {
public void send(UserPreferences prefs, String event, Map<String, Object> data) {
emailClient.send(prefs.getEmail(), templateEngine.render("email/" + event, data));
}
}
@Component
public class SlackChannel implements NotificationChannel {
public void send(UserPreferences prefs, String event, Map<String, Object> data) {
slackClient.post(prefs.getSlackWebhookUrl(), formatMessage(event, data));
}
}
@Service
public class NotificationService {
private final List<NotificationChannel> channels;
public void notify(String userId, String event, Map<String, Object> data) {
channels.forEach(ch -> {
try {
ch.send(prefs, event, data);
} catch (Exception e) {
log.error("Notification failed", e);
}
});
}
}Adding WhatsApp means writing one new class, WhatsAppChannel. Spring discovers it automatically. No changes to NotificationService, no retesting.
L — Liskov Substitution Principle
[Liskov 1987]Subtypes must be substitutable for their base types without changing program behavior. A CachingOrderRepository that returns stale cached data after save() breaks this contract.
The fix:
// Anti-pattern: cache not invalidated on save/delete
public class CachingOrderRepository implements OrderRepository {
public Order findById(Long id) {
Order cached = cache.get(id);
return cached != null ? cached : delegate.findById(id);
}
public void save(Order order) {
delegate.save(order);
// Bug: forgot to invalidate cache — findById still returns stale version
}
}
// Correct: maintain the contract
public class CachingOrderRepository implements OrderRepository {
public Order findById(Long id) {
return cache.get(id, key -> delegate.findById(key));
}
public void save(Order order) {
delegate.save(order);
cache.put(order.getId(), order); // Maintain contract: find returns latest
}
public void delete(Long id) {
delegate.delete(id);
cache.invalidate(id); // Maintain contract: find returns null
}
}The fixed version maintains the same observable behavior as the delegate. The cache is an optimization, not a behavior change.
I — Interface Segregation Principle
[Martin, 2000]No client should be forced to depend on methods it doesn't use. A read-only reporting service shouldn't depend on OrderRepository.truncateTable() just because it needs find().
The fix:
// Anti-pattern: fat interface, 11 methods, 3 different consumers
public interface OrderRepository {
Order findById(Long id);
List<Order> findByStatus(OrderStatus status);
Page<Order> findAll(Pageable pageable);
Order save(Order order);
void delete(Long id);
void truncateTable();
void rebuildIndexes();
// ... 4 more methods for different consumers
}
// Solution: split by consumer
public interface OrderReadRepository {
Order findById(Long id);
List<Order> findByStatus(OrderStatus status);
}
public interface OrderWriteRepository {
Order save(Order order);
void delete(Long id);
}
public interface OrderAdminRepository {
void truncateTable();
void rebuildIndexes();
}
// One implementation implements all
@Repository
public class JpaOrderRepository implements
OrderReadRepository, OrderWriteRepository, OrderAdminRepository {
// All method implementations
}
// Each service depends only on what it needs
@Service
public class ReportingService {
private final OrderReadRepository readRepo;
// Compiler prevents calling delete() — the interface doesn't have it
}The compiler now enforces interface boundaries. ISP at the system level is CQRS: reads and writes use different models optimized for each use case.
D — Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions. Business logic defines interfaces; infrastructure implements them.
The dependency-flow inversion in one picture — before-and-after for a payment service:
graph TB
subgraph Before[BEFORE — DIP violation]
B_Svc[PaymentService<br/>high-level domain] --> B_Stripe[StripeClient<br/>concrete SDK]
B_Test[Unit test] -.->|cannot mock<br/>without infra| B_Stripe
style B_Stripe fill:#fdd
style B_Test fill:#fdd
end
subgraph After[AFTER — DIP applied]
A_Svc[PaymentService<br/>high-level domain] --> A_Iface[PaymentGateway<br/>interface owned by<br/>domain layer]
A_Stripe[StripeAdapter<br/>infrastructure] -.->|implements| A_Iface
A_Mock[InMemoryPaymentGateway<br/>test fake] -.->|implements| A_Iface
A_Test[Unit test] -->|injects| A_Mock
style A_Iface fill:#dfd
style A_Mock fill:#dfd
style A_Test fill:#dfd
end
The arrow direction is the entire principle: in the before-picture, the domain's arrow points outward at the SDK (high-level depends on low-level); in the after-picture, both point inward at the domain-owned interface. Tests become trivial because the in-memory fake implements the same interface as the production adapter.
The fix:
// Anti-pattern: hardcoded infrastructure
@Service
public class PaymentService {
private final StripeClient stripe = new StripeClient(key);
private final RedisTemplate redis;
public PaymentResult charge(PaymentRequest request) {
String cached = redis.opsForValue().get("payment:" + request.idempotencyKey());
if (cached != null) return PaymentResult.fromJson(cached);
Charge charge = stripe.charges().create(
ChargeCreateParams.builder()
.setAmount(request.amount().longValue())
.build());
// ... test this without Stripe sandbox and Redis? Impossible
}
}
// Solution: business logic defines interfaces
public interface PaymentGateway {
PaymentResult charge(String paymentMethodId, BigDecimal amount, String currency);
}
public interface IdempotencyStore {
Optional<PaymentResult> find(String key);
void store(String key, PaymentResult result, Duration ttl);
}
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentGateway gateway;
private final IdempotencyStore store;
public PaymentResult charge(PaymentRequest request) {
Optional<PaymentResult> cached = store.find(request.idempotencyKey());
if (cached.isPresent()) return cached.get();
PaymentResult result = gateway.charge(
request.paymentMethodId(), request.amount(), request.currency());
store.store(request.idempotencyKey(), result, Duration.ofHours(24));
return result;
}
}
// Infrastructure: plugs in from outside
@Component
public class StripePaymentGateway implements PaymentGateway {
private final StripeClient stripe;
public PaymentResult charge(String paymentMethodId, BigDecimal amount, String currency) {
Charge charge = stripe.charges().create(ChargeCreateParams.builder()
.setAmount(amount.longValue())
.setCurrency(currency)
.setSource(paymentMethodId)
.build());
return new PaymentResult(charge.getId(), charge.getStatus());
}
}
// Test: no Stripe, no Redis, no Spring
@Test
void charge_returnsStoredResult_onIdempotentKey() {
var store = new InMemoryIdempotencyStore();
store.store("key-1", new PaymentResult("ch_123", "succeeded"), Duration.ofHours(1));
var service = new PaymentService((m, a, c) -> fail("Should not call"), store);
assertEquals("ch_123",
service.charge(new PaymentRequest("key-1", "pm_card", BigDecimal.TEN, "usd")).chargeId());
}DIP at the system level is hexagonal architecture: domain defines interfaces (ports), infrastructure implements them (adapters). Dependency arrows point inward.
Production checklist
Apply when: SRP (class changes for multiple unrelated reasons); OCP (conditional chains grow with variants); LSP (subtypes change observable behavior); ISP (consumer depends on unused methods); DIP (tests require infrastructure).
Skip when: One implementation with no realistic second; code lifespan is weeks; team is two developers.
The pragmatic test: "What is the second implementation?" A test fake counts. "Maybe someday" doesn't.
When SOLID earns its complexity: the three real-world refactors
The principles aren't free — abstraction costs reading time, indirection adds cognitive load. The four refactors below show the exact moments where the cost flips: where the un-abstracted version becomes harder to read, harder to test, and harder to evolve than the principled version. Each is taken from a real production cleanup pass.
SRP — splitting the fat OrderService into single-purpose collaborators. The signal: a single test class growing past 30 methods, each setting up six unrelated mocks. The before-version mixes validation rules, persistence, and notification side effects in one method, so any test must stub all three.
// BEFORE: one class, three reasons to change, every test stubs everything
public class OrderService {
public Order place(OrderRequest req) {
if (req.items().isEmpty()) throw new ValidationException("empty");
if (req.total().signum() <= 0) throw new ValidationException("amount");
Order order = new Order(UUID.randomUUID(), req.userId(), req.items());
jdbc.update("INSERT INTO orders ...", order.id(), order.userId());
emailClient.send(req.userEmail(),
"Order " + order.id() + " confirmed", renderTemplate(order));
slackClient.post("#orders", "New order: " + order.id());
return order;
}
}// AFTER: three collaborators, three focused tests, zero stubbing for unrelated concerns
public class OrderValidator {
public void validate(OrderRequest req) {
if (req.items().isEmpty()) throw new ValidationException("empty");
if (req.total().signum() <= 0) throw new ValidationException("amount");
}
}
public class OrderPersister {
private final JdbcTemplate jdbc;
public Order persist(OrderRequest req) {
Order order = new Order(UUID.randomUUID(), req.userId(), req.items());
jdbc.update("INSERT INTO orders ...", order.id(), order.userId());
return order;
}
}
public class OrderNotifier {
private final EmailClient email;
private final SlackClient slack;
public void announce(Order order, String userEmail) {
email.send(userEmail, "Order " + order.id() + " confirmed", renderTemplate(order));
slack.post("#orders", "New order: " + order.id());
}
}
@RequiredArgsConstructor
public class OrderService {
private final OrderValidator validator;
private final OrderPersister persister;
private final OrderNotifier notifier;
public Order place(OrderRequest req) {
validator.validate(req);
Order order = persister.persist(req);
notifier.announce(order, req.userEmail());
return order;
}
}The validation test no longer needs an email mock; the persistence test no longer needs a Slack stub. Test setup shrinks from 40 lines to 8, and a flaky email test stops failing the validation suite.
OCP — replacing the payment-gateway switch with a strategy interface. The signal: every new gateway requires editing the same method, and the QA team retests Stripe and PayPal whenever Adyen ships. Conditionals over a string discriminator are the canonical OCP smell.
// BEFORE: every new gateway means editing this method and re-testing the others
public PaymentResult charge(String type, BigDecimal amount, String token) {
if (type.equals("stripe")) {
return new StripeClient(stripeKey).charge(amount, token);
} else if (type.equals("paypal")) {
return new PayPalClient(paypalKey).charge(amount, token);
} else if (type.equals("adyen")) {
return new AdyenClient(adyenKey).charge(amount, token); // added Q3
}
throw new IllegalArgumentException("unknown gateway: " + type);
}// AFTER: each gateway is closed for modification; new ones extend without editing existing code
public interface PaymentGateway {
String id();
PaymentResult charge(BigDecimal amount, String token);
}
@Component
public class StripeGateway implements PaymentGateway {
public String id() { return "stripe"; }
public PaymentResult charge(BigDecimal amount, String token) { /* ... */ }
}
@Component
public class PayPalGateway implements PaymentGateway {
public String id() { return "paypal"; }
public PaymentResult charge(BigDecimal amount, String token) { /* ... */ }
}
@Service
@RequiredArgsConstructor
public class PaymentRouter {
private final List<PaymentGateway> gateways;
private Map<String, PaymentGateway> byId;
@PostConstruct
void index() {
byId = gateways.stream().collect(toMap(PaymentGateway::id, identity()));
}
public PaymentResult charge(String type, BigDecimal amount, String token) {
PaymentGateway gw = byId.get(type);
if (gw == null) throw new IllegalArgumentException("unknown gateway: " + type);
return gw.charge(amount, token);
}
}A new AdyenGateway ships as one new file. PaymentRouter is never touched, and the regression suite for Stripe and PayPal stays green by construction.
LSP — replacing the Square extends Rectangle violation with sealed types. The signal: a subclass overriding setters to keep an invariant alive (Square.setWidth also sets height), which silently breaks any caller that assumed Rectangle.setWidth only changes width. Inheritance is the wrong tool — these aren't subtypes, they're sibling shapes.
// BEFORE: Square pretends to be a Rectangle and breaks every caller's assumption
public class Rectangle {
protected int width, height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int area() { return width * height; }
}
public class Square extends Rectangle {
@Override public void setWidth(int w) { this.width = w; this.height = w; }
@Override public void setHeight(int h) { this.width = h; this.height = h; }
}
// caller: r.setWidth(5); r.setHeight(10); assertEquals(50, r.area())
// passes for Rectangle, fails for Square — LSP violation// AFTER: sealed interface with two siblings — exhaustive pattern matching, no broken contracts
public sealed interface Shape permits Rectangle, Square {
int area();
}
public record Rectangle(int width, int height) implements Shape {
public int area() { return width * height; }
}
public record Square(int side) implements Shape {
public int area() { return side * side; }
}
public int describe(Shape s) {
return switch (s) {
case Rectangle r -> r.area();
case Square sq -> sq.area();
};
}The sealed hierarchy is honest: a square is not a rectangle whose setters happen to be coupled — it is a different shape with its own constructor. Pattern matching makes the call sites explicit, and the compiler enforces exhaustiveness when a third shape is added.
DIP — depending on a UserRepository interface to unlock infrastructure-free tests. The signal: unit tests pull in Testcontainers and a Postgres image just to verify a permission rule. The dependency arrow runs from domain to infrastructure; flip it.
// BEFORE: domain logic depends on a concrete Postgres class — test starts a database
@Service
public class UserPermissionService {
private final PostgresUserRepository repo = new PostgresUserRepository(dataSource);
public boolean canPublish(long userId) {
User u = repo.findById(userId);
return u != null && u.role() == Role.EDITOR && u.verified();
}
}
// test: spin up Postgres container, seed user row, assert — 12s per test// AFTER: domain owns the interface; Postgres and an in-memory fake are interchangeable
public interface UserRepository {
User findById(long id);
}
@Service
@RequiredArgsConstructor
public class UserPermissionService {
private final UserRepository repo;
public boolean canPublish(long userId) {
User u = repo.findById(userId);
return u != null && u.role() == Role.EDITOR && u.verified();
}
}
@Repository
@RequiredArgsConstructor
public class PostgresUserRepository implements UserRepository {
private final JdbcTemplate jdbc;
public User findById(long id) {
return jdbc.queryForObject("SELECT id, role, verified FROM users WHERE id = ?",
userMapper(), id);
}
}
// test fake — no JDBC, no container, no Spring context
public class InMemoryUserRepository implements UserRepository {
private final Map<Long, User> users = new HashMap<>();
public InMemoryUserRepository seed(User u) { users.put(u.id(), u); return this; }
public User findById(long id) { return users.get(id); }
}
@Test
void canPublish_returnsFalse_forUnverifiedEditor() {
var repo = new InMemoryUserRepository().seed(new User(7L, Role.EDITOR, false));
var service = new UserPermissionService(repo);
assertFalse(service.canPublish(7L));
}The unit test runs in 3 milliseconds instead of 12 seconds, and the production code is unchanged because PostgresUserRepository and InMemoryUserRepository are substitutable through the same interface.
Frequently Asked Questions
When should I NOT apply SOLID principles?
Skip the abstraction when: (1) there's only one implementation with no realistic second one, (2) the codebase lifespan is weeks not years, (3) the module is simple enough that two developers can hold it in their heads. SOLID pays compound interest over time — short-lived code doesn't benefit from the investment.
How does SOLID relate to microservice architecture?
Each SOLID principle has a system-level analog. SRP maps to service boundaries (one business capability per service). OCP maps to event-driven extensibility (new subscriber, no core changes). ISP maps to CQRS and BFF patterns (different interfaces for different consumers). DIP maps to hexagonal architecture (business logic doesn't know about infrastructure). SOLID at the class level trains the design instincts you need at the system level.
Is SOLID only for object-oriented programming?
The principles originated in OOP, but the underlying ideas — separation of concerns, programming to contracts, minimizing coupling — apply universally. Go enforces ISP through implicit interfaces. Functional programming achieves OCP through higher-order functions. Rust's trait system embodies LSP. The language syntax changes; the design constraints don't.
Keep Reading
- Spring Boot REST: JPA, Validation, Exception Handling, and Testing — SOLID principles applied to a real Spring Boot API with layered architecture
- Java Singleton Pattern: Thread-Safe, Reflection-Proof, Serialization-Safe — When the singleton pattern is justified, and when dependency injection replaces it
- Java Testing with JUnit 5 and Mockito: A Production Guide — How dependency inversion makes code testable without infrastructure dependencies
- Microservices Architecture Patterns — SOLID at the service boundary: SRP becomes "one service, one bounded context"; DIP becomes "talk to the API, not the database"
- API Architecture: REST vs gRPC vs GraphQL — Interface Segregation at the wire level: thin per-consumer endpoints vs one shared schema
Engineering Team
A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.
Read Next
Modern Java Collections: computeIfAbsent, Immutables, and Best Practices
Java collections: computeIfAbsent, getOrDefault, removeIf, immutables, and Comparator chains that eliminate entire bug categories.
Java Date and Time API: The Definitive Guide to java.time
Stop fighting java.util.Date. Master LocalDateTime, ZonedDateTime, Instant, and Duration — predictable, thread-safe time handling.
Java Singleton Pattern: Thread-Safe, Reflection-Proof, Serialization-Safe
Java singletons: enum patterns, double-checked locking, and holder-classes — and when dependency injection is the better answer.