Skip to content
ADevGuide Logo ADevGuide
Go back

SOLID Principles in Java Explained with Examples

Updated:

By Pratik Bhuite | 51 min read

Hub: Java / Design Patterns

Series: Java Design Patterns Series

Last updated: Aug 30, 2026

Part 9 of 9 in the Java Design Patterns Series

Key Takeaways

On this page
Reading Comfort:

SOLID Principles in Java: Explained with Examples

Imagine you are on call for a payment service at Razorpay that processes UPI, wallet, and card settlements through a single PaymentProcessor class. On a routine Friday deploy, the product team asks for a new settlement rule for UPI AutoPay. You edit PaymentProcessor to add the rule, and that one edit couples three concerns: pricing logic, database writes, and a direct instantiation of RazorpayGateway inside the same method. A test that used to pass now fails because the pricing change altered the DB write path, the new gateway cannot be mocked without changing the class, and a downstream reporting job breaks because it reused the processor for read-only settlement previews. One file, three reasons to change, five stakeholders to re-approve.

This is exactly the maintenance crisis SOLID addresses. SOLID is a set of five object-oriented design rules that turn a tangle of responsibilities and concrete dependencies into a system that grows by adding classes rather than by surgically editing a shared file. This guide expands each principle in Java from definition to production trade-offs, with bad versus good code that explains WHY per line, and if you want to see how these rules shape creation patterns, read Factory Design Pattern Java Simplified and Factory Method Design Pattern Java Simple Detailed Examples alongside this one.

Table of Contents

Open Table of Contents

Why SOLID Exists: The Maintenance Crisis

SOLID is not an academic checklist. It is a response to four recurring costs that show up in every Java monolith that survives its first year: merge contention on god classes, test setup that needs a database to verify pricing, silent contract breakage when a subclass overrides a method with a weaker promise, and refactors that touch ten files because a concrete gateway is imported everywhere. Each cost corresponds to a principle.

SRP exists because cohesion is ownership. A class with three responsibilities has three owners who must approve every change to the file. That approval cost compounds: a hand-off PR that intended to tune retry intervals now needs a database reviewer, a pricing reviewer, and an integration reviewer. OCP exists because most growth in a payment or notification platform is additive. You rarely remove UPI; you add UPI AutoPay. If addition requires editing an existing dispatch that already ships card settlements, every new method risks regressing a flow that previously earned trust. LSP exists because inheritance is a promise about substitutability. If PremiumSettlement cannot stand in for Settlement in a preview job without throwing UnsupportedOperationException, polymorphism has become a hazard rather than a reuse tool. ISP exists because fat interfaces penalize every consumer with methods it never calls. A processor that implements settle(), refund(), reconcile(), and generateReport() in one interface forces a read-only reporter to depend on refund semantics to compile. DIP exists because direct instantiation is a decision that resists context. When a class calls new RazorpayGateway() internally, the test environment, the staging environment, and the partner failover path all have to live with the same choice.

The alternative to SOLID is not chaos on day one; it is velocity on day one paid back as friction per deploy. Teams at Uber and Razorpay treat SOLID as a budget conversation. Each single-responsibility split costs an extra file, each abstraction costs an indirection that IDE navigation must cross, but those costs are traded for smaller blast radius per PR, narrower test doubles per module, and selective extension without selective regression. The pattern families in Builder Pattern in Java: Explanation and Example and Abstract Factory Pattern in Java: Explanation and Example reward the same trade, which is why they pair naturally with this guide when construction becomes the extension point.

SOLID Dependency Flow Diagram

flowchart TD
    C[Client Code\nOrderService / Payment Flow] --> ABS[Abstraction\nInterfaces: PaymentGateway, Settlement]
    ABS --> IMPL1[Implementation A\nRazorpayGateway]
    ABS --> IMPL2[Implementation B\nStripeGateway]
    IMPL1 --> DIP[DIP Checkpoint\nHigh-level depends on Abstraction]
    IMPL2 --> DIP
    DIP --> OCP[OCP Extension Point\nAdd new gateway without editing client]
    OCP --> NEW[New Implementation C\nUpiAutoPayGateway]
    NEW --> ABS
    ABS --> LSP[LSP Check\nSubtypes must be substitutable]
    LSP --> ISP[ISP Segregation\nSmall focused interfaces]
    ISP --> SRP[SRP Cohesion\nOne reason to change per class]
    SRP --> C
    C --> FEEDBACK{New Requirement?}
    FEEDBACK -- Yes --> OCP
    FEEDBACK -- No --> DONE[Stable Release]

    classDef client fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px,color:#000000;
    classDef abstraction fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#000000;
    classDef impl fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#000000;
    class C,FEEDBACK,DONE client;
    class ABS,DIP,OCP,LSP,ISP,SRP abstraction;
    class IMPL1,IMPL2,NEW impl;

