Solid Principles Interview Questions | JiQuest

add

#

Solid Principles

Java design principles deep dive

SOLID Principles in Java: 100 interview questions with professional answers.

SOLID is five object-oriented design principles coined by Robert C. Martin: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. Together they guide maintainable, extensible object-oriented design, and this guide covers all five with Java code, realistic violations, and the concrete refactors interviewers expect.

100Scenarios
5Principles
4+Related patterns
SOLID S one reason to change O extend without modifying L substitutable subtypes I no fat interfaces D depend on abstractions Five independent principles, one goal: change without fear

What makes a good SOLID answer?

Interviewers want to see you spot a violation in real code and explain its concrete consequence, a bug it enables, a test that becomes hard to write, a change that ripples too far, not just recite a one-line definition.

Guidelines, not lawsSOLID principles are guidelines, not laws; dogmatic over-application causes needless abstraction and speculative generality.
Name the smellA strong answer names the specific smell in the code, not just the principle's name, and ties it to a concrete failure mode.
Violations overlapMost real-world violations involve more than one SOLID letter at once; naming the primary one is enough, mentioning the secondary one is a bonus.
Refactor incrementallyRefactoring toward SOLID should be incremental, driven by an actual pain point in the code, not a wholesale rewrite.
Class doing too many unrelated things? S Editing existing code every time a new case appears? O Subclass breaks when substituted for its base type? L Class forced to implement irrelevant methods? I High-level code directly new-ing low-level concrete classes? D
PrincipleOne-line intentCommon violation smellTypical fix
Single Responsibility (S)A class should have only one reason to change.A God class mixing computation, formatting, persistence, and notification.Split into focused classes, one per reason to change, wired together by the caller.
Open/Closed (O)Open for extension, closed for modification.A growing if/else or switch chain edited every time a new case appears.Introduce an interface or abstract class; add new cases as new implementations.
Liskov Substitution (L)Subtypes must be usable anywhere their base type is expected.A subclass that throws, weakens a guarantee, or silently no-ops on override.Redesign the hierarchy so every subtype honors the base type's full contract.
Interface Segregation (I)No client should depend on methods it doesn't use.A fat interface forcing unrelated implementers to stub out irrelevant methods.Split into small, role-specific interfaces implemented independently.
Dependency Inversion (D)Depend on abstractions, not concrete low-level details.High-level business logic directly instantiating a concrete infrastructure class.Introduce an interface owned by the high-level module; inject the implementation.

Topics

SRP core intent Q1ReportGenerator refactor Q2Cohesion vs coupling Q3 SRP at class level Q4SRP at method level Q5SRP at package level Q6 SRP at microservice level Q7God class antipattern Q8SRP vs Unix philosophy Q9 Layered architecture Q10Slow-burn SRP symptom Q11Testing benefits Q12 Over-splitting trade-off Q13Reason vs responsibility Q14User class example Q15 Fat REST controller Q16Code review signals Q17The "actor" concept Q18 Constructor injection signal Q19SRP capstone refactor Q20OCP core intent Q21 DiscountType example Q22Polymorphism over conditionals Q23Strategy enables OCP Q24 Template Method enables OCP Q25Decorator enables OCP Q26Avoiding switch chains Q27 Plugin architectures Q28Protected variation Q29Speculative generality Q30 Payment gateway OCP Q31Sealed interfaces nuance Q32Legacy OCP refactor Q33 Testing OCP code Q34Factory relationship Q35OCP vs LSP Q36 ServiceLoader / SPI Q37Enum extension tension Q38OCP code smells Q39 OCP capstone refactor Q40LSP core intent Q41Rectangle/Square violation Q42 Precondition strengthening Q43Postcondition weakening Q44Exception contract violation Q45 Covariant returns vs params Q46instanceof smell Q47No-op override smell Q48 Bird/Penguin redesign Q49equals/hashCode violation Q50UnsupportedOperationException Q51 Design by contract Q52Shared abstract test suite Q53Array covariance caveat Q54 Null vs Optional violation Q55Mutable subclass of immutable Q56Final methods/classes Q57 Narrowed valid inputs Q58Composition over inheritance Q59LSP capstone redesign Q60 ISP core intent Q61Fat Machine split Q62Multi-function vs simple printer Q63 ISP vs SRP Q64Default methods nuance Q65Testing benefits of small interfaces Q66 Read/Write repository split Q67Role vs header interfaces Q68Worker/RobotWorker example Q69 ISP + Adapter/Facade Q70ISP code smells Q71IDE stub pollution Q72 Fat listener interfaces Q73Segregated API contracts Q74Generic Repository<T> bloat Q75 Vehicle.fly() example Q76Package-level segregation Q77Functional interfaces and ISP Q78 Empty method bodies signal Q79ISP capstone UserService split Q80DIP core intent Q81 NotificationService example Q82DIP vs DI vs IoC Q83Spring constructor injection Q84 Direct new violations Q85DIP and testability Q86Domain importing DB class Q87 Package dependency direction Q88PaymentProcessor refactor Q89DIP and GoF patterns Q90 IoC container wiring Q91Dependency arrow diagram Q92Service layer + SQLException Q93 Multiple implementations Q94Injection style trade-offs Q95POJO testing benefit Q96 Vendor SDK in signatures Q97Clean/Onion architecture Q98DIP vs OCP nuance Q99 SOLID capstone refactor Q100

Interview questions and answers

Each answer gives the core idea, a worked Java example where it clarifies the point, and the production consequence that makes the answer stronger than a textbook definition.

SSingle Responsibility Principle – Q1–Q20

1. State the Single Responsibility Principle precisely and explain what "a class should have only one reason to change" actually means in practice.

SRP says a class should have exactly one reason to change, meaning it should answer to, and be responsible to, only one source of requirements. "Reason to change" is not the same as "does one thing" in a narrow procedural sense; it refers to one axis of business or technical concern whose evolution should not force unrelated code to be touched.

A class that both calculates payroll tax and formats a printed pay stub has two reasons to change: a tax law update and a print-layout redesign, driven by entirely different stakeholders. SRP asks you to separate those axes so each can evolve independently.

FoundationalReason to change

2. Refactor a ReportGenerator class that computes report data, formats it as HTML, and saves it to disk into SRP-compliant classes, and explain the benefit of the split.

The original class mixes three unrelated responsibilities behind one API, so a change to the storage location, the output format, or the calculation logic all require editing the same file and risk breaking the other two concerns by accident.

// Before: one class, three reasons to change
class ReportGenerator {
    ReportData compute(Sales sales) { /* aggregation logic */ return null; }
    String formatAsHtml(ReportData data) { /* HTML templating */ return null; }
    void saveToDisk(String html, String path) { /* file I/O */ }
}

// After: one responsibility per class
class ReportDataCalculator {
    ReportData compute(Sales sales) { /* aggregation logic only */ return null; }
}
class HtmlReportFormatter {
    String format(ReportData data) { /* HTML templating only */ return null; }
}
class ReportStorage {
    void save(String html, String path) { /* file I/O only */ }
}

A caller composes the three: compute, then format, then save. Each class can now be unit tested, replaced, or reused independently, for example swapping HtmlReportFormatter for a PdfReportFormatter without touching calculation or storage code.

3. Explain the relationship between cohesion, coupling, and the Single Responsibility Principle, and why high cohesion inside a class supports low coupling between classes.

Cohesion measures how closely the methods and fields inside one class relate to a single purpose; coupling measures how tightly separate classes depend on each other. SRP is essentially a mandate for high cohesion: when a class has one responsibility, its fields and methods naturally cluster around that one concern.

High cohesion tends to reduce coupling as a side effect, because a focused class exposes a narrower, more stable public surface, giving other classes fewer reasons to reach into its internals or depend on incidental details that later change.

CohesionCoupling

4. Describe what applying the Single Responsibility Principle looks like specifically at the individual class level, with a concrete counter-example.

At the class level, SRP means every public method on a class should serve the same single purpose. A counter-example is a Configuration class that both parses YAML files and validates business rules against environment-specific policy; a syntax change to the YAML library and a new validation rule are unrelated reasons to change, yet both land in the same class.

class ConfigLoader {
    Config parseYaml(String path) { /* parsing only */ return null; }
}
class ConfigValidator {
    void validate(Config config) { /* business rule checks only */ }
}

5. Explain how the Single Responsibility Principle applies at the individual method level, not just the class level, and give an example of a method that violates it.

Even inside an already well-scoped class, a single method can violate SRP by doing more than one job, most commonly validating input and performing the operation in the same block, or computing a value and logging or emitting a side effect at the same time.

// Violates SRP at method level: validates, computes, and persists in one method
void processOrder(Order order) {
    if (order.items().isEmpty()) throw new IllegalArgumentException("empty order");
    double total = order.items().stream().mapToDouble(Item::price).sum();
    orderRepository.save(order.withTotal(total));
}

// Split: one small method per concern, composed by a caller
void validate(Order order) { if (order.items().isEmpty()) throw new IllegalArgumentException("empty order"); }
double computeTotal(Order order) { return order.items().stream().mapToDouble(Item::price).sum(); }
void persist(Order order) { orderRepository.save(order); }

6. Explain how the Single Responsibility Principle scales up to the package level, and what a package-level violation looks like in a Java project.

At the package level, SRP suggests grouping classes so that a package as a whole answers to one cohesive area of the system, rather than becoming a dumping ground for unrelated helpers. A package-level violation looks like a generic com.company.util package that mixes date formatting, HTTP client wrappers, currency rounding, and email validation, so a change to any unrelated concern still shows up in the same package's diff and release notes.

The fix is the same idea applied one level up: split into com.company.util.time, com.company.http, com.company.money, each owning one cohesive concern with its own reason to change.

