08-low-level-design

SOLID Principles for Backend Engineers

A practical guide to SOLID principles in Java backend systems, with real order, coupon, and payment examples of each violation and fix.

August 14, 2026
backend-engineersoliddesign-principlessrpdependency-inversionclean-code

SOLID Principles for Backend Engineers

SOLID is not a checklist you tick off in a design review — it is a set of pressure-tested answers to the question "why does this codebase get harder to change every sprint?" Each letter names a specific failure mode that shows up in real services: a OrderService that nobody dares touch, a switch statement that grows a new case every release, a mock that can't honestly stand in for the real implementation. This guide walks through all five principles using the same domain every backend engineer eventually builds — order processing, coupons, inventory, and payments — showing the violation first, the fix second, and the concrete pain each violation causes in production.


1. Why SOLID Matters in Backend Systems

SOLID principles are not about theoretical purity. Each one maps to a specific, observable symptom in a codebase:

PrincipleSymptom when violatedWhat it costs you
S — Single ResponsibilityA class changes for unrelated reasonsMerge conflicts, unrelated regressions, fear of touching the file
O — Open/ClosedAdding a feature means editing existing, tested codeRegression risk on every new feature
L — Liskov SubstitutionSubtypes throw or behave differently than callers expectRuntime surprises that unit tests miss
I — Interface SegregationImplementers stub out methods they don't needFake implementations, UnsupportedOperationException everywhere
D — Dependency InversionHigh-level logic imports low-level infrastructureUntestable code, vendor lock-in
💡

These principles trade off against each other. Applying SRP aggressively can create so many tiny classes that DIP wiring becomes exhausting. Applying OCP everywhere can produce a maze of strategy interfaces for logic that never actually varies. SOLID is a set of forces to balance, not a set of rules to maximize independently — we'll call out the over-application risk in each section.


2. Single Responsibility Principle (SRP)

"A class should have one, and only one, reason to change." In practice: a class should own exactly one axis of business or technical concern — not "do one thing" in the literal sense (a class can have many methods), but change for only one kind of reason.

The violation

java
// BAD: OrderProcessor has at least four reasons to change —
// order validation rules, persistence technology, payment provider,
// and notification channel are all tangled together.
public class OrderProcessor {
 
    private final DataSource dataSource;
    private final HttpClient paymentHttpClient;
 
    public OrderProcessor(DataSource dataSource, HttpClient paymentHttpClient) {
        this.dataSource = dataSource;
        this.paymentHttpClient = paymentHttpClient;
    }
 
    public void processOrder(OrderRequest request) throws SQLException {
        // 1. Validation logic
        if (request.getItems().isEmpty()) {
            throw new IllegalArgumentException("Order must have at least one item");
        }
        if (request.getCustomerId() == null) {
            throw new IllegalArgumentException("Customer ID required");
        }
 
        // 2. Price calculation
        BigDecimal total = BigDecimal.ZERO;
        for (LineItem item : request.getItems()) {
            total = total.add(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())));
        }
 
        // 3. Direct JDBC persistence
        try (Connection conn = dataSource.getConnection()) {
            PreparedStatement stmt = conn.prepareStatement(
                "INSERT INTO orders (customer_id, total, status) VALUES (?, ?, ?)");
            stmt.setString(1, request.getCustomerId());
            stmt.setBigDecimal(2, total);
            stmt.setString(3, "PENDING");
            stmt.executeUpdate();
        }
 
        // 4. Raw HTTP call to payment provider
        String json = "{\"amount\":" + total + ",\"customer\":\"" + request.getCustomerId() + "\"}";
        HttpRequest httpRequest = HttpRequest.newBuilder()
            .uri(URI.create("https://api.stripe.com/v1/charges"))
            .POST(HttpRequest.BodyPublishers.ofString(json))
            .build();
        // ... send, parse response, retry on failure ...
 
        // 5. Email sending, inline
        String emailBody = "Your order for $" + total + " has been placed.";
        // ... SMTP client call ...
    }
}

This class changes when: validation rules change, the SQL schema changes, you switch payment providers, or marketing wants a new email template. Four unrelated teams will eventually edit this one file, and a merge conflict between "add GST validation" and "switch to SendGrid" is now inevitable.