The diagram shows the feedback loop that justifies the upfront abstraction. A new settlement requirement does not cut through the middle of OrderService. It enters at the OCP extension point, adds a new implementation behind the existing abstraction, passes LSP substitutability, respects ISP narrowness, preserves SRP cohesion, and returns to the client without a central edit. The loop is where review time is saved: the senior who owns order flow re-approves the extension wiring, not the core.

Single Responsibility Principle (SRP)

What It Says

A class should have only one reason to change, meaning it should have only one responsibility. Robert C. Martin phrased it as “a class should have only one actor who would request a change.” That actor is the stakeholder, not the method count.

Why It Matters

Cohesion is a maintenance argument. When pricing logic, persistence, and notification live in the same payment class, a change demanded by risk, a change demanded by finance, and a change demanded by observability all converge on the same file. Each stakeholder touches code they do not own, each test fixture must satisfy three concerns even when exercising one, and a performance improvement in serialization can corrupt settlement because both concerns share mutable fields. SRP keeps the radius of a regression as small as the reason for the change.

Bad Code: Three Responsibilities in One Class

// WHY this violates SRP: one class now owns pricing, persistence, and notification
// A pricing rule change, a schema migration, and a logging change all edit the same file
public class PaymentProcessor {

    public void process(Order order) {
        // Responsibility 1: pricing
        double amount = order.getBaseAmount() * 1.18; // GST baked inline, no single owner
        if (order.isPremiumUser()) {
            amount *= 0.90; // discount policy mixed with orchestration
        }

        // Responsibility 2: persistence
        // WHY this coupling hurts testability: unit test now needs a DB or a mock of DriverManager
        try (var conn = java.sql.DriverManager.getConnection("jdbc:postgresql://prod/db")) {
            var stmt = conn.prepareStatement("INSERT INTO payments(order_id, amount) VALUES (?, ?)");
            stmt.setLong(1, order.getId());
            stmt.setDouble(2, amount);
            stmt.executeUpdate();
        } catch (java.sql.SQLException e) {
            throw new RuntimeException(e);
        }

        // Responsibility 3: notification
        System.out.println("Payment processed for order " + order.getId());
    }
}

Good Code: Each Reason to Change Isolated

// WHY: value object owns only order data, so schema changes do not touch pricing tests
public final class Order {
    private final long id;
    private final double baseAmount;
    private final boolean premiumUser;
    public Order(long id, double baseAmount, boolean premiumUser) {
        this.id = id; this.baseAmount = baseAmount; this.premiumUser = premiumUser;
    }
    public long getId() { return id; }
    public double getBaseAmount() { return baseAmount; }
    public boolean isPremiumUser() { return premiumUser; }
}

// WHY: single responsibility is pricing calculation; no DB or I/O present
public class PricingService {
    public double calculateTotal(Order order) {
        double amount = order.getBaseAmount() * 1.18; // WHY GST isolated here: tax authority is the sole change actor
        if (order.isPremiumUser()) {
            amount *= 0.90; // WHY discount isolated here: product is the sole change actor
        }
        return amount;
    }
}

// WHY: single responsibility is persistence; schema change affects only this class
public class PaymentRepository {
    public void save(long orderId, double amount) {
        // WHY persistence detail lives behind a method boundary: connection pool choice is free to change
        System.out.println("Persisting payment " + orderId + " amount " + amount);
    }
}

// WHY: orchestrator composes collaborators but contains no business rule or SQL
public class PaymentProcessor {
    private final PricingService pricing;
    private final PaymentRepository repository;

    public PaymentProcessor(PricingService pricing, PaymentRepository repository) {
        this.pricing = pricing; // WHY constructor injection instead of new: test can supply fakes
        this.repository = repository;
    }

    public void process(Order order) {
        double amount = pricing.calculateTotal(order); // WHY pricing call is isolated: reviewing GST does not re-review persistence
        repository.save(order.getId(), amount);
    }
}