7. Explain how the Single Responsibility Principle motivates service boundaries in a microservices architecture, and what happens when a service is granted too many responsibilities.

At the microservice level, SRP suggests each service should own one bounded business capability, such as inventory management or billing, so that a change driven by one business stakeholder does not require redeploying a service that also serves an unrelated stakeholder's concern.

A service that combines order processing and customer support ticketing has two reasons to change owned by two different teams; a schema change for one forces a coordinated deployment and testing cycle for the other, which is exactly the coupling SRP tries to prevent, just expressed at the scale of a whole deployable unit instead of a class.

Bounded contextService ownership

8. What is a "God class" antipattern, how does it relate to the Single Responsibility Principle, and what warning signs indicate a class is becoming one?

A God class accumulates responsibilities over time until it knows about, and is depended on by, huge swaths of the codebase, becoming a single class that both orchestrates business rules and reaches into persistence, validation, and formatting concerns that should each live elsewhere. It is the most extreme, systemic violation of SRP.

Warning signs include a class file that keeps growing every sprint regardless of which feature is being built, a constructor with a dozen or more unrelated collaborator dependencies, and a class name ending in generic suffixes like Manager, Processor, or Helper that no one can describe in one sentence.

Watch out Utility classes full of unrelated static methods are the same antipattern in a different shape; they just accumulate unrelated responsibilities as static functions instead of instance methods.

9. Compare the Single Responsibility Principle to the Unix philosophy of "do one thing and do it well," and explain the subtle difference between the two ideas.

Both ideas favor small, focused units of behavior, but the Unix philosophy is about doing one narrow task extremely well from a functional, black-box perspective, useful for describing a single method or a small utility program. SRP is a design principle about the axis of change: a class can technically "do one thing" from a functional description yet still have two reasons to change if that one thing serves two different stakeholders with independently evolving requirements.

In practice the two align most of the time, but SRP is the more precise lens for deciding class boundaries in a large codebase, because it asks who requests the change, not just what the code computes.

10. Explain how a standard layered architecture, with separate controller, service, and repository layers, embodies the Single Responsibility Principle.

A controller's one responsibility is translating HTTP concerns, request parsing, status codes, and serialization, into and out of domain calls. A service's one responsibility is orchestrating business rules. A repository's one responsibility is persistence mechanics. Each layer changes for a distinct reason: an API contract change touches only the controller, a business rule change touches only the service, and a schema or query optimization touches only the repository.

@RestController
class OrderController {
    private final OrderService orderService;
    @PostMapping("/orders")
    ResponseEntity<OrderResponse> create(@RequestBody OrderRequest req) {
        Order order = orderService.placeOrder(req.toDomain());
        return ResponseEntity.ok(OrderResponse.from(order));
    }
}
class OrderService {
    private final OrderRepository repository;
    Order placeOrder(Order order) { /* business rules */ return repository.save(order); }
}

11. Describe an SRP violation that is invisible on day one but reveals itself only after months of maintenance, when every unrelated change keeps touching the same class.

A class that started as a clean InvoiceService can silently accumulate SRP violations one small "just add this here" change at a time: a tax calculation tweak, then an email notification call, then a PDF export helper, none individually alarming, until the class's git blame shows commits from finance, notifications, and reporting teams all landing in the same file within the same quarter.

This symptom, "every unrelated change touches this one class," is often a more reliable real-world SRP detector than staring at the code in isolation, because it surfaces the actual, empirically observed reasons to change rather than a guess about future ones.

Practical tip Run git log --follow on a suspiciously large class; if commit messages span unrelated feature areas, that class is accumulating responsibilities it should shed.

12. Explain the testing benefits of SRP-compliant classes, specifically why a class with a single responsibility tends to have fewer reasons for its tests to break.

A class with one responsibility needs only the collaborators and edge cases relevant to that one concern, so its unit tests exercise a narrow, stable surface. When an unrelated concern changes elsewhere in the system, this class's tests remain green, because nothing about its actual responsibility changed.

A God class, by contrast, has tests that break for reasons unrelated to what a given test is actually checking, for example a formatting test failing because a persistence dependency's constructor signature changed, which erodes confidence in what a red test actually means and encourages people to skip investigating failures carefully.

13. What is the trade-off of over-applying the Single Responsibility Principle by splitting a class into too many extremely narrow classes, and how do you recognize you have gone too far?

Splitting aggressively can produce a swarm of tiny classes, each holding one field and one method, that forces a reader to jump across a dozen files to understand one operation, replacing a maintenance problem with a navigation and wiring problem. Excess indirection also adds constructor-injection boilerplate and can hide the actual business logic behind layers of pass-through delegation.

A reasonable heuristic is: split only when two concerns genuinely change for different reasons at different times, driven by different stakeholders; if two pieces of logic have never changed independently and are unlikely to, keeping them together, well-organized inside one class, is a legitimate SRP-respecting choice, not a violation.

Over-engineeringReal trade-off

14. Precisely distinguish "reason to change" from "responsibility" in the context of SRP, since the two terms are often used loosely and interchangeably.

A responsibility is a cohesive area of functionality a class owns, such as "calculating shipping cost." A reason to change is the underlying source of requirements behind that responsibility, such as "the shipping carrier's rate table changes" or "a new regional tax rule applies." A class can have one responsibility yet still have two reasons to change if that responsibility is implicitly serving two different stakeholders' independently evolving rules.

Robert Martin later refined this further by tying reasons to change to "actors," distinct groups of people or systems who would request a given change, which sharpens the definition beyond a vague sense of "does too much."

15. Identify the SRP violations in a User class that handles field validation, database persistence, and welcome-email notification, and show how to split it.

This class answers to at least three actors: a product owner defining valid user data, a DBA-adjacent concern around schema and persistence mechanics, and a marketing team owning email content and delivery. Any one of those three can force a change to a class none of the other two care about.

// Before
class User {
    void validate() { /* field rules */ }
    void save() { /* JDBC / JPA persistence */ }
    void sendWelcomeEmail() { /* SMTP call, email template */ }
}

// After
class UserValidator { void validate(User user) { /* field rules */ } }
class UserRepository { void save(User user) { /* persistence only */ } }
class WelcomeEmailSender { void send(User user) { /* email only */ } }

16. A REST controller method parses the request, validates business rules, calls three different repositories directly, and builds the JSON response by hand. Identify the SRP violations and refactor it.

This single method mixes HTTP-layer parsing, cross-cutting business validation, direct persistence orchestration, and response serialization, four distinct reasons to change compressed into one method. The fix pushes validation and orchestration into a service class, leaving the controller responsible only for translating HTTP in and out.

@PostMapping("/checkout")
ResponseEntity<CheckoutResponse> checkout(@RequestBody CheckoutRequest req) {
    CheckoutResult result = checkoutService.process(req.toDomain());
    return ResponseEntity.ok(CheckoutResponse.from(result));
}

CheckoutService now owns validation and repository orchestration, and can be unit tested without spinning up an HTTP layer at all.

17. What signals during a code review indicate that a class under review is violating the Single Responsibility Principle?

Practical review signals include: a class name that is a vague noun like Manager or Handler with no clear single description; import statements spanning unrelated packages such as email, persistence, and formatting all in one file; a constructor with far more collaborator dependencies than a single concern would plausibly need; and a diff for "fix the tax calculation" that also touches unrelated formatting code purely because it lives in the same class.

  • Can you describe the class's purpose in one sentence without using "and"?
  • Does the class's method list read like a table of contents for one topic, or several?
  • Would two different teams ever need to review a change to this file for unrelated reasons?

18. Explain Robert C. Martin's later refinement of SRP around the concept of an "actor," and why he restated the principle as "a module should be responsible to one, and only one, actor."

Martin refined the original wording because "reason to change" alone was ambiguous: two different changes might both feel like the "same reason" to one team but not another. An actor is a person or group, such as the CFO or the HR department, who could request a change to a given piece of logic. The restated principle says a module should serve exactly one actor, so that requests from different actors never force the same code to change for unrelated reasons.

This framing makes SRP violations easier to spot in interviews: name the actors who could request a change to the class in question, and if there is more than one, the class likely needs to be split along that boundary.

19. Explain why an unusually high number of constructor-injected dependencies on a class is often a practical, early-warning proxy signal for an SRP violation.

Each collaborator a class depends on typically exists to support one responsibility; a class needing an email client, a payment gateway, a tax calculator, and a PDF renderer in its constructor is very likely juggling four unrelated responsibilities rather than one cohesive one, since a genuinely single-purpose class rarely needs that many unrelated kinds of help.

// Six unrelated collaborators is a strong SRP smell, before reading a single method body
class OrderProcessor {
    OrderProcessor(PaymentGateway gw, EmailSender email, PdfRenderer pdf,
                   TaxCalculator tax, InventoryClient inventory, AuditLogger audit) { ... }
}
Constructor smellEarly warning

20. Walk through a capstone refactor of a monolithic OrderService that validates orders, applies discounts, charges payment, updates inventory, and sends confirmation emails, splitting it along SRP lines.

Each of these five behaviors answers to a different actor: a business-rules owner for validation, a marketing/finance owner for discounts, a payments team for charging, a warehouse system for inventory, and a communications team for email. The refactor extracts one collaborator per concern and leaves a thin orchestrator that sequences them.

class OrderService {
    private final OrderValidator validator;
    private final DiscountCalculator discounts;
    private final PaymentGateway payments;
    private final InventoryUpdater inventory;
    private final ConfirmationEmailSender emails;

    Order placeOrder(OrderRequest request) {
        validator.validate(request);
        Order priced = discounts.apply(request.toOrder());
        payments.charge(priced.total());
        inventory.reserve(priced.items());
        emails.sendConfirmation(priced);
        return priced;
    }
}