The fix

java
public class OrderValidator {
    public void validate(OrderRequest request) {
        if (request.getItems().isEmpty()) {
            throw new InvalidOrderException("Order must have at least one item");
        }
        if (request.getCustomerId() == null) {
            throw new InvalidOrderException("Customer ID required");
        }
    }
}
 
public class OrderPricingService {
    public Money calculateTotal(List<LineItem> items) {
        return items.stream()
            .map(item -> item.getPrice().multiply(item.getQuantity()))
            .reduce(Money.ZERO, Money::add);
    }
}
 
public interface OrderRepository {
    Order save(Order order);
}
 
public class JdbcOrderRepository implements OrderRepository {
    private final DataSource dataSource;
    // JDBC-specific persistence only — changes only when schema/storage changes
    public JdbcOrderRepository(DataSource dataSource) { this.dataSource = dataSource; }
 
    @Override
    public Order save(Order order) { /* JDBC insert */ return order; }
}
 
// The orchestrator has exactly one reason to change: the order placement workflow itself
public class OrderService {
    private final OrderValidator validator;
    private final OrderPricingService pricingService;
    private final OrderRepository orderRepository;
    private final PaymentGateway paymentGateway;
    private final NotificationService notificationService;
 
    public OrderService(OrderValidator validator, OrderPricingService pricingService,
                         OrderRepository orderRepository, PaymentGateway paymentGateway,
                         NotificationService notificationService) {
        this.validator = validator;
        this.pricingService = pricingService;
        this.orderRepository = orderRepository;
        this.paymentGateway = paymentGateway;
        this.notificationService = notificationService;
    }
 
    public Receipt placeOrder(OrderRequest request) {
        validator.validate(request);
        Money total = pricingService.calculateTotal(request.getItems());
        Order order = orderRepository.save(Order.pending(request, total));
        PaymentResult payment = paymentGateway.charge(order.toPaymentRequest());
        notificationService.notifyOrderPlaced(order);
        return new Receipt(order, payment);
    }
}

How to spot an SRP violation

SmellWhat it means
A class imports java.sql.*, java.net.http.*, and your domain model togetherPersistence, transport, and business logic are mixed
You can't describe the class in one sentence without "and"It has multiple responsibilities
Unit testing it requires mocking 5+ unrelated collaboratorsIts dependencies reveal its scattered concerns
Two different teams routinely edit the same file for unrelated ticketsTwo responsibilities are living in one class
Method names don't share a common vocabulary (validate, insertRow, sendHttpCharge, renderEmail)Each method belongs to a different responsibility
⚠️

SRP is about reasons to change, not line count. A 400-line class with one responsibility (say, a state machine with many transitions) is fine. A 40-line class that does validation, persistence, and notification is a bigger liability. Don't split classes just to hit an arbitrary size — split them along the axis of who asks for the change.


3. Open/Closed Principle (OCP)

"Software entities should be open for extension but closed for modification." You should be able to add new behavior without editing and re-testing code that already works.

The violation

java
// BAD: every new coupon type requires editing this method and re-testing
// every existing coupon type along with it.
public class CouponService {
 
    public Money applyDiscount(Order order, Coupon coupon) {
        if (coupon.getType() == CouponType.PERCENTAGE) {
            BigDecimal rate = coupon.getValue().divide(BigDecimal.valueOf(100));
            return order.getTotal().multiply(BigDecimal.ONE.subtract(rate));
        } else if (coupon.getType() == CouponType.FLAT) {
            return order.getTotal().subtract(Money.of(coupon.getValue()));
        } else if (coupon.getType() == CouponType.FREE_SHIPPING) {
            return order.getTotal().subtract(order.getShippingCost());
        } else if (coupon.getType() == CouponType.BUY_ONE_GET_ONE) {
            // added six months later, by someone who didn't read the first three branches
            return applyBogoDiscount(order);
        }
        throw new IllegalArgumentException("Unknown coupon type: " + coupon.getType());
    }
}

Every new promotion type — and marketing asks for a new one every quarter — means editing this method, which means re-testing PERCENTAGE, FLAT, and FREE_SHIPPING all over again to make sure the new if branch didn't break the else if chain above it.

The fix