The reviewer who owns pricing now approves PricingService alone, and the DB owner approves PaymentRepository alone. Tests mirror that split. PricingService is verified with pure arithmetic fixtures that never construct a connection, while PaymentRepository can be tested against an in-memory fake or an integration harness without importing discount logic.

Real Trade-off

SRP adds files. A six-responsibility god class becomes six focused classes plus an orchestrator and their tests, so IDE navigation, constructor wiring, and onboarding checklists grow. In a tiny script that runs once, that decomposition is speculative ceremony. The reward appears when change frequency climbs, because edits become localized and re-approval scopes shrink. A practical rule many teams follow is that when a second stakeholder has requested a change to the same class in the same quarter, SRP was already overdue.

When to Violate SRP

A DTO that mirrors a REST contract, a migration script that builds a staging table and drops it, and a truly throwaway CLI for an experiment are cases where collapsing two narrowly overlapping responsibilities keeps review shorter than the benefit isolation would buy. Even there, keep the methods internally grouped so a later split requires moving a contiguous block rather than teasing logic from interwoven branches.

Open/Closed Principle (OCP)

What It Says

Software entities should be open for extension but closed for modification. In Java, that typically means using interfaces or abstract classes so new behavior is added by adding a class, not by editing a shared conditional that already ships proven behavior.

Why It Matters

OCP is a shipping safety argument. A settlement engine that handles card and UPI through an if (type.equals("UPI")) branch inside PaymentProcessor cannot welcome UPI AutoPay without editing the method that already handles card settlements. The PR diff interleaves old and new paths, reviewers must re-certify card logic on every instrument addition, and feature-flag coverage becomes the only guard against a regression that the structure itself invited. When the dispatch is polymorphic, the card path is literally closed: no line inside it changed, so no card test is expected to fail.

Bad Code: Branching That Must Be Edited Per Instrument

// WHY this violates OCP: every new payment method edits the same method
public class SettlementService {
    public void settle(Order order, String method) {
        if ("CARD".equals(method)) {
            System.out.println("Settling via card gateway");
        } else if ("UPI".equals(method)) {
            System.out.println("Settling via UPI gateway");
        } else {
            throw new IllegalArgumentException("Unsupported method " + method);
        }
        // WHY modification risk: adding UPI_AUTOPAY touches card and UPI branches in diff review
    }
}

Good Code: Extension by Addition Through Abstraction

// WHY: abstraction closed for modification — client depends on PaymentMethod, never on concrete branches
public interface PaymentMethod {
    void pay(Order order);
}

public class CardPayment implements PaymentMethod {
    @Override public void pay(Order order) {
        // WHY: card-specific gateway detail stays inside CardPayment, not in a shared dispatcher
        System.out.println("Settling via card gateway for order " + order.getId());
    }
}

public class UpiPayment implements PaymentMethod {
    @Override public void pay(Order order) {
        System.out.println("Settling via UPI gateway for order " + order.getId());
    }
}

// WHY: new variant added without touching SettlementService — true extension by addition
public class UpiAutoPayPayment implements PaymentMethod {
    @Override public void pay(Order order) {
        System.out.println("Settling via UPI AutoPay with mandate for order " + order.getId());
    }
}

public class SettlementService {
    // WHY: closed dispatcher programs against abstraction, so review proves no regression in existing methods
    public void settle(Order order, PaymentMethod method) {
        method.pay(order);
    }
}

The settlement test suite now shows the payoff. Card settlement tests never import UpiAutoPayPayment, so they cannot be accidentally coupled to its failure modes. UPI AutoPay introduces its own test file and a new wiring entry at the composition root, which is a feature-flag decision rather than a diff across shared logic.

Real Trade-off

Abstractions introduce indirection. Debugging a five-method interface introduces one more hop than scanning a single dispatcher, and a large family of tiny strategy classes can make onboarding feel like browsing a directory rather than reading a method. Plugin architectures recover that cost instantly, but small services with two fixed variants may spend more time creating strategy classes than they save in regression defense. The balanced move is to extract the abstraction at the third variant or when a real plugin requirement appears.

When to Violate OCP

Configuration-driven branching via an enum and a single switch is reasonable when the set is closed by product, such as a fixed set of settlement modes that the contract says will never grow. A generated switch over a protobuf oneof with exhaustive handling is another case where edit-per-variant is expected and review tools already verify completeness.

Liskov Substitution Principle (LSP)

What It Says

