Each SOLID principle with a Java example, SRP violations in small classes, OCP violations in switch statements, LSP and ISP through interfaces (with concrete violations), DIP violations and tight coupling, refactoring fat classes while keeping cohesion, misused DRY, premature abstraction and YAGNI, KISS vs DRY, how Clean Architecture enforces SOLID, code smells and fixes, enterprise Java anti-patterns, balancing performance with clean code, fluent API design, and signs a class or module does too much.
Published September 25, 2026
Principles questions are judged on concrete examples, and nuance. Every principle has a misuse mode:
Senior answers show you know when to apply a principle and when to bend it.
Short answer:
InvoiceService computing totals and rendering PDFs and emailing them has three reasons to change. Split it into InvoiceCalculator, InvoicePdfRenderer and InvoiceMailer.DiscountPolicy implementations, instead of editing a switch.ReadOnlyList implements List whose add throws breaks callers who expect add to work. Model it as a separate read-only abstraction.Printer { print(); scan(); fax(); } into Printer, Scanner and Fax, so a simple printer doesn't implement fax() with a throw.OrderService depends on a PaymentGateway interface (owned by the domain), with StripeGateway implementing it in the infrastructure layer, injected by constructor.Learn it in depth → Object-Oriented Design Refresher
Short answer: SRP is about reasons to change (the actors), not about size. A 40-line class can mix concerns:
UserValidator that validates and writes audit logs and formats error messages for the UI;The test: "who asks for changes to this class?" More than one kind of stakeholder means more than one responsibility.
switch?Short answer: A shipping-cost calculator that switches on the carrier:
BigDecimal cost(Shipment s) {
switch (s.carrier()) {
case "DHL": return dhlRates(s);
case "FEDEX": return fedexRates(s) .add(fuelSurcharge(s));
case "BLUEDART": return blueDartRates(s);
default: throw new IllegalArgumentException();
} // every new carrier edits this method, and every similar switch elsewhere (labels, tracking, returns)
}
The problem is that the same type-switch is duplicated in several places, so adding a carrier means editing all of them, each a regression risk. Fix: a Carrier strategy interface (cost, label, trackingUrl) with one implementation per carrier, registered in a map or injected as beans. New carriers are added, not edited in.
Key points to cover:
Short answer:
interface Repository<T> { save; delete; findAll; bulkImport; exportCsv; } forces a read-only reporting repository to throw on save/delete.Square extends Rectangle, where setWidth also changes the height, breaking callers' area expectations;Collections.unmodifiableList() returns a List whose mutators throw. It's documented as optional, but it's a common practical LSP trap;withdraw() that rejects amounts the base class allowed (a strengthened precondition);equals that breaks symmetry with the superclass.Readable, Writable), so no type claims abilities it lacks. Sealed interfaces, and composition over inheritance, reduce accidental LSP breaks.Learn it in depth → Liskov Substitution & Interface Segregation
Short answer:
public class OrderService {
private final MySqlOrderDao dao = new MySqlOrderDao(); // a concrete detail, created internally
private final StripeClient stripe = new StripeClient("sk_live_..."); // a vendor SDK, and a secret, inside business logic
public void place(Order o) { dao.insert(o); stripe.charge(o.total()); }
}
The consequences:
The fix: the domain defines OrderRepository and PaymentGateway interfaces. The infrastructure implements them (JpaOrderRepository, StripePaymentGateway). Dependencies are injected through the constructor. Now the arrows point inward, toward the domain.
Learn it in depth → Dependency Inversion
Short answer:
PriceCalculator, StockReserver).Short answer: DRY is about knowledge (a single authoritative representation of a business rule), not about identical-looking code. The misuse is merging coincidentally similar code that serves different purposes. For example:
validateAddress() used by billing and shipping, whose rules later diverge;The shared abstraction then couples independent parts. Every change needs coordination, and it grows flags and parameters (validate(address, isBilling, skipPostcode...)).
Heuristics:
Short answer: Classic examples:
PaymentProvider interface, factory, plugin loader and configuration system built for "future providers", when the product only integrates with one provider, and has no plans for more;GenericRepository<T, ID, Q extends Query<?>> hierarchy wrapping Spring Data;The costs: indirection that's hard to navigate, speculative flexibility that usually guesses wrong, and more code to maintain and test. YAGNI: build for today's requirements, keep the code easy to change, and refactor into an abstraction when the second real case arrives. (Extracting an interface later with IDE tooling is cheap.)
Short answer:
They conflict when removing duplication requires complex machinery: generic abstractions, reflection, metaprogramming, deep inheritance, or configuration-driven frameworks. Two simple, slightly duplicated methods may be easier to understand and change than one clever, parameterised method.
Resolve it by: duplicating simple, incidental code freely, and centralising business rules that must stay consistent. When in doubt, favour readability, because code is read far more often than it's written.
Short answer: Clean (Hexagonal, Onion) Architecture organises code in concentric layers: domain entities → use cases (application services) → interface adapters (controllers, repositories, gateways) → frameworks and drivers. The Dependency Rule says source-code dependencies point only inward.
LoadOrderPort, SaveOrderPort).Enforce it with module structure (Maven or Gradle modules, JPMS), and ArchUnit tests (for example, "domain classes must not depend on org.springframework.."). Beware the cost: too many layers and mapping code for simple CRUD services. Scale the architecture to the domain's complexity.
Short answer: A surface symptom that suggests a deeper design problem. It's not a bug, but a warning sign. Three examples:
order.getCustomer().getAddress().getCity() chains). Fix: Move Method to the data owner. "Tell, don't ask."String/BigDecimal/long for emails, money and IDs, with validation scattered everywhere. Fix: value objects (records like Email, Money, OrderId) that validate in their constructors.Others worth naming: shotgun surgery (one change touches many classes), duplicated switch statements, data clumps, long parameter lists (use a parameter object or builder), speculative generality, and comments explaining unclear code (rename or extract instead).
Short answer:
*Service classes, so invariants aren't enforced.@Transactional everywhere cargo cult, or transactions around remote calls.catch (Exception e) {}), and logging plus rethrowing at every layer.Abstract* chains.For each, you'd describe the fix you applied: DTOs and mappers, rich domain objects, bounded contexts, outbox and events.
Short answer:
Short answer: Fluent APIs (method chaining that returns this or a next-step type) read like sentences in the domain language. They make configuration self-documenting, guide users through valid sequences (staged builders, where the type system enforces the order), and reduce errors from positional arguments. Examples:
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).followRedirects(NORMAL).build();HttpSecurity lambda DSL;assertThat(order.total()).isPositive().isLessThan(limit));Comparator.comparing(...).thenComparing(...);WebClient/RestClient request builders.Design tips:
Short answer:
Q: Is SOLID still relevant with functional programming and records? A: Yes, in spirit. Functions and records reduce the need for some patterns, but single responsibility, depending on abstractions (function types are abstractions), and interface segregation (small functional interfaces) still apply.
Q: What is "tell, don't ask"?
A: Instead of querying an object's state and deciding outside it (if (account.getBalance() >= x) account.setBalance(...)), tell the object what to do (account.withdraw(x)), so the logic and its invariants live with the data.
Q: How do you get a team to adopt clean-code practices without dogma? A: Agree on a small set of principles, with examples. Automate the style (formatters, linters, Sonar gates, ArchUnit). Use code reviews for design discussions, not nitpicks. Refactor opportunistically ("leave it better than you found it"), and measure outcomes (defect rates, lead time), not rule compliance.
Q: What is the Law of Demeter?
A: "Only talk to your immediate friends". A method should call methods on itself, its fields, its parameters, and objects it creates, not on objects returned by other calls (a.getB().getC().doX()). It reduces coupling to object structure. Fluent builders and streams are acceptable exceptions.