java
public interface DiscountStrategy {
    Money apply(Order order);
}
 
public class PercentageDiscount implements DiscountStrategy {
    private final BigDecimal percentage;
    public PercentageDiscount(BigDecimal percentage) { this.percentage = percentage; }
 
    @Override
    public Money apply(Order order) {
        BigDecimal rate = percentage.divide(BigDecimal.valueOf(100));
        return order.getTotal().multiply(BigDecimal.ONE.subtract(rate));
    }
}
 
public class FlatAmountDiscount implements DiscountStrategy {
    private final Money amount;
    public FlatAmountDiscount(Money amount) { this.amount = amount; }
 
    @Override
    public Money apply(Order order) {
        return order.getTotal().subtract(amount).max(Money.ZERO);
    }
}
 
public class FreeShippingDiscount implements DiscountStrategy {
    @Override
    public Money apply(Order order) {
        return order.getTotal().subtract(order.getShippingCost());
    }
}
 
// Adding BuyOneGetOneDiscount later touches zero existing files.
public class BuyOneGetOneDiscount implements DiscountStrategy {
    @Override
    public Money apply(Order order) { /* new logic, isolated and independently testable */ return order.getTotal(); }
}
 
public class CouponService {
    public Money applyDiscount(Order order, DiscountStrategy strategy) {
        return strategy.apply(order);
    }
}

OCP is what makes A/B testing and gradual rollouts safe. When each variant is a separate DiscountStrategy, you can register a new one behind a feature flag, run it for 5% of traffic, and delete it later without touching any existing, already-proven strategy class.

⚠️

Don't apply OCP speculatively. If you only have one coupon type today and no roadmap signal that a second is coming, a Coupon class with a single applyDiscount() method is simpler and equally correct. Introduce the strategy interface when the second variant actually shows up — that's when the abstraction earns its cost.


4. Liskov Substitution Principle (LSP)

"Objects of a superclass should be replaceable with objects of a subclass without breaking the program." Subtypes must honor the contract the base type promises — same preconditions (or weaker), same postconditions (or stronger), no new exceptions the caller isn't prepared for.

The violation

java
public interface InventoryRepository {
    Optional<InventoryRecord> findBySku(String sku);
    void reserve(String sku, int quantity);
    void release(String sku, int quantity);
}
 
// BAD: a "read-only" implementation that silently breaks the contract.
// Every caller of reserve()/release() now has to know, out of band,
// that this particular implementation will blow up.
public class ReadOnlyInventoryRepository implements InventoryRepository {
 
    @Override
    public Optional<InventoryRecord> findBySku(String sku) {
        return queryReplica(sku);
    }
 
    @Override
    public void reserve(String sku, int quantity) {
        throw new UnsupportedOperationException("Read replica cannot reserve stock");
    }
 
    @Override
    public void release(String sku, int quantity) {
        throw new UnsupportedOperationException("Read replica cannot release stock");
    }
}
java
// Any code written against the interface is now unsafe to call generically:
public void checkout(InventoryRepository repo, String sku, int qty) {
    repo.reserve(sku, qty);  // works for JdbcInventoryRepository,
                              // throws for ReadOnlyInventoryRepository
}

The compiler happily accepts ReadOnlyInventoryRepository wherever InventoryRepository is expected — but the program is no longer correct. This is the textbook Liskov violation: the subtype narrows the contract the interface promised.

The fix

Segregate the contract so the type system reflects reality instead of hiding it behind an exception:

java
public interface InventoryReader {
    Optional<InventoryRecord> findBySku(String sku);
}
 
public interface InventoryWriter {
    void reserve(String sku, int quantity);
    void release(String sku, int quantity);
}
 
public interface InventoryRepository extends InventoryReader, InventoryWriter {}
 
// Now this class only claims to implement what it can actually honor.
public class ReadOnlyInventoryRepository implements InventoryReader {
    @Override
    public Optional<InventoryRecord> findBySku(String sku) {
        return queryReplica(sku);
    }
}
 
// Checkout code depends only on the capability it needs, and the
// compiler guarantees every implementation can actually reserve stock.
public void checkout(InventoryWriter writer, String sku, int qty) {
    writer.reserve(sku, qty);
}

A subtler LSP violation: strengthened preconditions