Objects of a superclass should be replaceable with objects of a subclass without affecting correctness. Barbara Liskov phrased it as a contract: subtypes must honor the invariants, preconditions, and postconditions that callers of the parent type rely on.

Why It Matters

LSP is the test for whether inheritance actually models substitutability. When a subclass throws UnsupportedOperationException for a method that the parent promises, every caller that was written against the parent must now know which child it holds before calling. That nullifies polymorphism: the caller must branch, which is the coupling inheritance was supposed to remove. A principled subtype never weakens the base promise.

Bad Code: Subclass That Breaks the Contract

// WHY this hierarchy looks honest in a class diagram but fails at the call site
public class Bird {
    public void fly() {
        System.out.println("Flying"); // WHY parent establishes fly as universally supported
    }
}

public class Penguin extends Bird {
    @Override public void fly() {
        // WHY violation: subtype cannot fulfill the parent promise, so callers must now branch before calling
        throw new UnsupportedOperationException("Penguins cannot fly");
    }
}

// Client that passed review against Bird now crashes at runtime when handed a Penguin
// void migrate(Bird bird) { bird.fly(); } // WHY hazardous: compiles but fails per runtime subtype

Good Code: Segregated Abstractions That Keep the Contract True

// WHY: not every bird flies, so the promise is moved to a capability interface rather than a universal base class
public interface Flyable {
    void fly(); // WHY capability interface: only types that truly can fly implement it
}

public interface Swimmable {
    void swim();
}

public class Sparrow implements Flyable {
    @Override public void fly() {
        System.out.println("Sparrow flying");
    }
}

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

// WHY: caller declares capability it needs, so substitution holds without instanceof or try/catch
public class MigrationService {
    public void migrate(Flyable flyable) { flyable.fly(); }
    public void migrateSwimmer(Swimmable swimmable) { swimmable.swim(); }
}

The cleaner production analogy is settlements. Settlement { void settle() } must not be extended by PreviewSettlement that overrides settle() to throw. The preview type was not a stronger settlement; it was a different abstraction. Modeling Settleable and Previewable separately keeps every substitution honest, and that same narrowing is the doorway into the next principle.

Real Trade-off

Strict LSP pushes designs toward more interfaces rather than deep inheritance hierarchies, which is healthy but can feel like an explosion of tiny types. Deep hierarchies become shallow capability bundles, and developers who learned inheritance as the default reuse tool must unlearn the reflex to extend for sharing. The reward is that call sites compile only when promises are actually kept, which is precisely the invariant production incidents demand.

When to Violate LSP

Deliberate non-substitutability is reasonable only when you are not claiming a subtype relationship. A legacy API that historically exposed Square extends Rectangle may retain that name while documenting that square is not substitutable and diverging the types in the next major version. New Java code should instead redesign the hierarchy rather than annotate around the violation.

Interface Segregation Principle (ISP)

What It Says

A class should not be forced to implement interfaces it does not use. Instead of one fat interface, create small, specific interfaces so consumers depend only on the capability they actually need.

Why It Matters

Fat interfaces punish every consumer with methods they never call. The moment a reporting job imports PaymentProcessor that declares settle(), refund(), reconcile(), and generateDailyReport(), the reporting job’s compilation depends on refund semantics to exist, its test setup must provide a refund collaborator, and its deployment graph includes classes that production never loads for reporting but that can still introduce a failure at startup when someone changes refund configuration. Segregated interfaces keep the graph minimal.

Bad Code: Fat Interface Forces Unused Implementations

// WHY this interface is fat: it bundles settlement, refund, and reporting in one contract
public interface Worker {
    void work();
    void eat();
    void sleep();
}

// WHY violation: SuperWorker and RobotWorker are both forced to implement behavior they never use
public class RobotWorker implements Worker {
    @Override public void work() { System.out.println("Robot working"); }
    @Override public void eat() { /* WHY dead code: robots do not eat, but interface requires it */ }
    @Override public void sleep() { /* WHY dead code: same for sleep */ }
}

Good Code: Segregated Capability Interfaces

// WHY: each capability is its own interface with a single coherent reason to change
public interface Workable { void work(); }
public interface Eatable { void eat(); }
public interface Sleepable { void sleep(); }

public class HumanWorker implements Workable, Eatable, Sleepable {
    @Override public void work() { System.out.println("Human working"); }
    @Override public void eat() { System.out.println("Human eating"); }
    @Override public void sleep() { System.out.println("Human sleeping"); }
}