OrderService now has a single, coordinating responsibility, orchestration, and each collaborator can change, be tested, or be replaced independently of the others.

OOpen/Closed Principle – Q21–Q40

21. State the Open/Closed Principle precisely and explain what it means for a class to be "open for extension but closed for modification."

OCP says software entities should be open for extension, new behavior can be added, but closed for modification, the existing, already-tested source code should not need to change to support that new behavior. In Java this is achieved by coding against an abstraction, an interface or abstract class, so that adding a new case means adding a new implementation, not editing existing ones.

The benefit is that code already shipped and tested in production stays untouched, and the risk of a new feature reintroducing a regression in unrelated existing behavior drops sharply, since nothing existing was edited.

22. Refactor a discount calculator that uses an if/else chain on a discountType string into an OCP-compliant design that supports adding a new DiscountType without editing existing code.

The original method must be edited every time a new discount type is introduced, risking a regression in the existing, already-verified discount branches. The fix introduces a Discount interface with one implementation per type, so a new discount is a new class, not an edited method.

// Before: must edit this method for every new discount type
double calculate(String discountType, double amount) {
    if (discountType.equals("SEASONAL")) return amount * 0.9;
    else if (discountType.equals("LOYALTY")) return amount * 0.85;
    else return amount;
}

// After: open for extension, closed for modification
interface Discount { double apply(double amount); }
class SeasonalDiscount implements Discount { public double apply(double amount) { return amount * 0.9; } }
class LoyaltyDiscount implements Discount { public double apply(double amount) { return amount * 0.85; } }
// Adding BlackFridayDiscount later needs zero edits to the two classes above.

23. Explain how polymorphism and interfaces replace conditional branching as the primary mechanism for achieving the Open/Closed Principle in Java.

A conditional branch, whether if/else or switch, encodes every known case inside one method body, so the method's source must change whenever a new case is added. Polymorphism moves that decision to the type system: the caller holds a reference to the abstraction, and the correct behavior is selected automatically by which concrete implementation was constructed, with no branching logic to edit.

List<Discount> discounts = List.of(new SeasonalDiscount(), new LoyaltyDiscount());
double total = discounts.stream()
    .map(d -> d.apply(amount))
    .reduce(amount, Math::min);

24. Explain how the Strategy pattern is a direct enabler of the Open/Closed Principle, with a worked shipping-cost example.

Strategy defines a family of interchangeable algorithms behind one interface, letting the caller select or inject the concrete strategy at runtime. Adding a new algorithm means writing a new strategy class implementing the same interface; the code that uses the strategy is never touched.

interface ShippingCostStrategy { double cost(Shipment shipment); }
class GroundShipping implements ShippingCostStrategy { public double cost(Shipment s) { return s.weightKg() * 1.2; } }
class ExpressShipping implements ShippingCostStrategy { public double cost(Shipment s) { return s.weightKg() * 3.5 + 5; } }

class ShippingCalculator {
    private final ShippingCostStrategy strategy;
    ShippingCalculator(ShippingCostStrategy strategy) { this.strategy = strategy; }
    double calculate(Shipment shipment) { return strategy.cost(shipment); }
}
Strategy patternOCP enabler

25. Explain how the Template Method pattern enables the Open/Closed Principle, with an example of a report-generation skeleton that subclasses extend without modifying the base algorithm.

Template Method defines the fixed skeleton of an algorithm in a final or non-overridable base method, deferring specific steps to abstract or protected hook methods that subclasses implement. New behavior is added by creating a new subclass that fills in the hooks; the base class's overall algorithm never needs modification.

abstract class ReportTemplate {
    final String generate(ReportData data) {
        String header = buildHeader(data);
        String body = buildBody(data);
        return header + body;
    }
    protected abstract String buildHeader(ReportData data);
    protected abstract String buildBody(ReportData data);
}
class CsvReport extends ReportTemplate {
    protected String buildHeader(ReportData d) { return "id,total\n"; }
    protected String buildBody(ReportData d) { return d.rows().stream().map(Object::toString).collect(java.util.stream.Collectors.joining("\n")); }
}

26. Explain how the Decorator pattern enables the Open/Closed Principle by letting new behavior be layered onto an object without modifying its class.

Decorator wraps an object implementing a common interface and adds behavior around its existing methods, without editing the wrapped class or the interface. A new cross-cutting behavior, such as logging or caching, is added by writing a new decorator class, leaving both the original implementation and every other decorator untouched.

interface DataFetcher { String fetch(String key); }
class RemoteDataFetcher implements DataFetcher { public String fetch(String key) { /* network call */ return null; } }
class CachingDataFetcher implements DataFetcher {
    private final DataFetcher delegate;
    private final Map<String, String> cache = new java.util.HashMap<>();
    CachingDataFetcher(DataFetcher delegate) { this.delegate = delegate; }
    public String fetch(String key) { return cache.computeIfAbsent(key, delegate::fetch); }
}
Decorator patternOCP enabler

27. Explain why a growing if/else or switch chain that must be extended for every new business case is a textbook Open/Closed Principle violation, and how to recognize it during review.

Every time a new case is added, the existing, already-tested branches of the chain are put at risk, since the method must be edited and re-verified as a whole rather than extended in isolation. The chain also tends to grow indefinitely, becoming an unreadable, unmaintainable list of special cases that no single change owns cleanly.

Review signal A comment like // add new case here above a switch statement is a strong sign the team already recognizes the OCP violation but has not yet fixed it.

28. Explain the relationship between the Open/Closed Principle and plugin architectures, where third-party code extends an application's behavior without access to its source.

A plugin architecture is OCP taken to its logical extreme: the host application defines extension point interfaces, and plugins, often compiled and deployed entirely separately, implement them and are discovered at runtime, typically via java.util.ServiceLoader or a similar registry. The host's source code never needs to change, or even be recompiled, to support new plugin behavior.

This only works because the host was designed with the correct extension seams from the start; retrofitting a plugin architecture onto code that was never built around stable abstractions usually requires a genuine OCP-oriented refactor first.

29. Explain the "protected variation" design idea and how it relates to, and slightly generalizes, the Open/Closed Principle.

Protected variation, a principle from the GRASP catalog, says: identify points of predicted variation and create a stable interface around them. OCP can be seen as protected variation applied specifically to the axis of "new cases being added over time." The generalization is that protected variation applies to any kind of expected change, not just new cases, including changes in data format, changes in algorithm, or changes in an external dependency.

Both ideas share the same core move: identify what's likely to vary, and wrap it behind a stable seam so the variation is isolated rather than scattered.

30. What is the risk of over-applying the Open/Closed Principle by introducing abstractions for extension points that never actually get a second implementation?

This is speculative generality: building an interface, a strategy hook, or a plugin point "just in case" a second implementation is ever needed, when in reality only one implementation ever exists. The cost is real: extra indirection, harder-to-navigate code, and an abstraction whose design was guessed rather than informed by an actual second use case.

A pragmatic rule is to introduce the OCP-enabling abstraction when the second variation actually arrives, or when there is concrete, near-term evidence it will, rather than preemptively for every method that could theoretically vary someday.

Speculative generalityYAGNI tension

31. Design a payment-gateway integration point that lets a new payment gateway be added without modifying any existing gateway's code, following the Open/Closed Principle.

Define a PaymentGateway interface once, implement one adapter class per provider, and register new providers through a factory or a Spring bean map keyed by provider name. Adding Adyen next quarter means writing AdyenGateway and registering it; Stripe's and PayPal's adapters, and everything that calls PaymentGateway, are never edited.

interface PaymentGateway { PaymentResult charge(PaymentRequest request); }
class StripeGateway implements PaymentGateway { public PaymentResult charge(PaymentRequest r) { /* ... */ return null; } }
class PayPalGateway implements PaymentGateway { public PaymentResult charge(PaymentRequest r) { /* ... */ return null; } }
// Later, with zero edits above:
class AdyenGateway implements PaymentGateway { public PaymentResult charge(PaymentRequest r) { /* ... */ return null; } }

32. Discuss the nuanced interaction between Java 17/21 sealed interfaces with exhaustive pattern matching and the Open/Closed Principle: when is an exhaustive switch actually preferable to OCP-style extension?

OCP-style extension is ideal when the set of cases is genuinely open-ended and owned by different teams or plugins over time. But when the set of cases is intentionally closed and fully known, for example the four suits of a card or the finite states of a protocol handshake, a sealed interface with an exhaustive switch is arguably the better design: the compiler forces every branch to be handled whenever a new permitted subtype is added, which is a stronger safety net than an interface implementation that could silently be forgotten somewhere.

sealed interface PaymentStatus permits Approved, Declined, Pending {}
record Approved(String txnId) implements PaymentStatus {}
record Declined(String reason) implements PaymentStatus {}
record Pending() implements PaymentStatus {}

String describe(PaymentStatus status) {
    return switch (status) {
        case Approved a -> "Approved: " + a.txnId();
        case Declined d -> "Declined: " + d.reason();
        case Pending p -> "Pending";
        // adding a new permitted type without a new case here is a compile error
    };
}

The nuance to state out loud in an interview: OCP is the right default for open-ended extension, but a deliberately closed set of cases is better served by exhaustiveness checking than by artificially forcing polymorphic extension points onto data that will never actually grow.

33. Walk through an incremental refactor plan for legacy code containing a large switch statement on order type that has grown unmanageable, without a risky big-bang rewrite.