java
public class Payment {
    protected final Money amount;
    protected Payment(Money amount) { this.amount = amount; }
 
    // Base contract: accepts any positive amount
    public void process() {
        if (amount.isNegative() || amount.isZero()) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        doCharge();
    }
 
    protected void doCharge() { /* ... */ }
}
 
// BAD: silently strengthens the precondition the base class advertised.
// Callers who successfully process $5 payments via other Payment subtypes
// will get surprised by this one rejecting amounts under $50.
public class WireTransferPayment extends Payment {
    public WireTransferPayment(Money amount) { super(amount); }
 
    @Override
    public void process() {
        if (amount.isLessThan(Money.of(50))) {
            throw new IllegalArgumentException("Wire transfers require a $50 minimum");
        }
        super.process();
    }
}

If WireTransferPayment truly has a $50 minimum, that's a legitimate business rule — but it must be surfaced at the type or validation layer the caller can inspect (e.g., payment.getMinimumAmount()), not sprung on callers as a new runtime exception the base contract never promised.

🚨

UnsupportedOperationException in a subtype is almost always an LSP violation. java.util.List.of(...) throwing on .add() is the JDK's own well-known example — and it trips up production code regularly (Collections.unmodifiableList too). If you're about to write throw new UnsupportedOperationException() inside an interface implementation, stop and ask whether the interface needs to be segregated instead.

LSP checklist

QuestionIf the answer is "yes," you have a violation
Does the subtype throw an exception the base type's contract didn't declare?Yes → LSP violated
Does the subtype require stronger preconditions (narrower valid input) than the base type?Yes → LSP violated
Does the subtype return a weaker postcondition (e.g., null where the base type guaranteed non-null)?Yes → LSP violated
Does calling code need an instanceof check to use the subtype safely?Yes → LSP violated

5. Interface Segregation Principle (ISP)

"Clients should not be forced to depend on methods they do not use." Large, do-everything interfaces force implementers to either support every method or stub out the ones they don't need — both bad outcomes.

The violation

java
// BAD: a single fat interface for every kind of outbound notification.
public interface NotificationService {
    void sendEmail(String to, String subject, String body);
    void sendSms(String phoneNumber, String message);
    void sendPushNotification(String deviceToken, String title, String body);
    void sendWebhook(String url, String payload);
    void sendSlackMessage(String channel, String message);
}
 
// A team building a lightweight order-confirmation feature only needs email,
// but must implement (or stub) all five methods to satisfy the interface.
public class SimpleEmailNotifier implements NotificationService {
    @Override
    public void sendEmail(String to, String subject, String body) { /* real */ }
 
    @Override
    public void sendSms(String phoneNumber, String message) {
        throw new UnsupportedOperationException(); // forced stub
    }
 
    @Override
    public void sendPushNotification(String deviceToken, String title, String body) {
        throw new UnsupportedOperationException(); // forced stub
    }
 
    @Override
    public void sendWebhook(String url, String payload) {
        throw new UnsupportedOperationException(); // forced stub
    }
 
    @Override
    public void sendSlackMessage(String channel, String message) {
        throw new UnsupportedOperationException(); // forced stub
    }
}

Note this is the same root problem as the LSP violation above — a fat interface practically guarantees LSP violations in its implementers, because no single implementation naturally supports every method.

The fix

java
public interface EmailSender {
    void send(String to, String subject, String body);
}
 
public interface SmsSender {
    void send(String phoneNumber, String message);
}
 
public interface PushSender {
    void send(String deviceToken, String title, String body);
}
 
// Implementers depend only on what they actually do.
public class SimpleEmailNotifier implements EmailSender {
    @Override
    public void send(String to, String subject, String body) { /* real, complete implementation */ }
}
 
// A fan-out orchestrator composes the narrow interfaces it needs —
// it depends on abstractions, and each is fully honored by its implementer.
public class OrderNotificationOrchestrator {
    private final EmailSender emailSender;
    private final PushSender pushSender;
 
    public OrderNotificationOrchestrator(EmailSender emailSender, PushSender pushSender) {
        this.emailSender = emailSender;
        this.pushSender = pushSender;
    }
 