// WHY: robot depends only on work, so its test and lifecycle need not mention eat or sleep
public class RobotWorker implements Workable {
    @Override public void work() { System.out.println("Robot working"); }
}

// WHY: client method can depend on the smallest interface it needs, keeping compilation scope narrow
public class TaskRunner {
    public void run(Workable worker) { worker.work(); }
}

In checkout terms, PaymentGateway { void charge() } plus Refundable { void refund() } and Reconcilable { void reconcile() } is superior to a single PaymentProcessor interface that every integration must fully implement. A gateway that does not support refunds simply does not implement Refundable, and callers that only charge never import refund types at all, which keeps the DI graph and the failure surface honest.

Real Trade-off

Segregation increases the interface count and requires a deliberate decision about where to draw the boundary. Two-method splits can feel excessive if the consumer almost always needs both capabilities together; in that case a composed interface that extends both preserves the convenience without merging the underlying segregation. The cost is therefore drafting more types up front, which pays back during refactoring when one capability’s contract changes but the other’s consumers remain unaffected.

When to Violate ISP

In a tiny internal tool with two concrete classes and a single call site, a two-method interface that precisely matches the only consumer is cleaner than three single-method capability interfaces that are never used separately. If every consumer needs the combined set, the segregation is speculative and the fat interface is in practice the right abstraction until the graph genuinely diverges.

Dependency Inversion Principle (DIP)

What It Says

High-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions. In practice, that means depending on PaymentGateway rather than RazorpayGateway and letting the composition root choose the concrete.

Why It Matters

Direct dependencies on concrete gateways freeze the environment. A notification service that internally calls new EmailService() cannot be exercised without a real SMTP connection, cannot fail over to SmsService, and cannot swap in a partner in staging without editing the service. Inverted dependencies move the binding decision outside the class to configuration or a DI container, which makes environments composable, retries policy-driven, and tests isolated via fakes.

Bad Code: High-Level Service Constructs Its Own Low-Level Dependency

// WHY low-level detail: concrete gateway class is a policy decision tied to environment and partner
public class RazorpayGateway {
    public void charge(double amount) { System.out.println("Charging via Razorpay " + amount); }
}

public class OrderService {
    // WHY violation: OrderService depends directly on RazorpayGateway; no interception point for test or failover
    private final RazorpayGateway gateway = new RazorpayGateway();

    public void placeOrder(Order order, double amount) {
        // WHY hard coupling leaks into tests: unit test must now handle real gateway I/O or reflection
        gateway.charge(amount);
        System.out.println("Order placed " + order.getId());
    }
}

Good Code: Both Sides Depend on an Abstraction

// WHY: abstraction owns the contract, not the detail, so high and low levels depend on the same policy
public interface PaymentGateway {
    void charge(double amount);
}

public class RazorpayGateway implements PaymentGateway {
    @Override public void charge(double amount) {
        // WHY: detail depends on abstraction, so it can be swapped without editing OrderService
        System.out.println("Charging via Razorpay " + amount);
    }
}

public class StripeGateway implements PaymentGateway {
    @Override public void charge(double amount) {
        System.out.println("Charging via Stripe " + amount);
    }
}

// WHY: high-level module now depends on abstraction, so policy lives at the composition root
public class OrderService {
    private final PaymentGateway gateway;

    // WHY constructor injection inverts construction: test supplies a fake, prod supplies Razorpay, staging swaps Stripe
    public OrderService(PaymentGateway gateway) {
        this.gateway = gateway;
    }

    public void placeOrder(Order order, double amount) {
        gateway.charge(amount);
        System.out.println("Order placed " + order.getId());
    }
}

// WHY: composition root is the single decision point; no business class calls new RazorpayGateway()
// OrderService service = new OrderService(new RazorpayGateway());
// vs in test
// OrderService service = new OrderService(amount -> System.out.println("Fake charge " + amount));

This inversion is the backbone for the creation patterns that extend a family without touching core flow. The factory variants in Factory Method Design Pattern Java Simple Detailed Examples and Abstract Factory Pattern in Java: Explanation and Example both assume the caller depends on a creator or kit abstraction, not on a concrete product constructor, and the singleton hardening story in Singleton Creational Design Pattern Java Explained also benefits when shared resources are discovered through an interface rather than a concrete holder.

Real Trade-off