First, introduce an OrderTypeHandler interface matching the switch's actual behavior. Second, extract each existing case into its own class implementing that interface, one commit at a time, verified by existing tests after each extraction, leaving the switch temporarily delegating to a lookup map. Third, once every case is extracted, replace the switch and the lookup map with a registry (a Map<OrderType, OrderTypeHandler> populated by Spring or a factory).

Why incremental Extracting one case at a time keeps the diff small and the tests green after every step, versus a rewrite that risks a long-lived, hard-to-review branch and a big-bang regression surface.

34. Explain the testing implications of Open/Closed-compliant code: why adding a new case adds new tests without requiring any existing tests to be touched.

Because a new case is a new class implementing a shared interface, its test is a new test class exercising only that implementation; no existing test file needs to change, since nothing about the existing implementations' source changed. This is a strong practical signal that OCP is genuinely being respected: check whether the last several "add a new case" pull requests only added files and tests, versus editing existing ones.

35. Explain how the Factory Method and Abstract Factory patterns work together with the Open/Closed Principle to keep object-creation code closed for modification when new product types are added.

Factory Method defers the decision of which concrete class to instantiate to a subclass or an overridable method, so adding a new product type means adding a new factory method override rather than editing a central new-heavy method. Abstract Factory extends this to whole families of related objects, letting an entire family be swapped by substituting one concrete factory for another, again without editing the code that consumes the factory's output.

Factory MethodAbstract FactoryOCP

36. Explain why the Open/Closed Principle only works safely when new subtypes also satisfy the Liskov Substitution Principle, and what happens when they don't.

OCP promises that adding a new implementation of an abstraction is safe without touching existing code. That promise silently depends on the new implementation actually honoring the abstraction's contract, which is precisely what LSP requires. If a new Discount implementation throws an exception the interface never documented, or returns a negative amount when every other implementation guarantees a non-negative one, callers written against the "safe" abstraction break even though, from an OCP-mechanics view, nothing was "modified."

In short, OCP protects existing code from edits; LSP protects existing code from broken assumptions about new code. Both are needed together for extension to actually be safe.

37. Explain how Java's ServiceLoader (SPI) mechanism supports the Open/Closed Principle for discovering implementations at runtime without hardcoding a list of known classes.

ServiceLoader lets a module declare a service interface and have any number of provider modules register implementations via a META-INF/services file or a module-info provides clause. The consuming code iterates over whatever providers happen to be on the classpath or module path, so adding a new provider is purely a matter of shipping a new jar, with zero changes to the code that loads and uses the service.

ServiceLoader<PaymentGateway> loader = ServiceLoader.load(PaymentGateway.class);
for (PaymentGateway gateway : loader) {
    // each discovered provider, no hardcoded list of classes
}

38. Discuss the tension between the Open/Closed Principle and adding a new constant to an existing Java enum, and why this is a legitimately debated edge case.

Adding a new constant to an enum is, strictly, a modification of existing source, not a pure extension, and any exhaustive switch elsewhere in the codebase over that enum must be revisited (though the compiler will flag missing cases if the switch is exhaustive over a sealed type or the switch expression requires exhaustiveness). Purists sometimes argue this violates OCP, since the enum's source file changes.

In practice this is usually the right trade-off: enums represent a genuinely closed, well-understood set of values, and the compiler-enforced exhaustiveness check on every consuming switch is often a safer signal than an interface-based extension point would be, echoing the same nuance raised with sealed interfaces.

Debated edge caseCompiler-enforced safety

39. List concrete code smells that indicate an Open/Closed Principle violation is present in a Java codebase, beyond the obvious if/else chain.

  • Repeated instanceof checks scattered across multiple classes, all branching on the same family of types.
  • A comment reading "add new case here" or "remember to update this list" above a conditional.
  • A single enum-driven switch statement duplicated in several unrelated classes rather than centralized behind polymorphism.
  • Pull requests for "support a new X" that consistently modify the same handful of existing files instead of only adding new ones.

40. Walk through a capstone refactor of a NotificationService whose send() method uses a switch on channel type (EMAIL, SMS, PUSH) into a fully Open/Closed-compliant design.

Extract a NotificationChannel interface with one implementation per channel, then have NotificationService depend on a collection of channels rather than a switch, selecting the right one by matching each channel's declared type.

interface NotificationChannel {
    ChannelType type();
    void send(Notification notification);
}
class EmailChannel implements NotificationChannel {
    public ChannelType type() { return ChannelType.EMAIL; }
    public void send(Notification n) { /* SMTP send */ }
}

class NotificationService {
    private final Map<ChannelType, NotificationChannel> channels;
    NotificationService(List<NotificationChannel> available) {
        this.channels = available.stream().collect(java.util.stream.Collectors.toMap(NotificationChannel::type, c -> c));
    }
    void send(ChannelType type, Notification notification) { channels.get(type).send(notification); }
}

Adding a WhatsApp channel later means writing WhatsAppChannel and registering it as a bean; NotificationService and every existing channel implementation stay untouched.

LLiskov Substitution Principle – Q41–Q60

41. State the Liskov Substitution Principle precisely and explain what it means for a subtype to be substitutable for its base type "without altering correctness."

LSP says objects of a superclass should be replaceable with objects of a subclass without breaking the correctness of the program, meaning any code written against the base type's documented contract, its preconditions, postconditions, and invariants, must keep working unmodified when handed any subtype instead.

This is a stronger requirement than merely compiling; a subtype can satisfy every method signature and still violate LSP by changing behavior in a way that breaks a caller's reasonable assumptions, which is why LSP violations are usually discovered at runtime, not at compile time.

42. Walk through the classic Rectangle/Square Liskov Substitution violation in Java, including a concrete failing unit test that demonstrates the break.

Making Square extends Rectangle and overriding setWidth/setHeight to keep both sides equal seems mathematically reasonable, but it breaks any code that assumes setting a rectangle's width leaves its height unchanged, an assumption baked into Rectangle's implicit contract.

class Rectangle {
    protected int width, height;
    void setWidth(int w) { this.width = w; }
    void setHeight(int h) { this.height = h; }
    int area() { return width * height; }
}
class Square extends Rectangle {
    @Override void setWidth(int w) { width = w; height = w; }
    @Override void setHeight(int h) { width = h; height = h; }
}

@Test
void settingWidthShouldNotChangeHeight() {
    Rectangle r = new Square(); // substituted, per LSP this should still hold
    r.setWidth(5);
    r.setHeight(10);
    assertEquals(50, r.area()); // fails: Square forces area() to be 100
}

The fix is to not model Square as a Rectangle subtype at all; both should implement a common Shape interface with independent, non-inheriting implementations.

43. Explain the rule that overridden methods must not strengthen preconditions compared to the base method, with a Java example of a violation.

A precondition is what the caller must guarantee is true before calling a method. LSP requires an override to accept everything the base method accepted, or more; it must never reject an input the base class would have happily processed, since that would break client code written against the base type's looser contract.

class FileProcessor {
    void process(String path) { /* accepts any non-null path */ }
}
class SecureFileProcessor extends FileProcessor {
    @Override
    void process(String path) {
        if (!path.startsWith("/secure/")) throw new IllegalArgumentException("must be under /secure/");
        // violates LSP: rejects inputs the base class accepted
    }
}

44. Explain the rule that overridden methods must not weaken postconditions compared to the base method, with a Java example of a violation.

A postcondition is what the method guarantees is true after it returns. An override must uphold at least everything the base method guaranteed, or more; it must never deliver a weaker result than callers were promised.

class Repository {
    // base contract: always returns a non-null, saved entity with a generated id
    Entity save(Entity entity) { entity.setId(generateId()); return entity; }
}
class CachingRepository extends Repository {
    @Override
    Entity save(Entity entity) {
        return entity; // violates LSP: postcondition "id is populated" is silently dropped
    }
}

45. Explain how a subclass overriding a method to throw a new, broader checked exception that the base method's signature never declared violates the Liskov Substitution Principle.

Client code compiled against the base type only catches the exceptions the base method declares. If a subclass, even legally under Java's override rules (an override may narrow, but never widen, checked exceptions), instead throws a new unchecked exception the base method's documentation never promised, callers relying on the base contract are surprised by a failure mode they had no reason to anticipate or handle.

class Repository {
    Entity find(String id) { /* returns entity or null */ return null; }
}
class RemoteRepository extends Repository {
    @Override
    Entity find(String id) {
        throw new RemoteConnectionException(); // callers never expected this from Repository.find
    }
}
Watch out Java's compiler only enforces checked-exception narrowing on overrides; it does nothing to stop a new unchecked exception type from silently breaking the base contract.

46. Compare covariant return types, which are fine under LSP, with a subclass narrowing the type of an accepted parameter, which is an LSP concern, and explain the asymmetry.

A covariant return type, an override returning a more specific subtype than the base method declared, is always safe: any caller expecting the base return type can still use the more specific one, since it's a subtype. Narrowing an accepted parameter type, however, breaks substitutability, because callers using the base type's reference are entitled to pass any value valid for the base parameter type, and a narrower override would reject some of them.

class Animal {}
class Dog extends Animal {}

class Shelter {
    Animal adopt() { return new Animal(); }        // covariant return: safe to narrow
}
class DogShelter extends Shelter {
    @Override Dog adopt() { return new Dog(); }     // fine: still an Animal
}
// But narrowing a parameter type is NOT expressible via simple overriding in Java
// (it would be overloading, not overriding) -- which is itself the LSP-preserving guardrail.

Java's type system actually prevents parameter narrowing from compiling as an override at all; attempting it creates an overload instead, which is one reason this particular LSP violation is rarer in Java than in more dynamically-typed languages.

47. Explain why scattering instanceof checks throughout client code to special-case a particular subtype's behavior is a smell indicating a Liskov Substitution violation.