    public void notifyOrderConfirmed(Customer customer, Order order) {
        emailSender.send(customer.getEmail(), "Order Confirmed", renderEmail(order));
        if (customer.hasPushEnabled()) {
            pushSender.send(customer.getDeviceToken(), "Order Confirmed", renderPush(order));
        }
    }
}
💡

ISP scales down as easily as it scales up. A microservice that only reads from a repository should depend on a two-method InventoryReader, not a ten-method InventoryRepository — even if a single class happens to implement both in production. The interface a component depends on should reflect what that component needs, not what the concrete implementation offers.

Fat interface vs. segregated interfaces

AspectFat interfaceSegregated interfaces
Implementer burdenMust implement (or stub) everythingImplements only what it supports
Test mockingMust mock unused methodsMocks are minimal and focused
Compile-time safetyinstanceof checks needed to use subset safelyType system enforces capability
Change blast radiusAdding a method breaks every implementerAdding a method affects only that interface's implementers

6. Dependency Inversion Principle (DIP)

"High-level modules should not depend on low-level modules; both should depend on abstractions." This is the principle that makes dependency injection frameworks like Spring valuable — not the annotations themselves, but the architectural shape they encourage.

The violation

java
// BAD: OrderService (high-level business logic) directly instantiates and
// depends on RazorpayClient (low-level infrastructure detail).
public class OrderService {
    private final RazorpayClient razorpayClient = new RazorpayClient("live_key_xxx");
 
    public Receipt checkout(Order order) {
        RazorpayChargeRequest request = new RazorpayChargeRequest(order.getTotal().toPaise());
        RazorpayChargeResponse response = razorpayClient.charge(request);
        return new Receipt(order, response.getTransactionId());
    }
}

Every problem this creates is downstream of one design mistake: the business-critical checkout() workflow now knows about Razorpay's request/response shapes, its API key management, and its retry semantics. Switching providers, unit testing without live credentials, or running a second gateway for a different region all require editing this class.

The fix

java
// The abstraction both layers depend on. Owned by the domain, not by any vendor SDK.
public interface PaymentGateway {
    PaymentResult charge(PaymentRequest request);
}
 
// Low-level detail — depends on (implements) the abstraction, not the other way around.
public class RazorpayPaymentGateway implements PaymentGateway {
    private final RazorpayClient client;
 
    public RazorpayPaymentGateway(RazorpayClient client) { this.client = client; }
 
    @Override
    public PaymentResult charge(PaymentRequest request) {
        RazorpayChargeResponse response = client.charge(toRazorpayRequest(request));
        return PaymentResult.success(response.getTransactionId());
    }
}
 
// High-level module depends only on the abstraction.
public class OrderService {
    private final PaymentGateway paymentGateway;
 
    public OrderService(PaymentGateway paymentGateway) {  // injected, not constructed
        this.paymentGateway = paymentGateway;
    }
 
    public Receipt checkout(Order order) {
        PaymentResult result = paymentGateway.charge(order.toPaymentRequest());
        return new Receipt(order, result.getTransactionId());
    }
}
java
// Spring wires the concrete implementation at startup — OrderService never knows.
@Configuration
public class PaymentConfig {
    @Bean
    public PaymentGateway paymentGateway(RazorpayClient client) {
        return new RazorpayPaymentGateway(client);
    }
}
 
// Testing OrderService is now trivial — no HTTP, no live credentials.
@Test
void checkoutChargesThePaymentGateway() {
    PaymentGateway fakeGateway = request -> PaymentResult.success("txn_test_123");
    OrderService service = new OrderService(fakeGateway);
 
    Receipt receipt = service.checkout(sampleOrder());
 
    assertThat(receipt.getTransactionId()).isEqualTo("txn_test_123");
}

"Depend on abstractions" doesn't mean "wrap everything in an interface." String, List, BigDecimal — stable, unlikely-to-change JDK types — don't need an abstraction layer. Reserve DIP for boundaries that are actually volatile: external services, storage engines, and anything with a vendor name in its class name.

⚠️

DIP is about the direction of source-code dependency, not about using an interface at all. If PaymentGateway lived in a razorpay-integration package and OrderService had to import from that package to use it, you'd still have low-level infrastructure dictating high-level design — just with an extra layer of indirection. The abstraction must live in (or be owned by) the domain, and the infrastructure package should depend on the domain, never the reverse.