Inversion adds wiring. A class that previously did new RazorpayGateway() inline now receives its gateway through a constructor, so callers must supply it and the graph must be documented. For a script with one gateway that will never change, that wiring is pure overhead. For a checkout service whose gateway varies by region, partner SLA, and retry experiment, the same wiring is the difference between a config change and a code change across dozens of services.

When to Violate DIP

Utilities with no environment variance, such as java.time.Clock when you choose not to abstract it in a leaf script, and stable JDK types like String or List where the abstraction is the type itself, do not earn an extra interface. Reserve DIP for dependencies whose implementation is expected to vary by environment, partner, or test case.

Real-World Example: How Spring Framework and Netflix Apply SOLID

Spring Framework is DIP and OCP at container scale. Spring’s ApplicationContext is literally a dependency inversion machine: business code declares constructors such as OrderService(PaymentGateway gateway, PaymentRepository repo) and the container wires the concrete choice per profile. The business layer never names RazorpayGateway in an import that matters for behavior. OCP follows from that wiring. Adding UpiAutoPayGateway implements PaymentGateway and binding it behind a payment.gateway=upi-autopay property is extension by addition. The order flow PR shows zero edits to OrderService, so the reviewer who guards settlement correctness only validates wiring, not core logic. Spring’s PlatformTransactionManager exercises the same shape: DataSourceTransactionManager and JtaTransactionManager share the abstraction, and swapping the transaction policy does not edit business services.

Netflix’s governance of notification and encoding families shows SRP, ISP, and LSP. A notification platform that supports email, push, and SMS is routinely fronted by per-channel factories such as EmailNotificationFactory implements NotificationFactory. SRP places templating, throttling, and dispatch each in its own class, so changing push throttle policy does not re-review email rendering. ISP keeps Emailable, Pushable, and Smsable separate so a push-only experiment import does not drag in email template compilation as a transitive dependency. LSP keeps PushNotificationSender implements NotificationSender substitutable so the orchestrator that calls sender.send(message) never branches on instanceof. Netflix’s Conductor and Ribbon era services made those channel and retry strategy boundaries explicit for exactly that reason, and the encoding pipeline does the same per bitrate profile: each profile factory returns a coherent kit of encoder, muxer, and metadata writer, so no code path silently wires a 4K encoder to a mobile muxer.

Both organizations arrive at the same conclusion through startup cost. Indirection is budgeted as an investment whose dividend is narrow ownership, narrow test setup, and narrow merge contention, which is why their DI and factory layers pair explicitly with code ownership files that assign reviewers per abstraction boundary.

When to Use vs When NOT to Use SOLID

SOLID is not an all-or-nothing gate. It is a set of guardrails whose value tracks change frequency and team shape. Use it when a module is widely depended upon, when change actors are distinct, and when extension is routine. Reach for lighter structure when the module is closed, isolated, or short-lived.

Use SOLID when:

  • The module sits on a hot path such as checkout, settlement, or request dispatch that several teams edit quarterly. Localizing each team’s edits behind a dedicated interface reduces merge contention and review sprawl in direct proportion to team count.
  • The domain has capabilities that naturally appear and disappear by integration. Payment methods, notification channels, and storage vendors each reward OCP plus ISP because the extension axis is predictable even if the next variant name is not.
  • Tests need focused doubles. Inverted dependencies and narrow interfaces let a pricing test pass pure values without constructing a repository, and let a gateway test fake charge without wiring a database.

Avoid or relax SOLID when:

  • The code is a one-off migration, a benchmark harness, or an internal admin screen that will never acquire a second gateway, a second storage backend, or a second rendering mode. An interface for one implementation plus its factory and segregated capability traits triples the files without halving any risk.
  • The team is still discovering the domain boundary. Premature SRP splits that cut through a concept the product has not settled on force churn across six interfaces when one cohesive class would have absorbed the iteration. Wait until the bounded context clarifies, then split along actors rather than method count.
  • The runtime is intentionally closed. Generated protobuf oneof dispatch, compile-time exhaustive switches, and single-vendor contracts that are sealed by legal rather than code are better expressed as explicit branching than speculative polymorphism.
DimensionUse SOLIDWhen to Relax
Responsibility countOne actor per class, pricing vs persistence separatedDTO, script, or migration where grouping is intentional
Extension modelNew payment method added by new classClosed enum where exhaustive switch is the real invariant
Hierarchy healthSubtypes honored in every call siteSingle legacy subtype whose non-substitutability is documented debt
Interface shapeCapabilities split so consumers import only what they callTiny tool where every consumer needs the same two methods
Dependency varianceGateway varies by env, partner, or testStable stdlib type with no expected variation