If client code needs to check if (shape instanceof Square) to handle it differently from other shapes, the abstraction has already failed: the whole point of polymorphism and LSP is that client code should be able to treat every subtype uniformly through the base type's interface. Special-casing one subtype is a strong sign that subtype's behavior does not actually honor the base contract, forcing callers to work around the mismatch instead of relying on it.

// Smell: client must know about a specific subtype to use it safely
if (shape instanceof Square square) {
    // special handling because Square secretly behaves differently
}

48. Explain how an overridden method that silently does nothing (a no-op) when the base class documents real, expected behavior violates the Liskov Substitution Principle.

If a base class's save() method is documented to persist an entity and make it retrievable afterward, a subclass overriding save() as an empty no-op technically satisfies the method signature but silently breaks every caller relying on the documented postcondition, often failing much later and far from the actual bug's location, when a "saved" entity turns out never to have been persisted.

class ReadOnlyRepository extends Repository {
    @Override void save(Entity entity) { /* intentionally does nothing */ }
    // violates LSP: callers relying on Repository's save contract get silent data loss
}

49. Walk through the classic Bird/Penguin hierarchy where Penguin can't fly, explain why it violates LSP, and show how to redesign it correctly.

If Bird declares fly() and Penguin extends Bird, then Penguin must either throw an exception from fly() or implement nonsensical behavior, both of which break any code that substitutes a Penguin wherever a Bird is expected and calls fly(). The fix is to separate the capability from the base type.

interface Bird { void eat(); }
interface FlyingBird extends Bird { void fly(); }

class Sparrow implements FlyingBird {
    public void eat() { /* ... */ }
    public void fly() { /* ... */ }
}
class Penguin implements Bird {
    public void eat() { /* ... */ }
    // no fly(): Penguin was never forced to implement a capability it doesn't have
}

50. Explain how a subclass that changes value semantics can violate Java's equals()/hashCode() contract in a way that also constitutes a Liskov Substitution violation.

The equals()/hashCode() contract requires symmetry, reflexivity, transitivity, and consistency with hashing. A common violation occurs when a subclass adds a new field to equals() comparison, breaking symmetry between base and subtype instances (base.equals(sub) returning a different result than sub.equals(base)), which corrupts HashSet/HashMap behavior the moment both types are mixed in the same collection, and it directly violates LSP because code substituting a subtype instance where a base type was expected now gets inconsistent equality results.

class Point {
    int x, y;
    @Override public boolean equals(Object o) {
        if (!(o instanceof Point p)) return false;
        return x == p.x && y == p.y;
    }
}
class ColorPoint extends Point {
    String color;
    @Override public boolean equals(Object o) {
        if (!(o instanceof ColorPoint cp)) return false; // breaks symmetry with plain Point
        return super.equals(o) && color.equals(cp.color);
    }
}

Effective Java's guidance, favoring composition over inheritance for value classes, or making the base class final, sidesteps this entire category of LSP break.

51. Explain a very common real-world Liskov Substitution violation: a subclass that throws UnsupportedOperationException for a method the base class documents as always safe to call, such as List.add() on an immutable list implementation.

java.util.List documents add() as a normal, always-available mutating operation. List.of(...) and Collections.unmodifiableList(...) both return implementations that throw UnsupportedOperationException from add(), meaning any code written generically against List and relying on mutability breaks at runtime the moment it receives one of these particular implementations instead.

List<String> names = List.of("a", "b");
names.add("c"); // throws UnsupportedOperationException -- a classic, JDK-sanctioned LSP break
Interview point This is a widely acknowledged, pragmatic LSP violation baked into the JDK itself; the trade-off (safety of immutability vs. a strict substitutability guarantee) is considered worth it, but it's exactly the kind of nuance that shows real understanding when you name it unprompted.

52. Explain the relationship between the Liskov Substitution Principle and the broader idea of design by contract.

Design by contract, formalized by Bertrand Meyer, frames every method as a formal contract with preconditions, postconditions, and invariants. LSP is essentially design by contract applied specifically to subtyping: it requires that a subtype's contract be at least as permissive on preconditions and at least as strong on postconditions and invariants as its supertype's contract, which is exactly the "don't strengthen preconditions, don't weaken postconditions" rule.

Thinking in contract terms gives a precise, checkable way to evaluate whether a proposed subclass is actually safe to substitute, rather than relying on intuition about whether it "feels like a kind of" the base type.

53. Describe how to test Liskov Substitution compliance across every subtype using a shared abstract test suite, and why this catches violations that individual per-class tests miss.

Write one abstract test class that expresses the base type's contract as executable assertions, with an abstract factory method supplying the instance under test; every concrete subtype gets a small concrete test subclass that only implements that factory method and inherits every contract test automatically.

abstract class ShapeContractTest {
    abstract Shape createShape();

    @Test
    void areaIsNeverNegative() {
        assertTrue(createShape().area() >= 0);
    }
}
class CircleContractTest extends ShapeContractTest {
    Shape createShape() { return new Circle(5); }
}
class SquareContractTest extends ShapeContractTest {
    Shape createShape() { return new Square(5); } // inherits the same contract assertions automatically
}

Because every subtype runs the exact same contract assertions, any new subtype that violates an inherited guarantee fails immediately, without anyone having to remember to write a bespoke LSP check for it.

54. Explain Java's array covariance and why arrays being covariant but not truly safely substitutable is a related caveat worth knowing alongside Liskov substitution.

Java arrays are covariant: a String[] can be assigned to an Object[] reference. But this is not truly LSP-safe, because writing an incompatible element into that reference compiles fine yet fails at runtime with ArrayStoreException, since the array itself remembers its actual runtime component type.

Object[] objects = new String[3]; // legal: array covariance
objects[0] = 42; // compiles, but throws ArrayStoreException at runtime

Generics deliberately do not allow this kind of covariance by default (a List<String> is not a List<Object>), precisely because the language designers wanted compile-time safety instead of a runtime substitutability trap like arrays have.

55. Explain how a subclass returning null where the base class's contract guarantees a non-null value constitutes a Liskov Substitution violation, and how Optional helps prevent it.

If the base type's Javadoc, or its established behavior, guarantees a method always returns a usable object, a subclass silently returning null instead breaks every caller that reasonably skips a null check based on that guarantee, typically surfacing as a distant NullPointerException unrelated to the actual override.

class Repository {
    Optional<Entity> find(String id) { /* explicit, type-enforced possibility of absence */ }
}
// A subtype cannot silently violate this contract by returning a bare null instead of an empty Optional,
// since the return type itself documents and enforces the "might be absent" case.

Optional does not eliminate the possibility of a badly-behaved override, but making absence part of the type signature makes the contract explicit and harder to violate by accident.

56. Explain why a mutable subclass of an intentionally immutable base class is a Liskov Substitution violation, and why this pattern should generally be avoided.

Code that receives a reference typed as the immutable base class often relies on that immutability for correctness, for example safely sharing the instance across threads without synchronization, or using it as a stable map key. A mutable subclass silently breaks that invariant for any caller who receives one polymorphically, since nothing in the base type's reference reveals that the actual runtime instance can now change underneath them.

ImmutabilityThread-safety risk

57. Explain how marking a class or method final in Java can be a deliberate, defensive tool for preventing future Liskov Substitution violations.

If a class was never designed to be safely extended, for example because its invariants depend on its fields never being reinterpreted by a subclass, marking it final removes the possibility of a future, unreviewed subclass silently breaking those invariants. Marking a specific method final is a narrower version of the same defense: it lets a class remain extensible in general while protecting one particular method whose exact contract must never be altered by an override.

public final class Money { /* immutable value type: safe from LSP-breaking subclasses entirely */ }

58. Give an example of an overridden method that throws for a subset of inputs that the base class's contract treats as universally valid, and explain why this is an LSP violation even though most inputs still work.

A base class's withdraw(amount) method might accept any non-negative amount up to the current balance. A subclass modeling a savings account with a minimum-balance rule might throw for amounts that would drop the balance below that minimum, an input the base contract never restricted. Even though the vast majority of calls behave identically, any caller who happens to hit that narrower edge case, perhaps in a batch job iterating over mixed account types, gets a surprising and inconsistent failure depending purely on which subtype it happened to receive.

class Account {
    void withdraw(double amount) { if (amount > balance) throw new InsufficientFundsException(); balance -= amount; }
}
class SavingsAccount extends Account {
    @Override void withdraw(double amount) {
        if (balance - amount < MIN_BALANCE) throw new IllegalArgumentException("below minimum"); // new restriction
        super.withdraw(amount);
    }
}

59. Explain how favoring composition over inheritance can structurally prevent Liskov Substitution violations before they happen.

Inheritance forces a subtype to honor its supertype's entire contract, which is exactly where LSP violations creep in when the "is-a" relationship is only approximately true. Composition sidesteps the problem entirely: instead of a Penguin extending Bird and having to fake or reject fly(), a Penguin can hold a SwimBehavior and simply never expose a fly() method at all, since there is no inherited contract to honor or violate.

This is the same underlying idea behind the well-known advice "favor composition over inheritance": most LSP violations only exist because inheritance was used to model a relationship that was never a true, unconditional "is-a" in the first place.

60. Walk through a capstone LSP redesign of a Vehicle/ElectricVehicle hierarchy where ElectricVehicle.refuel() has no meaningful implementation, ending with a design that fully satisfies substitutability.

If Vehicle declares refuel() and ElectricVehicle extends Vehicle, an electric vehicle has no gasoline to refuel, forcing either a no-op or an exception, both LSP violations. The fix separates energy-replenishment behavior from the vehicle hierarchy itself.

interface Vehicle { void drive(); }
interface RefuelableVehicle extends Vehicle { void refuel(); }
interface RechargeableVehicle extends Vehicle { void recharge(); }