7. Applying All Five Together

Here's what the checkout flow looks like once SRP, OCP, LSP, ISP, and DIP have all been applied — this is the shape you should expect a well-factored order-placement module to converge toward:

Each dependency is:

  • Single-purpose (SRP) — OrderValidator only validates, OrderRepository only persists
  • Extensible without modification (OCP) — new DiscountStrategy implementations don't touch OrderService
  • Faithfully substitutable (LSP) — any PaymentGateway implementation honors the same contract
  • Narrow and focused (ISP) — EmailSender isn't a fat NotificationService
  • Inverted at the boundary (DIP) — OrderService never imports RazorpayClient or JDBC types directly

8. When SOLID Becomes a Liability

🚨

Over-applying SOLID produces its own dysfunction: "abstraction soup." A codebase with an interface for every class, a factory for every interface, and a strategy for logic that has never varied in three years is just as hard to work in as a tangled God class — you spend your time navigating indirection instead of tangled responsibilities. Apply each principle when its corresponding pain shows up, not preemptively on day one of a new service.

Practical signals it's time to apply a principle, versus signals you're over-applying it:

PrincipleApply it when...Don't apply it when...
SRPA class has genuinely unrelated reasons to change todayYou're splitting a cohesive 60-line class just to hit a line-count target
OCPYou've added a second/third variant of the same conceptYou have exactly one implementation and no roadmap for a second
LSPA subtype needs to reject inputs or throw exceptions the base type didn't promiseYou're adding implements Serializable to a value object (no behavioral contract at stake)
ISPImplementers are stubbing methods with UnsupportedOperationExceptionAn interface has 3 cohesive methods that every implementer genuinely needs
DIPThe dependency is a volatile external boundary (vendor, DB, queue)The dependency is java.util.List or another stable JDK type

Key takeaways

  • SRP failures show up as merge conflicts and "who broke this" incidents — the fix is separating axes of change, not minimizing line count.
  • OCP is what makes feature flags and gradual rollouts safe: new behavior should be a new class, not an edited if/else chain.
  • LSP violations are almost always visible as UnsupportedOperationException, instanceof checks before calling a method, or a subtype narrowing its accepted inputs.
  • ISP and LSP are linked — a fat interface all but guarantees some implementer will violate LSP by stubbing out methods it can't honor.
  • DIP means the domain owns the abstraction; infrastructure implements it and depends on the domain, never the reverse.
  • SOLID principles trade off against each other — abstraction has a cost, and applying every principle maximally produces a different, equally real kind of unmaintainable codebase.
  • The best time to introduce an interface for OCP/DIP is when the second implementation actually appears, not speculatively before it does.
  • In code review, ask "which of these five pains does this abstraction solve, concretely, today?" — if the answer is "none yet," it can probably wait.

Interview Questions

  • What does "single responsibility" mean if not "a class should do only one thing"? Give an example of a large class that still follows SRP.
  • Walk through refactoring a class that does validation, persistence, and notification in one method to follow SRP.
  • How does the Strategy pattern help satisfy the Open/Closed Principle? What's the trade-off of applying it too early?
  • Give an example of a Liskov Substitution violation in the JDK itself. What does it teach you about immutable collections?
  • Why is UnsupportedOperationException in an interface implementation usually a design smell? How would you fix it?
  • What's the difference between a strengthened precondition and a weakened postcondition? Which one is the LSP violation?
  • Why does a "fat" service interface tend to produce Liskov violations downstream? How are ISP and LSP related?
  • Explain Dependency Inversion using an OrderService and a payment gateway. Where should the PaymentGateway interface live?
  • Is using an interface always sufficient for Dependency Inversion? What else has to be true about package ownership?
  • How would you unit test a class that depends on a PaymentGateway interface versus one that directly instantiates a RazorpayClient?
  • When would you not introduce an abstraction for DIP, even for an external dependency?
  • Describe a real codebase symptom that told you SOLID was being over-applied rather than under-applied.
  • How do SRP and OCP work together when you add a new coupon type to an existing discount engine?
  • What's the practical difference between depending on an abstraction and depending on an interface defined in the same package as its only implementation?