Treat the table as a pre-merge checklist. If the planned change lands in the left column, the principles protect the next quarter’s velocity. If it lands squarely in the right column, the simplest structure that compiles is the responsible choice and the design remains honest.

Common Pitfalls

Splitting responsibilities by method count rather than by change actor. Teams eager to apply SRP often cut a cohesive domain concept into OrderValidator, OrderCalculator, OrderSaver, and OrderNotifier when all four methods actually change for the same reason: the definition of what an order is expanded to include a new fulfillment type. The resulting shuffle moves logic across four files per product change, which is the merge contention the principle was meant to reduce. Fix this by asking which stakeholder requests the change. If the same product owner drives the edit to validation and calculation together, those two responsibilities belong together until their review actors diverge.

Hiding a switch inside a polymorphic dispatch. Declaring interface PaymentMethod { void pay(Order o, String type) } and then having CardPayment internally switch on type preserves the hierarchy shape while keeping the branching that OCP was meant to remove. Reviewers see the extension but miss the fact that card logic still accumulates flags. Keep factory methods parameterless per concrete strategy or, when a discriminator is genuinely needed, centralize it once at the composition root and let each strategy handle exactly one variant.

Segregating interfaces that are never independently consumed. Extracting Chargeable, Refundable, Reconcilable, and Reportable when every production flow chains charge then reconcile and then report replaces one honest fat interface with four ceremony interfaces that always travel together. The graph becomes correct on paper but tedious to wire, which is why segregations that do not match independently exercised consumer paths tend to be recombined informally by later authors. Validate ISP changes by naming two consumers that will actually import the interfaces separately; if that pair does not exist, the split is premature.

Interview Questions

1. What is SOLID and why would a payment team at Razorpay adopt it over a simple utility with static helpers?

SOLID is a set of five object-oriented principles — Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — that collectively reduce coupling and make systems grow by addition rather than by editing shared files. A payment team adopts it because payment logic has multiple change actors: tax policy, settlement partners, and observability each demand their own edits, and static helpers force all of those edits into the same file. SRP isolates each actor’s code, OCP lets a new settlement method ship as a new class, LSP guarantees that the new method can substitute wherever the abstraction is expected, ISP keeps consumers from importing capabilities they never call, and DIP makes the concrete gateway a wiring decision rather than a hard import. The consequence in review is smaller PRs, narrower test fixtures, and a wiring diff at the composition root rather than a cross-cutting change.

2. How does SRP improve cohesion, and where would you deliberately keep two responsibilities together?

SRP improves cohesion by tying a class’s lifetime to a single stakeholder’s change requests, so pricing, persistence, and notification each live where their single owner expects to find them. That cohesion shrinks regression blast radius: a pricing diff does not re-review DB transaction boundaries, and a schema diff does not re-review discount math, because ownership and tests align with the single responsibility. You deliberately keep responsibilities together when the product has not settled its bounded context. An early-stage order type whose validation and totals calculation evolve together for the same actor should not be split into Validator and Calculator until review history shows distinct owners competing on the same file.

3. How does OCP help you add UPI AutoPay to a settlement engine without re-approving card settlements?

OCP is satisfied when card and UPI flows live behind PaymentMethod { void pay(Order o) } and SettlementService depends only on that abstraction. Introducing UPI AutoPay means adding UpiAutoPayPayment implements PaymentMethod and binding it through configuration or a single factory selection method. No line inside CardPayment or UpiPayment changes, so their line coverage and prior certifications remain valid and review attention concentrates on the new class and its wiring. The alternative you will often see for small single-axis domains is the simpler centralized creation in Factory Design Pattern Java Simplified, which is preferable when the variant set is closed. Once addition without touching proven flows becomes the recurring requirement, OCP is the guardrail that makes the addition closed for the existing codebase.

4. What is a concrete LSP violation, and how would you fix the classic Bird and Penguin example?

A violation occurs when a subclass weakens the base promise so callers written against the base fail at runtime. The classic case is Bird { void fly() } with Penguin extends Bird { void fly() { throw ... } }. A method void migrate(Bird b) { b.fly(); } passes review because every Bird was supposed to fly, but crashes when handed a Penguin. The fix is to move the promise to capabilities rather than to a universal base type: declare Flyable { void fly() } and Swimmable { void swim() }, have Sparrow implements Flyable and Penguin implements Swimmable, and type the caller to the capability it actually needs. Call sites then compile only for substitutable types, and the hierarchy reports honest intent rather than inheriting a promise it cannot keep, which is the same discipline settlement services need when Previewable is separated from Settleable.