class GasCar implements RefuelableVehicle {
    public void drive() { /* ... */ }
    public void refuel() { /* ... */ }
}
class ElectricCar implements RechargeableVehicle {
    public void drive() { /* ... */ }
    public void recharge() { /* ... */ }
    // never forced to implement a meaningless refuel()
}

Client code that only needs to drive vehicles depends on Vehicle; code specifically managing gas stations depends on RefuelableVehicle; neither is ever handed a type that can silently fail to honor the capability it advertises.

IInterface Segregation Principle – Q61–Q80

61. State the Interface Segregation Principle precisely and explain what it means for "no client to be forced to depend on methods it doesn't use."

ISP says clients should not be forced to depend on interfaces they do not use; a class implementing an interface should never have to provide dummy, no-op, or exception-throwing implementations of methods irrelevant to its actual behavior. The core harm is coupling: a fat interface forces every implementer to change, or at least recompile and redeploy, whenever the interface adds a method that only some implementers actually need.

62. Walk through the classic fat Machine interface (print, scan, fax) violation and show how splitting it into role interfaces resolves it.

A single Machine interface with print(), scan(), and fax() forces a simple printer-only device to implement two methods it cannot support, typically by throwing UnsupportedOperationException. Splitting into role interfaces lets each device implement only the capabilities it actually has.

// Before
interface Machine { void print(); void scan(); void fax(); }

// After: role interfaces
interface Printer { void print(); }
interface Scanner { void scan(); }
interface Fax { void fax(); }

class SimplePrinter implements Printer { public void print() { /* ... */ } }
class MultiFunctionDevice implements Printer, Scanner, Fax {
    public void print() { /* ... */ }
    public void scan() { /* ... */ }
    public void fax() { /* ... */ }
}

63. Compare implementing all three capabilities on a multi-function device versus implementing only Printer on a simple device, and explain why role interfaces make both cases clean.

With role interfaces, MultiFunctionDevice naturally implements Printer, Scanner, and Fax because it genuinely supports all three, while SimplePrinter implements only Printer, with no obligation, and no dead code, related to scanning or faxing. Client code that only needs printing can depend on the narrow Printer interface and work identically with either device, never needing to know which one it actually received.

void printDocument(Printer printer, Document doc) { printer.print(); } // works for both device types

64. Compare the Interface Segregation Principle with the Single Responsibility Principle, since both push toward smaller, more focused units, and explain the precise distinction between them.

SRP is about a class having exactly one reason to change; it is a statement about implementation cohesion. ISP is about a client not being forced to depend on methods it doesn't use; it is a statement about interface design from the consumer's perspective. A class can violate SRP without violating ISP (a God class implementing one lean, focused interface while internally juggling five responsibilities), and an interface can violate ISP while every implementer individually still respects SRP (a fat interface that forces unrelated capabilities onto every implementer, even if each implementer is otherwise a clean, single-purpose class).

The two principles are related, both fight against unwanted coupling, but SRP is a class-design principle and ISP is specifically a contract-design principle, and interviewers value hearing that distinction stated explicitly rather than the two terms used interchangeably.

ISP vs SRPContract vs class design

65. Discuss the nuanced role of Java 8+ default methods in interface design: do they help avoid ISP violations, or do they risk encouraging fat interfaces again?

Default methods help in one specific way: they let an interface evolve by adding a new method with a sensible default implementation without breaking every existing implementer, avoiding a forced, disruptive change across the codebase. But they can also tempt designers to keep piling optional-feeling methods onto one interface, since "it has a default, so nobody's forced to override it" feels like it dodges ISP, when in fact the interface is still growing into a fatter contract that every implementer's public API technically now carries.

The nuance worth stating: default methods solve a binary-compatibility problem, not a design problem; a genuinely unrelated capability still belongs on its own interface, default method or not.

66. Explain the testing benefits of small, role-specific interfaces: why is it much easier to create test doubles for a 1-2 method interface than a 10-method one?

A test double for a narrow interface, whether hand-written or generated by Mockito, needs to stub or verify only the one or two methods the test actually cares about. A fat interface forces every mock setup to at least acknowledge, or risk unexpected-invocation failures from, methods entirely irrelevant to the test's scenario, and a hand-written fake must provide plausible behavior for methods it will never actually exercise.

// Narrow interface: trivial to fake
class FakeClock implements Clock { public Instant now() { return FIXED_INSTANT; } }
// A fat interface with 10 methods means 9 irrelevant stub bodies for the same test

67. Explain how splitting a repository interface that mixes read and write concerns into separate ReadRepository and WriteRepository interfaces resolves an ISP violation, and how this connects to CQRS.

A single Repository<T> interface with both find/list methods and save/delete methods forces a read-only reporting client to depend on, and potentially accidentally call, mutation methods it should never touch. Splitting into ReadRepository<T> and WriteRepository<T>, composed together only where both are genuinely needed, lets a reporting service depend solely on the read-only contract.

interface ReadRepository<T> { Optional<T> findById(String id); List<T> findAll(); }
interface WriteRepository<T> { T save(T entity); void delete(String id); }
interface Repository<T> extends ReadRepository<T>, WriteRepository<T> {}

This is exactly the structural seam Command Query Responsibility Segregation (CQRS) formalizes at an architectural level: separate models, and often separate data paths entirely, for the read side and the write side.

68. Explain the distinction between a "role interface" and a "header interface," and why role interfaces are the design ISP favors.

A header interface mirrors an existing concrete class's entire public API, one big interface extracted mechanically from one big class, usually so the class can be mocked or swapped, without regard to whether any given client actually needs every method. A role interface is instead designed from the client's point of view, exposing only the narrow set of methods a particular kind of caller actually needs, even if several different role interfaces end up implemented by the same concrete class.

Role interfaceHeader interface

69. Walk through a Worker interface that forces a RobotWorker to implement an irrelevant eat() method, and show the ISP-compliant fix.

A Worker interface with work() and eat() works fine for a HumanWorker but forces a RobotWorker, which does not eat, to provide a meaningless or exception-throwing eat() implementation purely to satisfy the interface.

// Before
interface Worker { void work(); void eat(); }

// After
interface Workable { void work(); }
interface Feedable { void eat(); }

class HumanWorker implements Workable, Feedable {
    public void work() { /* ... */ }
    public void eat() { /* ... */ }
}
class RobotWorker implements Workable {
    public void work() { /* ... */ }
    // never forced to implement eat()
}

70. Explain ISP's relationship to the Adapter and Facade patterns: how can they be used to expose a fat legacy interface behind several focused, role-specific interfaces?

When a legacy library or SDK exposes one large, fat interface you cannot redesign, an adapter, or a small set of adapters, can each implement one narrow, ISP-compliant interface internally, delegating to whichever subset of the legacy interface's methods that role actually needs. A facade can similarly expose several small entry points backed by one complex subsystem, letting different callers depend on only the slice relevant to them, without ever seeing the subsystem's full, fat surface.

AdapterFacadeISP at a boundary

71. List common code smells that indicate an Interface Segregation Principle violation is present in a Java codebase.

  • An implementing class whose method body is just throw new UnsupportedOperationException() for one or more interface methods.
  • An override that simply returns null, 0, or an empty collection purely to satisfy the compiler, with a comment like "not applicable here."
  • An interface whose Javadoc has to say "implementers of X should ignore method Y" as a caveat.
  • A mock in a test that must stub several unrelated methods it will never actually be asked to exercise, just to satisfy the interface.

72. Explain how IDE-generated "implement all methods" stubs can silently mask, and even encourage, Interface Segregation Principle violations over time.

When an IDE auto-generates empty or default-return stub bodies for every interface method a new class must implement, it removes the friction that would otherwise prompt a developer to ask "does this class actually need all these methods?" The stubs compile immediately and look complete, so a fat interface's cost stays invisible until much later, when someone accidentally calls one of those stub methods expecting real behavior and gets silent, wrong results instead of a compile-time signal that something is off.

73. Discuss fat event listener interfaces from an ISP perspective, and explain why a listener with a dozen callback methods is a design smell even when default methods exist.

A listener interface with many callback methods, one per possible event, forces every implementer's class declaration to formally depend on the entire event surface even though a typical listener only cares about one or two specific events. From a pure ISP lens, the fix is not just default methods (which soften the syntactic burden) but splitting into narrower, single-event listener interfaces, or favoring a functional-interface-per-event design that lets callers register only the specific callbacks they need.

interface OnOrderPlaced { void handle(Order order); }
interface OnOrderCancelled { void handle(Order order); }
// a caller only interested in cancellations registers one focused listener, not a fat multi-event one

74. Explain how the Interface Segregation Principle applies to microservice API contracts, favoring several small, versioned client interfaces over one large shared client.

A single, shared OrdersApiClient interface exposing every endpoint the orders service supports forces every consuming service to compile against, and potentially be affected by changes to, endpoints it never calls. Segregating the contract into narrower, purpose-specific client interfaces, such as OrderLookupClient and OrderCancellationClient, means a consuming service that only ever looks up orders depends on, and is only ever coupled to, exactly that slice of the API.

75. Explain how a generic Repository<T> interface with fifteen methods covering CRUD, search, batch operations, and reporting becomes an ISP violation, and how to segregate it.

Most concrete repositories only ever need a handful of those fifteen methods; forcing every one of them to implement all fifteen, often by inheriting from a fat generic base, means unused methods either throw or silently return placeholder values, and the interface's true purpose becomes unclear from its shape alone.

interface CrudRepository<T, ID> { T save(T t); Optional<T> findById(ID id); void deleteById(ID id); }
interface SearchableRepository<T> { List<T> search(SearchCriteria criteria); }
interface BatchRepository<T> { List<T> saveAll(List<T> items); }
// A given entity's repository composes only the interfaces it genuinely needs.

76. Give an example of a Vehicle interface that requires a fly() method, forcing a Car implementation to violate its own contract, and show the segregated fix.

If a shared Vehicle interface tries to cover every possible vehicle capability, including fly() for a hypothetical flying-car future, every ground vehicle implementation is forced to either throw or provide a meaningless implementation for a capability it fundamentally cannot support.

interface Drivable { void drive(); }
interface Flyable { void fly(); }

class Car implements Drivable { public void drive() { /* ... */ } }
class FlyingCar implements Drivable, Flyable {
    public void drive() { /* ... */ }
    public void fly() { /* ... */ }
}

77. Explain how the Interface Segregation Principle can apply at the package or module level, not just to a single Java interface.

A module that exposes one giant public API surface forces every consumer to take a dependency on the whole module, including transitive dependencies pulled in purely to support features that particular consumer never uses. Segregating at the module level, splitting a large module into several smaller ones along capability lines, mirrors ISP's goal one level up: a consumer only needing simple validation utilities shouldn't have to pull in, and be exposed to version churn from, an unrelated reporting module bundled in the same artifact.

78. Explain why single-method functional interfaces in Java are naturally ISP-compliant by construction, and how this connects to lambda-friendly API design.

A functional interface, by definition, exposes exactly one abstract method, so there is no way for it to accumulate unrelated methods that only some implementers need; ISP compliance is essentially guaranteed by the shape of the interface itself. This is one reason modern, lambda-friendly Java APIs, accepting Function, Predicate, or a custom single-method interface, tend to compose more flexibly than older, fatter listener-style interfaces: callers supply exactly the one behavior needed, nothing more.

Functional interfacesISP by construction

79. During code review, what specific pattern of empty method bodies across a class's implemented interface methods should immediately prompt an ISP discussion?

When a class's overridden methods cluster into two groups, several with real, substantial logic and several that are empty, return a hardcoded placeholder, or immediately throw, that split is a strong signal the interface being implemented actually represents two or more distinct roles bundled together. The reviewer's question should be: "if we split this interface along that exact line, would every implementer's methods fall cleanly into one side or the other?" If yes, that's the segregation the interface needs.

80. Walk through a capstone ISP refactor of a fat UserService interface that bundles CRUD, authentication, reporting, and data export into one contract, splitting it into segregated interfaces.

Each of these four concerns is needed by a different kind of caller: a profile-editing feature needs only CRUD, a login flow needs only authentication, an analytics dashboard needs only reporting, and a compliance job needs only export. One fat interface forces every consumer to depend on, and every implementer to satisfy, all four.

interface UserRepository { User save(User u); Optional<User> findById(String id); }
interface AuthenticationService { boolean authenticate(String username, String password); }
interface UserReportingService { UserActivityReport reportFor(String id); }
interface UserExportService { byte[] exportAsCsv(List<String> userIds); }
// A single class MAY implement several of these where genuinely appropriate,
// but no client is ever forced to depend on more than the role it actually needs.
DDependency Inversion Principle – Q81–Q100

81. State the Dependency Inversion Principle precisely, including both of its clauses, and explain what "abstractions should not depend on details" means concretely.

DIP has two clauses: high-level modules should not depend on low-level modules, both should depend on abstractions; and abstractions should not depend on details, details should depend on abstractions. Concretely, the interface describing what your business logic needs (an abstraction) should be defined and owned independently of any specific technology that fulfills it (a detail, such as a particular database driver or SDK), and it is the technology-specific code that must conform to the interface's shape, never the reverse.

82. Design a NotificationService (high-level module) that depends on a MessageSender interface rather than directly instantiating a concrete EmailSender.

Without DIP, NotificationService would create new EmailSender() internally, hardwiring itself to one specific channel and making it untestable without sending a real email. With DIP, it depends only on the MessageSender abstraction, and the concrete channel is supplied from outside.

interface MessageSender { void send(String recipient, String body); }
class EmailSender implements MessageSender { public void send(String r, String b) { /* SMTP */ } }

class NotificationService {
    private final MessageSender sender;
    NotificationService(MessageSender sender) { this.sender = sender; } // depends on abstraction
    void notifyUser(String recipient, String message) { sender.send(recipient, message); }
}

83. Precisely distinguish the Dependency Inversion Principle (a design principle), Dependency Injection (a technique), and an IoC container like Spring (infrastructure) from one another.

DIP is the design principle: depend on abstractions, not concrete details. Dependency Injection is one technique for satisfying DIP in code: instead of a class constructing its own collaborators, they are supplied, or "injected," from outside, typically via a constructor. An IoC container such as Spring is infrastructure that automates dependency injection at scale, scanning for components, resolving which concrete implementation satisfies which abstraction, and wiring the object graph together at startup.

You can apply DIP and manual dependency injection with zero frameworks at all, just plain constructors; the container is a convenience for large graphs, not a requirement for the principle. This distinction, stated clearly and unprompted, is one of the strongest signals of real understanding in a SOLID interview.

DIPDependency InjectionIoC container

84. Walk through a Spring constructor-injection example that demonstrates the Dependency Inversion Principle in practice, and explain exactly which part is DIP versus which part is Spring's job.

The application code depends only on the MessageSender interface; that dependency-on-an-abstraction is the DIP part, and it exists whether or not Spring is involved at all. Spring's job is purely mechanical: scanning for a bean that satisfies MessageSender and passing it into the constructor automatically at startup.

@Service
class NotificationService {
    private final MessageSender sender;
    NotificationService(MessageSender sender) { this.sender = sender; } // Spring supplies this automatically
}

@Component
class EmailSender implements MessageSender {
    public void send(String r, String b) { /* SMTP */ }
}

85. Identify the Dependency Inversion violation in a class that instantiates a concrete low-level class directly inside its business logic, and show the fix.

A high-level class calling new on a concrete infrastructure class hardwires itself to that exact implementation, making it impossible to substitute a different implementation or a test double without editing the high-level class's source.

// Violates DIP
class OrderService {
    private final MySqlOrderRepository repository = new MySqlOrderRepository(); // hardwired detail
}

// Complies with DIP
class OrderService {
    private final OrderRepository repository; // abstraction
    OrderService(OrderRepository repository) { this.repository = repository; }
}

86. Explain the testability benefit of Dependency Inversion: why does depending on an abstraction let you mock it in unit tests instead of needing real infrastructure?

Since the high-level class depends only on an interface's method signatures, a unit test can supply a mock or hand-written fake implementing that interface, controlling exactly what it returns or throws, without ever touching a real database, network call, or file system. This makes tests fast, deterministic, and independent of infrastructure availability.

@Test
void placeOrderChargesCorrectAmount() {
    PaymentGateway mockGateway = mock(PaymentGateway.class);
    OrderService service = new OrderService(mockGateway);
    service.placeOrder(new OrderRequest(...));
    verify(mockGateway).charge(argThat(amount -> amount.equals(new BigDecimal("49.99"))));
}

87. Explain a classic Dependency Inversion violation where a domain layer directly imports a database-specific class, and how hexagonal (ports-and-adapters) architecture formalizes fixing it.

If a domain service imports java.sql.ResultSet or a JPA EntityManager directly, the domain's core business rules become entangled with, and can be broken by changes to, a specific persistence technology, even though business rules and database drivers have no logical reason to be coupled. Hexagonal architecture formalizes the fix: the domain defines a "port" interface expressing exactly what persistence capability it needs, and a separate "adapter" in an outer layer implements that port using the specific database technology.

// domain layer, no persistence imports at all
interface OrderPort { void save(Order order); Optional<Order> findById(String id); }

// infrastructure layer, depends inward on the port
class JpaOrderAdapter implements OrderPort { /* uses EntityManager internally, never exposed */ }

88. Explain the subtlety of package and module dependency direction under DIP: why should the abstraction live in, or be owned by, the high-level module's package rather than the low-level one?

Many developers place an interface like PaymentGateway inside the same package as its implementations (com.company.payments.stripe), which still leaves the high-level module's source depending on, and needing to import from, the low-level module's package, even though it only uses the interface. DIP is fully satisfied only when the abstraction is owned by, and physically resides in, the high-level module's package (com.company.orders.PaymentGateway), and the low-level module depends inward on that package to implement it, reversing the naive dependency direction entirely.

Common mistake Defining the interface next to its implementations still leaves a package-level dependency pointing the wrong way, even though the code compiles and looks decoupled at the class level.

89. Walk through refactoring a PaymentProcessor that depends directly on a concrete StripeClient into one that depends on a PaymentGateway abstraction instead.

// Before: high-level PaymentProcessor depends on a concrete low-level detail
class PaymentProcessor {
    private final StripeClient stripeClient = new StripeClient(apiKey);
    void charge(Order order) { stripeClient.createCharge(order.total(), order.currency()); }
}

// After: depends on an abstraction owned by the payments module
interface PaymentGateway { void charge(BigDecimal amount, String currency); }
class StripePaymentGateway implements PaymentGateway {
    private final StripeClient stripeClient;
    StripePaymentGateway(StripeClient stripeClient) { this.stripeClient = stripeClient; }
    public void charge(BigDecimal amount, String currency) { stripeClient.createCharge(amount, currency); }
}
class PaymentProcessor {
    private final PaymentGateway gateway;
    PaymentProcessor(PaymentGateway gateway) { this.gateway = gateway; }
    void charge(Order order) { gateway.charge(order.total(), order.currency()); }
}

Switching to Adyen later, or injecting a test double, now requires zero changes to PaymentProcessor itself.