5. Why is ISP about import cost rather than just method count, and how does it apply to a gateway interface?

ISP matters because every method on an interface becomes a transitive dependency for every consumer, even when the consumer never calls that method. A fat PaymentOperations { void charge(); void refund(); void reconcile(); } forces a charge-only checkout path to import refund types, to supply refund collaborators in tests, and to carry refund configuration into its deployment graph, which widens both compile-time and startup-time failure surface. Splitting the interface into Chargeable, Refundable, and Reconcilable lets the checkout module depend on Chargeable alone, so its test harness can fake charge behavior and its configuration can omit refund wiring entirely. The cost shifts from one fat interface to several narrow ones, but the benefit is narrower failure scope per module, which is why teams that merge ISP with DI see the fastest reduction in startup-coupled incidents.

6. How does DIP differ from OCP, and how would you demonstrate inversion in a Spring service?

OCP is about being able to add behavior without modifying existing code, typically through an interface and polymorphic dispatch. DIP is the structural choice that makes OCP viable at construction: high-level modules such as OrderService must depend on an abstraction PaymentGateway rather than on RazorpayGateway, so the wiring decision lives outside the business class. In Spring, inversion is demonstrated by constructor injection: OrderService(PaymentGateway gateway) receives its collaborator from the container, and profiles bind RazorpayGateway in production and a FakeGateway in tests. Business code never calls new RazorpayGateway(), so swapping to StripeGateway or introducing UpiAutoPayGateway is a configuration edit rather than a business logic edit. When the next interviewer asks for the difference, anchor it this way: DIP inverts who decides the implementation, and OCP cashes that decision as extension without modification.

Conclusion

SOLID pays for its indirection when change is frequent and ownership is shared:

  1. Single responsibility localizes ownership. Keep pricing, persistence, and dispatch in separate classes so each stakeholder approves exactly the file that reflects their decision.
  2. Open-closed makes extension additive. Put new settlement modes and notification channels behind PaymentMethod or NotificationSender abstractions so the card and email paths are literally untouched by the new variant’s PR.
  3. Liskov substitution keeps polymorphism honest. Model capabilities such as Flyable and Swimmable so every call site compiles only when substitution is truly safe.
  4. Interface segregation keeps graphs narrow. Split broad processor interfaces into focused capabilities so a caller imports only what it exercises in tests and in deployment.
  5. Dependency inversion makes wiring a decision, not an import. Inject PaymentGateway rather than constructing it, so region, partner, and test swap at the composition root.
  6. Relax deliberately when the domain is closed. A migration script, a closed enum, or a truly single-vendor contract earns the simpler structure, and you can graduate to SOLID at the point change history shows competing actors or repeated extensions.

The next topic in this series covers Builder – assembling many optional fields immutably – which complements SOLID by solving construction complexity once your classes have been properly separated, and the family-oriented counterpart Abstract Factory Pattern in Java: Explanation and Example shows how DIP scales when products must stay consistent as a kit. For the lightest contrast that centralizes branching when only one product varies, revisit Factory Design Pattern Java Simplified.

References

  1. SOLID Principles - Refactoring Guru
    https://refactoring.guru/design-patterns/catalog
  2. SOLID Principles of Object-Oriented Design - DigitalOcean
    https://www.digitalocean.com/community/conceptual-articles/s-o-l-i-d-the-first-five-principles-of-object-oriented-design
  3. SOLID Design Principles Explained - Baeldung
    https://www.baeldung.com/solid-principles

YouTube Videos

  1. “Master SOLID Principles in Java | Complete Guide with Real Examples (2026)“
    https://www.youtube.com/watch?v=ubEWGy4xaxw

  2. “SOLID Principles in Java Explained with One Real-World Example”
    https://www.youtube.com/watch?v=Z7b58QBACXA


Share this post on:

Next in Series

Continue through the [object Object] with the next recommended article.

Related Posts

Keep Learning with New Posts

Subscribe through RSS and follow the project to get new series updates.

Was this guide helpful?

Share detailed feedback

Previous Post
Singleton Design Pattern in Java with Examples
Next Post
SQL vs NoSQL: How to Choose in System Design