90. Explain how Factory Method, Abstract Factory, Bridge, and Strategy all exist partly to satisfy the Dependency Inversion Principle, tying DIP back to the broader GoF pattern catalog.

Factory Method and Abstract Factory let high-level code obtain instances of an abstraction without depending on the concrete class being constructed, satisfying DIP at the object-creation boundary. Bridge deliberately decouples an abstraction from its implementation into two separate hierarchies, which is DIP applied structurally at design time. Strategy lets high-level code depend on an algorithm's interface while any concrete algorithm implementation is supplied and swapped independently, DIP applied to behavior selection.

Factory MethodAbstract FactoryBridgeStrategy

91. Explain, mechanically, how an IoC container such as Spring's ApplicationContext wires abstractions to concrete implementations at runtime to fulfill Dependency Inversion.

At startup, Spring scans configured packages for classes annotated as components, builds a registry mapping each bean to the interfaces and types it satisfies, and then, for every constructor parameter typed as an interface, looks up which registered bean implements it and injects that instance. If more than one candidate implements the same interface, @Qualifier or @Primary disambiguates which one to wire in.

@Configuration
class AppConfig {
    @Bean PaymentGateway paymentGateway(StripeClient client) { return new StripePaymentGateway(client); }
}

92. Draw out, in words, the package dependency arrow diagram that distinguishes a DIP-compliant architecture from a naive layered architecture, and explain why the arrow direction is the entire point.

In a naive layered design, the domain package imports the persistence package directly: domain -> persistence, meaning a change to the database technology can ripple into business logic. In a DIP-compliant design, the domain package defines the abstraction and the persistence package imports the domain package to implement it: persistence -> domain, with the arrow reversed, "inverted," relative to the naive flow of control (a repository call still flows from domain code into the persistence adapter at runtime, but the source-level, compile-time dependency points the other way).

This flipped arrow is precisely what "inversion" in Dependency Inversion refers to: the dependency between packages is inverted relative to the naive, control-flow-driven direction you would otherwise expect.

93. Explain why a service layer method that catches java.sql.SQLException directly is a Dependency Inversion violation, even if the method's own logic never explicitly imports a database driver.

Catching a JDBC-specific checked exception forces the service layer's method signature or catch block to acknowledge a low-level persistence detail, which means swapping to a different persistence technology, or even a different JDBC driver with different exception subclassing, could force a change in code that has no conceptual business reason to know SQL exists at all.

// Violates DIP: service layer entangled with a JDBC-specific exception type
void placeOrder(Order order) {
    try { repository.save(order); } catch (java.sql.SQLException e) { /* ... */ }
}

// Complies with DIP: the repository abstraction defines its own domain-level exception
void placeOrder(Order order) {
    try { repository.save(order); } catch (RepositoryException e) { /* ... */ }
}

94. Explain how DIP supports having multiple implementations behind one abstraction selected via Spring profiles or @Qualifier, such as switching feature-flagged payment strategies.

Because high-level code depends only on PaymentGateway, any number of implementations can coexist, a sandbox gateway for the dev profile, a production gateway for prod, or two live gateways selected per merchant via @Qualifier, entirely through configuration, with zero change to the code that calls PaymentGateway.

@Profile("dev")
@Bean PaymentGateway sandboxGateway() { return new SandboxPaymentGateway(); }

@Profile("prod")
@Bean PaymentGateway liveGateway(StripeClient client) { return new StripePaymentGateway(client); }

95. Compare constructor injection, field injection, and setter injection as ways of satisfying Dependency Inversion, and explain why constructor injection is generally preferred.

Constructor injection makes a class's dependencies explicit, mandatory, and immutable, an instance simply cannot exist in a half-wired state, and it works identically whether or not a DI framework is involved, which also makes plain unit testing trivial. Field injection (@Autowired directly on a field) hides dependencies from the constructor signature, makes the class unusable without a container or reflection-based test setup, and allows a partially-constructed, null-dependency object to exist momentarily. Setter injection allows dependencies to be optional or reconfigured after construction, useful in rare cases, but it also permits an invalid, only-partially-wired object state that constructor injection rules out entirely.

StyleDependencies visible in signatureTestable without a framework
Constructor injectionYes, mandatory and explicitYes, plain new works
Field injectionNo, hiddenNo, needs reflection or a container
Setter injectionPartially, optional-lookingYes, but allows invalid partial state

96. Explain the benefit of being able to test a DIP-compliant high-level policy class as a plain old Java object, with no framework or container involved at all.

Because a well-designed high-level class only depends on interfaces passed through its constructor, a test can construct it with plain new and hand-written or mocked fakes, with no Spring context, no classpath scanning, and no application startup cost, keeping unit tests fast, in the tens of milliseconds, and fully independent of any framework's behavior or configuration quirks.

@Test
void notifiesUserThroughInjectedSender() {
    MessageSender fake = (recipient, body) -> { /* capture for assertion */ };
    NotificationService service = new NotificationService(fake); // no Spring context needed
    service.notifyUser("a@b.com", "hello");
}

97. Explain why a business layer method whose public signature accepts or returns a vendor SDK type directly is a Dependency Inversion code smell, even if internally it delegates correctly.

Once a vendor type, such as a Stripe SDK's Charge object, appears in a business-layer method's public signature, every caller of that method becomes transitively coupled to that vendor's SDK on their classpath and their own compiled code, even callers who have no direct relationship with Stripe at all. The fix is to translate at the boundary, converting the vendor type into a domain type before it ever crosses into business-layer signatures.

Boundary leakageVendor type smell

98. Explain how Clean Architecture and Onion Architecture use the Dependency Inversion Principle as their core organizing rule across concentric layers.

Both architectures arrange code in concentric rings, domain entities at the center, use cases around them, then interfaces and infrastructure at the outer rings, and enforce a single rule: source-code dependencies may only point inward, toward the center, never outward. Outer rings depend on abstractions defined by inner rings and implement them, which is DIP applied as the literal structural law of the entire codebase, not just a tip for individual classes.

This is why frameworks, databases, and UI are deliberately pushed to the outermost ring in both architectures: they are the most volatile, most detail-laden parts of the system, and DIP says details should depend on abstractions, not the reverse.

99. Clarify the nuance between the Open/Closed Principle and the Dependency Inversion Principle, since both involve abstractions and are frequently conflated in interviews.

OCP is about safely adding new behavior without modifying existing, already-tested code; its concern is extension over time. DIP is about the direction of source-code dependencies between high-level and low-level modules; its concern is decoupling policy from mechanism, regardless of whether new cases are ever added at all. A design can satisfy DIP (business logic depends on a PaymentGateway interface) while still violating OCP elsewhere (a switch statement selecting which gateway implementation to construct), and a design can satisfy OCP for one axis of extension while still violating DIP by having that extension point defined and owned by the low-level module instead of the high-level one.

They frequently work together, an interface satisfying DIP is often the very same interface that enables OCP-style extension, but they answer different questions and should be named separately in an answer, not blended into one another.

PrinciplePrimary concernFails when
Open/ClosedAdding behavior without editing existing codeA new case requires modifying an existing method
Dependency InversionDirection of source-code dependenciesA high-level module imports a concrete low-level detail directly

100. Capstone: walk through refactoring one legacy OrderProcessor class end-to-end so it complies with all five SOLID principles, naming exactly which change addresses which letter.

Start from a single class that validates orders, computes discounts via an if/else chain, charges payment by directly instantiating StripeClient, persists via a hardcoded MySqlOrderRepository, and has a DiscountedOrder subclass that overrides getTotal() to sometimes return a negative adjustment the base class's contract never allowed.

class OrderProcessor {
    private final StripeClient stripe = new StripeClient(apiKey);
    private final MySqlOrderRepository repository = new MySqlOrderRepository();

    Order process(OrderRequest request) {
        if (request.items().isEmpty()) throw new IllegalArgumentException("empty");
        double total = 0;
        for (Item item : request.items()) total += item.price();
        if (request.discountType().equals("SEASONAL")) total *= 0.9;
        else if (request.discountType().equals("LOYALTY")) total *= 0.85;
        stripe.createCharge(total, "USD");
        Order order = new Order(request, total);
        repository.save(order);
        return order;
    }
}

The end-to-end refactor: (S) split validation, discount calculation, charging, and persistence into four focused collaborators, each with one reason to change; (O) replace the discount if/else chain with a Discount interface so a new discount type never edits existing branches; (L) redesign the DiscountedOrder subclass so it never returns a value violating Order's non-negative-total contract, or drop the inheritance entirely in favor of composition; (I) split any fat repository or gateway interface into the narrow read/write or charge/refund roles each collaborator actually needs; (D) have OrderProcessor depend only on PaymentGateway and OrderRepository abstractions, injected via constructor, never constructing StripeClient or MySqlOrderRepository itself.

class OrderProcessor {
    private final OrderValidator validator;
    private final DiscountCalculator discounts;
    private final PaymentGateway payments;      // D: abstraction, injected
    private final OrderRepository repository;   // D: abstraction, injected

    OrderProcessor(OrderValidator validator, DiscountCalculator discounts,
                   PaymentGateway payments, OrderRepository repository) {
        this.validator = validator; this.discounts = discounts;
        this.payments = payments; this.repository = repository;
    }

    Order process(OrderRequest request) {
        validator.validate(request);                       // S: one collaborator per concern
        Order priced = discounts.apply(request.toOrder());  // O: new Discount impls need no edits here
        payments.charge(priced.total());
        return repository.save(priced);
    }
}

Every one of the five letters maps to a distinct, nameable change, and none of them required rewriting the class from scratch: each was applied incrementally, verified by tests after each step, exactly as a real production refactor should be done.

No comments
Leave a Comment