Skip to content
Go back

OOP, SOLID & Design Patterns: Engineering Judgment for Real Java Code

Published:  at  10:00 AM

Your PaymentService starts as 50 lines of code. It reads a payment type, charges the customer, saves the receipt. Clean. Simple.

Then the business adds credit cards. Then bank transfers. Then e-wallets. Then crypto. Then refunds. Then retry logic. Then notifications. Then audit logging. Then a second payment provider. Then a third.

Eighteen months later, PaymentService is 1,500 lines. Every deployment touches it. Every bug report lands in it. The pull request that added PayPal broke card payments, because both branches lived in the same method and the new else if was placed before the one that handled CARD in a way nobody noticed.

Nobody decided to make this class bad. Every single change was reasonable at the time it was made. That is the uncomfortable truth this article is built around:

Bad design is almost never a single bad decision. It is the accumulated result of many individually reasonable decisions made under pressure.

This is not another SOLID tutorial. You already know the vocabulary — “SOLID means Single Responsibility, Open/Closed…” — and the generic examples that come with it (Animal, Dog, Cat, Shape, Circle). That knowledge is not what is missing.

What is missing is engineering judgment: the ability to look at a real codebase, feel where the design pressure is, choose the right technique — and refuse the techniques that would make things worse. So this article follows one learning flow, over and over:

Problem

Bad Design

Why It Is Bad

Principle / Pattern

Refactoring

Trade-offs

Special Cases (when NOT to do it)

Every concept is introduced because a concrete scenario breaks without it — not because the concept exists in a textbook. The domain stays consistent throughout: payments, orders, notifications, repositories. You will see bad code first, then the pressure it creates, then the refactoring, then the price of the refactoring.

Here is the whole article in one question:

Every time you add an interface, a class, or a pattern, you are spending something (indirection, indirection, indirection). What problem is the spending solving?


1. The Problem That Starts Everything

Go back to the opening story. Let us watch PaymentService grow, because every principle in this article exists to answer the question: where did this go wrong?

Month 1. One payment method. The service is a straight line:

public class PaymentService {
    public void pay(Order order, CardDetails card) {
        validateCard(card);
        chargeCard(card, order.getTotal());
        saveReceipt(order, "PAID");
        sendConfirmationEmail(order);
    }
}

Month 4. The business adds bank transfers and e-wallets. Reasonable reactions, one by one:

Month 18. The class looks like this:

public class PaymentService {
    public void pay(Order order, PaymentRequest request) {
        // 40 lines of validation for 6 payment types
        // 90 lines of branch logic (CARD, BANK_TRANSFER, E_WALLET, CRYPTO)
        // 30 lines of retry logic duplicated for 2 providers
        // 50 lines of email logic
        // 25 lines of audit logging
        // 20 lines of receipt persistence
        // 15 lines of exception translation
        // ...
    }

    public void refund(Payment payment, BigDecimal amount) {
        // another 200 lines, half of it copied from pay()
    }

    // 900 more lines: private helpers, provider-specific quirks,
    // special cases for crypto confirmations, error codes, ...
}

1500 lines. Here is the question you must learn to ask — because it is the question that separates knowing about SOLID from being able to use it:

What actually went wrong?

Not “what should we have done instead” — what went wrong? Which concrete symptoms tell you this class has a design problem, as opposed to just being long?

Look closely at the list of symptoms:

SymptomWhat it tells you
One class changes for many different reasonsSeveral responsibilities share one file
Adding a payment type means editing pay()The variation point is inside an if/else chain
Duplicated retry/email/audit codeCross-cutting behavior is woven into business code
A change to provider A breaks provider BBranches are coupled through shared mutable state
Testing pay() needs a real DB, email, SDKDependencies are hardwired, not injected
You can’t say what pay() does in one sentenceToo many things are happening per method call

Each symptom maps to a principle in this article. The table will fill in as we go — keep it in mind, because the refactoring case study in Part VI walks through fixing this exact class, one symptom at a time.

But before the principles, we need the raw material: the four OOP concepts that everything else is built on. Not as definitions — as mechanisms that either protect your system or betray it.


2. Part I — OOP: More Than Classes and Objects

You know the syntax of classes, objects, inheritance, interfaces. What this part is really about is what those mechanisms do — the protections they provide, the failure modes they create, and the judgment involved in using them. Four concepts, four engineering questions:

ConceptThe engineering question
EncapsulationWho is allowed to touch this state, and how?
AbstractionWhat should callers know about the inside?
InheritanceIs this really an “is-a” relationship?
PolymorphismWho decides which behavior runs?

2.1 Encapsulation: Protecting Invariants, Not Hiding Fields

Most Java developers believe encapsulation means private fields plus getters and setters. It does not. private is the mechanism; encapsulation is the outcome. The outcome is that an object’s state always satisfies its rules — no matter what code touches it.

Bad design first. Here is a BankAccount that compiles, is “encapsulated” by the letter of the rule, and is completely broken:

public class BankAccount {
    public BigDecimal balance;
    public BigDecimal creditLimit;

    public BankAccount(BigDecimal balance, BigDecimal creditLimit) {
        this.balance = balance;
        this.creditLimit = creditLimit;
    }
}

The fields are public, so any caller can do this:

BankAccount account = new BankAccount(BigDecimal.ZERO, BigDecimal.valueOf(100));

// no check, no error — the account is now 50 in the red past its limit
account.balance = account.balance.subtract(BigDecimal.valueOf(150));

// negative deposit — the "balance" can be manipulated arbitrarily
account.balance = account.balance.negate();

// and now the invariant "balance respects credit limit" is destroyed
account.creditLimit = null;   // the next check, wherever it is, will NPE

Why it is bad. The moment balance and creditLimit are exposed as mutable fields, the class has no way to enforce its own business rules. The invariant “the balance may never go below the negative credit limit” is no longer a property of the account — it is a hope. Every caller must remember to check it, and the first caller who forgets corrupts the state for everyone.

The principle. An object is a guardian of its own rules. The fields exist, but every mutation goes through a method that enforces the invariants — the conditions that must always be true for the object to be valid:

public class BankAccount {
    private BigDecimal balance;                    // never null, >= -creditLimit
    private final BigDecimal creditLimit;          // immutable once set

    public BankAccount(BigDecimal balance, BigDecimal creditLimit) {
        if (balance == null || creditLimit == null) {
            throw new IllegalArgumentException("balance and creditLimit are required");
        }
        if (creditLimit.signum() < 0) {
            throw new IllegalArgumentException("credit limit cannot be negative");
        }
        this.balance = balance;
        this.creditLimit = creditLimit;
    }

    public void withdraw(BigDecimal amount) {
        requirePositive(amount, "withdrawal amount");
        BigDecimal newBalance = balance.subtract(amount);
        if (newBalance.compareTo(creditLimit.negate()) < 0) {
            throw new InsufficientFundsException(
                "withdrawal exceeds available credit: " + amount);
        }
        balance = newBalance;
    }

    public void deposit(BigDecimal amount) {
        requirePositive(amount, "deposit amount");
        balance = balance.add(amount);
    }

    public BigDecimal getBalance() {
        return balance;
    }

    public BigDecimal getCreditLimit() {
        return creditLimit;
    }

    private static void requirePositive(BigDecimal value, String what) {
        if (value == null || value.signum() <= 0) {
            throw new IllegalArgumentException(what + " must be positive: " + value);
        }
    }
}

Now the invariants are enforced by the only object that can change the state. Notice two important details:

This is what encapsulation actually means:

Encapsulation is about protecting invariants, not simply hiding fields. private gives you the ability to protect them; it does not do the protecting itself.

Encapsulation leaks: the getter trap. Here is the most common way encapsulation fails even in disciplined code. The class keeps its fields private, but its getter hands out the internal object:

public class BankAccount {
    private final List<Transaction> transactions = new ArrayList<>();

    public List<Transaction> getTransactions() {   // the leak
        return transactions;
    }
}

Any caller can now do account.getTransactions().clear() or add a forged transaction — the internal state is mutated behind the class’s back, and nothing the class does can prevent it. The class looks encapsulated and is not. Two fixes, with different trade-offs:

// Fix A: return an unmodifiable view — cheap, but callers can still
// see the contents and callers can still hold the reference
public List<Transaction> getTransactions() {
    return Collections.unmodifiableList(transactions);
}

// Fix B: return a copy — expensive for large lists, but the caller
// can never observe or affect future changes
public List<Transaction> getTransactions() {
    return new ArrayList<>(transactions);
}

Defensive copying goes both ways. The same leak exists in the other direction: a constructor that stores the caller’s mutable object. If you hold a reference to an object the caller can still mutate, the caller can change your object’s state through the back door:

public class BankAccount {
    private final Address billingAddress;   // Address has setters

    public BankAccount(Address billingAddress) {
        this.billingAddress = billingAddress;   // LEAK: caller still holds it
    }
}

The fix is to copy on the way in:

public BankAccount(Address billingAddress) {
    this.billingAddress = new Address(billingAddress);   // defensive copy
}

Mutable objects vs immutable objects. Notice a pattern in everything above: the problems come from mutability. If Address, Transaction, and BigDecimal were immutable — their fields final, no setters, no way to change after construction — most leaks and defensive copies disappear.

There is a reason BigDecimal (and not double) was used throughout this example: money must never lose precision, and a value type that cannot change protects every invariant that depends on it. The general rule:

Make objects immutable unless you have a demonstrated reason to mutate them. Every setter you remove is a class of bugs you can no longer write.

When defensive copying and immutability are not worth it. Performance criticism of immutable objects is mostly theoretical until it is not: copying a 10,000-element list on every getter call is real cost. If the collection is huge and read in hot paths, an unmodifiable view (Fix A) gives most of the safety at zero copy cost. Similarly, immutable “value objects” built from many fields become constructor soup — that problem has its own solution, and you will meet it in the Builder section (Part III).

Summary of the trade-off. Encapsulation spends a little ceremony (validation methods, copies, immutability) to buy state integrity. The special case is simple: the smaller and more local the state, the less encapsulation machinery you need — a private field inside a 20-line public method’s own class rarely needs defensive copies.

2.2 Abstraction: What Should Callers Know About the Inside?

Encapsulation is about state; abstraction is about behavior. The question is: what contract do you expose to callers, and what do you hide?

The right question about abstraction. An interface in Java is a promise about behavior, not about structure: “any object that implements this can do X.” The design question is what X should be. Get it wrong in one direction and callers are coupled to your implementation details; get it wrong in the other and the abstraction costs more than it returns.

Leaky abstraction. Consider the classic backend boundary: paying through a third-party gateway. Here is an interface that looks abstract and is leaking details from underneath:

public interface PaymentGateway {
    // "token" is a card-specific concept; "brand" too
    PaymentResult chargeWithToken(String token, String brand, BigDecimal amountCents);
    void refundViaChargeId(String chargeId);
    String getProviderName();          // who needs this?
}

Why is this leaky?

The interface has failed: it abstracts one implementation’s vocabulary and calls it a contract. The telltale sign of a leaky abstraction is the if (implementation == concreteOne) in client code, or a parameter that only one implementation can meaningfully fill.

A cleaner abstraction. The interface should speak the domain’s language and hide everything else:

public interface PaymentGateway {
    PaymentResult pay(PaymentRequest request);
}

with domain types:

public class PaymentRequest {
    private final String orderId;
    private final BigDecimal amount;
    private final String currency;
    private final PaymentMethod method;   // card token, bank account, wallet id...

    // constructor + getters
}

public class PaymentResult {
    private final String providerReference;   // provider-specific id, hidden here
    private final boolean success;

    public static PaymentResult success(String providerReference) { ... }
    public static PaymentResult failure(String reason) { ... }
    // getters
}

Now CardGatewayImpl, BankGatewayImpl, and WalletGatewayImpl are all free to interpret PaymentMethod their own way, and every caller — the payment service, the refund service, the reporting job — only ever sees PaymentRequest and PaymentResult. The provider vocabulary (tokens, charges, transfers, settlements) stays inside the implementations. This is the shape of the Adapter pattern, which gets its own section in Part III, and the Dependency Inversion principle, in Part II — abstraction and dependency direction are two sides of the same decision.

What should be abstracted, and what should not. Here is the judgment:

SHOULD be abstracted:
  - Variation points (you expect multiple implementations)
  - External boundaries (SDKs, databases, files, queues, time, random)
  - Volatile details (things that change faster than their callers)

SHOULD NOT be abstracted:
  - Internal helpers with a single implementation
  - Things that change together with their callers
  - Hypothetical futures you have not seen yet

The word to internalize is volatility: abstraction is a bet that the hidden part will change more often — or have more variants — than the visible part. If both sides change at the same rate for the same reason, the interface between them is bureaucracy, not architecture.

Premature abstraction. The clearest symptom of premature abstraction is an interface with exactly one implementation, created because “we might need another one someday,” plus a factory that exists only to look up the one implementation:

public interface EmailSender {
    void send(Email email);
}

public class SmtpEmailSender implements EmailSender { ... }

public class EmailSenderFactory {
    public EmailSender create() {
        return new SmtpEmailSender();       // the factory has nothing to decide
    }
}

Nothing here is wrong — but nothing here is earned either. The callers could use SmtpEmailSender directly. The EmailSender interface and its factory add two indirections that solve zero problems. When the second sender actually appears (a transactional API provider, a queue-based sender), adding the interface is a small, mechanical refactoring that takes minutes. The wasted months of indirection you pay now are not recovered later. The judgment rule:

Abstract in response to a real variation, not in anticipation of one. The first implementation of anything is concrete; the interface appears when the second one exists — or is certain enough to be a requirement, not a guess.

The trade-off, summarized:

Too little abstraction → tight coupling; every change to an internal
                         detail ripples through all callers
Too much abstraction   → indirection with no payoff; callers must
                         navigate layers to find one implementation

The sweet spot is where the abstraction sits at the boundary — between modules that change independently — and nowhere else.

2.3 Inheritance: When “is-a” Is a Trap

Java gives you extends and implements. This section is about the one that causes the trouble: class inheritance. The trouble is not syntax. It is that inheritance forces a taxonomy onto your code, and real-world business rules do not usually form clean taxonomies.

Bad design first. A notification system. It starts simple:

public class Notification {
    private final String recipient;
    private final String message;

    public Notification(String recipient, String message) { ... }

    public void send() {
        System.out.println("Sending email to " + recipient + ": " + message);
    }
}

public class EmailNotification extends Notification {
    private final String subject;

    public EmailNotification(String recipient, String message, String subject) {
        super(recipient, message);
        this.subject = subject;
    }

    @Override
    public void send() {
        System.out.println("Sending email [" + subject + "] to " + recipient);
    }
}

Looks reasonable. “An email notification is a notification.” Then the business adds SMS:

public class SmsNotification extends Notification {
    public SmsNotification(String recipient, String message) {
        super(recipient, message);
    }

    @Override
    public void send() {
        System.out.println("Sending SMS to " + recipient + " (truncated to 160 chars)");
    }
}

Then push notifications, then WhatsApp, then Slack. Each new channel is a subclass. And then the problems start:

  1. The base class accumulates channel-specific cruft. SMS has a 160-character limit, WhatsApp needs a template id, Slack needs a channel name. Where do those fields live? The base Notification starts gaining optional fields (templateId, channelName, attachment) that only some subclasses use. The base class becomes the intersection of all channels — useless fields everywhere.

  2. Shared behavior is duplicated or misinherited. “Retry failed sends” sounds like a base-class concern, so it goes into Notification.send() as a template. But SMS retry limits differ from email retry limits, and now overriding the retry logic means overriding send() — the thing the base class was supposed to centralize.

  3. Fragile base class. Any change to Notification ripples into every subclass. One day someone adds a getDeliveryStatus() check into the base send() that assumes a synchronous channel — and the WhatsApp subclass, which is asynchronous, starts failing in production for reasons buried three levels up the hierarchy.

  4. The taxonomy collapses under LSP pressure. The base class promises “sends a message.” An EmailNotification can attach a file; an SMS cannot. A caller with a List<Notification> that calls attachFile() must instanceof-check every element — the polymorphic illusion is over.

Why this happens. The relationship looked like “is-a” but was really “has-a / can-be-delivered-by.” EmailNotification does not become delivery — it uses a delivery channel. The safe shape is composition:

public class Notification {
    private final String recipient;
    private final String message;
    private final NotificationSender sender;   // has-a, not is-a

    public Notification(String recipient, String message, NotificationSender sender) {
        this.recipient = recipient;
        this.message = message;
        this.sender = sender;
    }

    public void send() {
        sender.send(recipient, message);
    }
}

Now SmsSender, EmailSender, WhatsAppSender implement the single small interface NotificationSender. Adding a channel means adding a class and composing it — the Notification class itself does not change, and the channel-specific rules (SMS truncation, WhatsApp templates) live in the classes that own them. The taxonomy is gone; the variations are data and strategy, not subclasses.

is-a vs has-a: the decision rule. The distinction is not grammar — it is behavioral substitutability. X extends Y claims: “any code that works with a Y will work with an X.” That is the Liskov Substitution Principle, and it gets a full section in Part II. The practical test:

Ask: does the subclass genuinely "work anywhere the parent works"?
  - An EmailNotification used where a Notification is expected: fine.
  - A NotificationSender used BY a Notification: also fine — but it is
    has-a, not is-a.
  - An SmsNotification where an EmailNotification is expected: NOT fine —
    the relationship was never a taxonomy.

Why composition is usually safer. Composition gives you four things inheritance does not:

ConcernInheritanceComposition
ReuseInherited — reuse by ancestryDelegated — reuse by reference
Change rippleA base-class change hits all subclassesOnly the composing class
Runtime flexibilityFixed at compile time (which superclass)Swappable at runtime
Contract enforcementWeak — subclasses can weaken behaviorStrong — interface contract

When inheritance is genuinely reasonable. “Favor composition over inheritance” is a rule of thumb, not a law. Inheritance earns its keep when:

The point is not “never inherit.” The point is: before you write extends, you must be able to defend the substitutability claim — because that is what you are committing to.

2.4 Polymorphism: From if/else Chains to Dispatch — and Back

Polymorphism is the ability of different objects to respond to the same message differently. Java gives you three mechanisms: interface/class dispatch, overriding, and — modern Java — switch pattern matching and lambdas. The question is when each is the right tool.

Bad design first. The payment dispatcher, grown organically:

public class PaymentService {
    public PaymentResult pay(Payment payment) {
        if (payment.getType() == PaymentType.CARD) {
            return cardGateway.charge(payment.getReference(), payment.getAmount());
        } else if (payment.getType() == PaymentType.BANK_TRANSFER) {
            return bankGateway.transfer(payment.getReference(), payment.getAmount());
        } else if (payment.getType() == PaymentType.E_WALLET) {
            return walletGateway.pay(payment.getReference(), payment.getAmount());
        } else if (payment.getType() == PaymentType.CRYPTO) {
            return cryptoGateway.broadcast(payment.getReference(), payment.getAmount());
        } else {
            throw new UnsupportedPaymentTypeException(payment.getType());
        }
    }
}

Why it becomes painful. Every payment type added means editing this method. The chain grows; branch order matters (else if placement bugs are real — remember the PayPal story from the introduction); every caller of pay() recompiles and retests; and the method becomes a switchboard where all the real logic is still inside the branches. The chain does not grow — it deteriorates.

Refactoring toward polymorphism. The shape of the fix: define the behavior as a contract, let each variant implement it, and let the caller ask for the right variant by type. The PaymentStrategy interface (full treatment in Part III) is the vehicle:

public interface PaymentStrategy {
    PaymentResult pay(Payment payment);
}
public class CardPaymentStrategy implements PaymentStrategy {
    private final CardGateway cardGateway;

    public CardPaymentStrategy(CardGateway cardGateway) {
        this.cardGateway = cardGateway;
    }

    @Override
    public PaymentResult pay(Payment payment) {
        return cardGateway.charge(payment.getReference(), payment.getAmount());
    }
}

…and likewise BankTransferPaymentStrategy, WalletPaymentStrategy, CryptoPaymentStrategy. The service becomes:

public class PaymentService {
    private final Map<PaymentType, PaymentStrategy> strategies;

    public PaymentService(Map<PaymentType, PaymentStrategy> strategies) {
        this.strategies = strategies;
    }

    public PaymentResult pay(Payment payment) {
        PaymentStrategy strategy = strategies.get(payment.getType());
        if (strategy == null) {
            throw new UnsupportedPaymentTypeException(payment.getType());
        }
        return strategy.pay(payment);
    }
}

What changed, concretely?

The middle ground: switch expressions and enum dispatch. Before you build a full strategy map, consider whether Java’s own dispatch is enough. If the per-type behavior is small and the types are stable, a switch expression is honest, exhaustive, and much less machinery:

public PaymentResult pay(Payment payment) {
    return switch (payment.getType()) {
        case CARD -> cardGateway.charge(payment.getReference(), payment.getAmount());
        case BANK_TRANSFER -> bankGateway.transfer(payment.getReference(), payment.getAmount());
        case E_WALLET -> walletGateway.pay(payment.getReference(), payment.getAmount());
        case CRYPTO -> cryptoGateway.broadcast(payment.getReference(), payment.getAmount());
    };
}

This is not “less pure polymorphism” — it is a decision about the variation point. The switch is safe to the extent that the enum is sealed (it cannot grow without your editing this method, which the compiler then reminds you of via exhaustiveness). The strategy map is better when the branches are heavy — each with its own dependencies, its own lifecycle, its own testing — or when strategies must be configurable/swappable at runtime.

The special case: when a simple if is better than polymorphism. This is the section that most tutorials never teach. Consider:

public class OrderPersistenceService {
    private final OrderRepository orderRepository;
    private final AuditLogger auditLog;

    public OrderPersistenceService(OrderRepository orderRepository, AuditLogger auditLog) {
        this.orderRepository = orderRepository;
        this.auditLog = auditLog;
    }

    public void persist(Order order) {
        if (order.getStatus() == OrderStatus.CANCELLED) {
            auditLog.record("cancelled-order-skip", order.getId());
            return;
        }
        orderRepository.save(order);
    }
}

Should this be a CancelledOrderPersistenceStrategy and a NormalOrderPersistenceStrategy behind a PersistenceStrategy interface? No. And not because it’s small — because the two branches are not a family of interchangeable algorithms. One is a guard clause; the other is the actual operation. The variation here is a business rule (“cancelled orders are not persisted”), and expressing it as a dispatch table would make the code harder to read by hiding the rule.

Polymorphism earns its cost when all of these hold:

  1. The branches are variants of one behavior (ways of doing the same thing), not different things.
  2. New variants are expected — the variation point is real, not hypothetical.
  3. The branches are big enough that isolating them pays for the indirection (a one-line branch does not).

A two-branch, stable, small if is not a design debt. It is a design decision. The debt is the unbounded chain that grows with every business request — and the skill is telling the two apart.

The OOP part in one paragraph. Encapsulation protects state and rules; abstraction picks the contract at the boundaries; inheritance is only warranted by substitutability; polymorphism moves dispatch out of the business flow. Now take these four mechanisms and apply them to the five principles that govern how classes and modules relate — SOLID.

3. Part II — SOLID: The Engineering Reasoning

SOLID is not a checklist. It is five answers to five different questions about change:

PrincipleThe question it answers
SHow many reasons may one class have to change?
OWhich changes should not modify existing code?
LWhen may a subclass replace its parent?
IWho should an interface serve?
DWhich direction should dependencies point?

Every one of them is a judgment call about volatility — what is likely to change, how often, and who should absorb the change. None of them is an absolute law. This part treats them one at a time, always in the same shape: a real problem, the bad code, the pain, the principle, the refactoring, the trade-offs, and when NOT to apply it.

3.1 S — Single Responsibility Principle: One Reason to Change

The real problem. Go back to the opening story. Here is the classic OrderService — the class that does everything, and therefore changes for everything:

public class OrderService {
    private final OrderRepository orderRepository;
    private final EmailService emailService;
    private final PaymentService paymentService;

    public OrderService(OrderRepository orderRepository,
                        EmailService emailService,
                        PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.emailService = emailService;
        this.paymentService = paymentService;
    }

    public void placeOrder(Order order) {
        // 1. validation
        if (order.getItems().isEmpty()) {
            throw new IllegalArgumentException("order has no items");
        }
        if (order.getCustomer() == null || order.getCustomer().getEmail() == null) {
            throw new IllegalArgumentException("customer email is required");
        }

        // 2. pricing
        BigDecimal total = BigDecimal.ZERO;
        for (OrderItem item : order.getItems()) {
            total = total.add(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())));
        }
        if (total.compareTo(BigDecimal.valueOf(500)) > 0) {
            total = total.multiply(BigDecimal.valueOf(0.9));   // 10% discount over 500
        }
        order.setTotal(total);

        // 3. persistence
        orderRepository.save(order);

        // 4. payment
        paymentService.pay(order);

        // 5. email
        emailService.sendOrderConfirmation(order);

        // 6. logging
        logger.info("Order {} placed with total {}", order.getId(), order.getTotal());
    }
}

Why it is painful. Count the reasons this class can change:

Six independent business decisions, one file. The consequences compound:

The principle. The real SRP is not “a class should do one thing” (every useful class does many things) and definitely not “a class should have one method.” It is:

A class should have one reason to change. A reason to change is a single actor or business function that can demand a change — the pricing rules, the payment providers, the email templates, the persistence schema. Each such reason deserves its own class.

Refactoring. Split along the reasons to change — not along method boundaries:

public class OrderValidator {
    public void validate(Order order) {
        if (order.getItems().isEmpty()) {
            throw new IllegalArgumentException("order has no items");
        }
        if (order.getCustomer() == null || order.getCustomer().getEmail() == null) {
            throw new IllegalArgumentException("customer email is required");
        }
    }
}
public class OrderPricing {
    public BigDecimal calculateTotal(Order order) {
        BigDecimal total = BigDecimal.ZERO;
        for (OrderItem item : order.getItems()) {
            total = total.add(item.getPrice()
                .multiply(BigDecimal.valueOf(item.getQuantity())));
        }
        if (total.compareTo(BigDecimal.valueOf(500)) > 0) {
            total = total.multiply(BigDecimal.valueOf(0.9));
        }
        return total;
    }
}
public class OrderService {
    private final OrderValidator validator;
    private final OrderPricing pricing;
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    private final EmailService emailService;

    // constructor injection...

    public void placeOrder(Order order) {
        validator.validate(order);
        order.setTotal(pricing.calculateTotal(order));
        orderRepository.save(order);
        paymentService.pay(order);
        emailService.sendOrderConfirmation(order);
    }
}

Each class now has exactly one reason to change, can be tested in isolation, and can be reused (the validator and pricer are usable by the order-edit flow and the refund flow too). Notice what did not happen: the OrderService still orchestrates the flow — that is its job. SRP did not turn it into an empty shell; it turned it into a coordinator whose only reason to change is the flow itself.

The difficult question: when are two responsibilities actually different? This is where SRP gets misused, and it deserves precision. Responsibilities are different when they change at different rates, for different reasons, or by different actors. The test:

Ask: "If I change X, will I also want to change Y?"
  - Pricing rule changes and email template changes: different actors,
    different rates → different classes.
  - "Save order" and "load order by id": same actor, same rate, same data
    schema → same class (a repository), always.
  - Validation of an order before payment and before editing: same rule,
    same actor → do NOT split into OrderValidationService and
    OrderEditValidationService.

Over-fragmentation: when splitting hurts. The flip side of SRP is the org-chart refactoring, where every responsibility gets not just a class but a ceremony:

OrderValidator
OrderValidatorImpl
OrderValidationService
OrderPricingCalculator
OrderPricingService
OrderPersistenceManager
OrderRepository
OrderRepositoryImpl
OrderPersistenceService
OrderProcessor
OrderCoordinator
OrderService

Ten classes, each two lines, linked by dependencies, with nothing to test per class beyond “calls the next class.” This is not SRP — it is file splitting. The original OrderService was easier to understand because the flow was visible in one place; the fragmentated version hides the flow behind a dependency chain. The SRP test applies symmetrically:

A class has one reason to change — but it must also be a class that deserves to exist. If a “responsibility” has no rules, no state, and no behavior beyond forwarding, it is not a responsibility; it is a forwarding station. Split when each piece carries real logic; coordinate when the pieces are trivial.

The judgment: SRP is about change isolation, not class count. Five classes each with one genuine responsibility beat one class with five — and beat twenty classes with none.

3.2 O — Open/Closed Principle: Protect the Variation Point

The real problem. Back to the payment dispatcher, but now the pain is specific: adding a payment type is a modification of shared code. Here is the scenario everyone recognizes:

public class PaymentService {
    public void pay(Payment payment) {
        if (payment.getType().equals("CARD")) {
            cardGateway.charge(payment.getReference(), payment.getAmount());
        } else if (payment.getType().equals("BANK_TRANSFER")) {
            bankGateway.transfer(payment.getReference(), payment.getAmount());
        } else if (payment.getType().equals("E_WALLET")) {
            walletGateway.pay(payment.getReference(), payment.getAmount());
        } else {
            throw new UnsupportedPaymentTypeException(payment.getType());
        }
    }
}

The business says: “Add PayPal.” The change is:

        } else if (payment.getType().equals("PAYPAL")) {
            paypalGateway.pay(payment.getReference(), payment.getAmount());
        }

Why is this tiny change risky? Because the file being edited is shared — it holds the card path that runs real money. Any mis-placement, any merge conflict, any half-finished refactor in that file risks every payment type, not just the new one. The change is one line and the blast radius is the whole class. Over months, this method accumulates ten types, and its risk profile is now ten payment products pinned to one file.

The principle. The Open/Closed Principle says: a module should be open for extension (you can add behavior) and closed for modification (adding behavior does not require editing the existing module). The important correction, because this principle is quoted wrongly constantly:

OCP does NOT mean “never modify existing code.” It means: protect the variation point — the place where you know new variants will arrive — with an abstraction, so that adding a variant is an addition (a new class, a new registration) instead of a modification (an edit to shared code). You are not avoiding change; you are routing it.

The kind of change OCP protects against is one-dimensional: a new member of a known family (payment types, notification channels, report formats, export destinations). For that family, you want the “add” cost to be small and isolated, and the “modify” cost to be zero.

Refactoring with polymorphism + registration (Strategy). Using the strategy map from Section 2.4:

public class PaymentService {
    private final Map<PaymentType, PaymentStrategy> strategies;

    public PaymentService(Map<PaymentType, PaymentStrategy> strategies) {
        this.strategies = strategies;
    }

    public PaymentResult pay(Payment payment) {
        PaymentStrategy strategy = strategies.get(payment.getType());
        if (strategy == null) {
            throw new UnsupportedPaymentTypeException(payment.getType());
        }
        return strategy.pay(payment);
    }
}

Adding PayPal is now: a new PayPalPaymentStrategy class, and one entry in the map (usually a Spring @Bean registration or a config file — often zero edits to Java). PaymentService never changes again for a new payment type. The shared file — the one that runs real money — is closed.

Trade-offs. What did this buy, and what did it cost?

BoughtCost
New variants do not touch shared codeA new abstraction (interface + registry)
Each variant is independently testableDispatching is now indirect
Variant logic is co-located with its dependenciesThe “one method shows all branches” readability is gone

Special cases — when NOT to apply OCP.

  1. The variation point is unknown. You cannot “protect” a variation you have not seen. Building a strategy registry for the first payment method is speculative. The honest sequence: write the if, feel the second branch arrive, then introduce the abstraction. Refactoring to OCP is cheap when the branches are small; guessing the variation point is expensive when it is wrong.

  2. The change is not one-dimensional. If the “new payment type” also changes how orders flow, how refunds work, and what the receipt looks like, no dispatch table will contain the change — the variation is cross-cutting and the interface will leak (Section 2.2’s “family that is not a family”).

  3. The family is closed by nature. sealed enums (Part VII) or sealed class hierarchies deliberately fix the family. Then “closed for modification” is desired: the exhaustive switch the compiler verifies beats a registration map you can forget to update.

The recurring judgment is: OCP is a bet on which dimension will grow. Make the bet only when the growth is visible.

3.3 L — Liskov Substitution Principle: Behavioral Compatibility

The LSP is the one SOLID principle that is not about change management — it is about correctness of the type system. It answers: when is a subclass allowed to replace its parent?

The real problem. Forget Bird and Penguin. Here is a backend version, and it fails in production, not in a zoo.

public class VoucherDiscount {
    private final BigDecimal percentage;

    public VoucherDiscount(BigDecimal percentage) {
        this.percentage = percentage;
    }

    public BigDecimal apply(BigDecimal total) {
        return total.multiply(BigDecimal.ONE.subtract(percentage));
    }
}

The checkout flow uses VoucherDiscount polymorphically — it is stored in a List<VoucherDiscount> and applied to every order:

public BigDecimal applyAll(List<VoucherDiscount> vouchers, BigDecimal total) {
    BigDecimal result = total;
    for (VoucherDiscount voucher : vouchers) {
        result = voucher.apply(result);
    }
    return result;
}

Now the business adds a “minimum spend” voucher: only orders over 200 qualify. The developer writes:

public class MinimumSpendVoucher extends VoucherDiscount {
    private final BigDecimal minimumSpend;

    public MinimumSpendVoucher(BigDecimal percentage, BigDecimal minimumSpend) {
        super(percentage);
        this.minimumSpend = minimumSpend;
    }

    @Override
    public BigDecimal apply(BigDecimal total) {
        if (total.compareTo(minimumSpend) < 0) {
            throw new IllegalArgumentException("order below minimum spend");
        }
        return super.apply(total);
    }
}

It compiles. It passes its own tests. And it breaks checkout in production, because applyAll has no idea a VoucherDiscount.apply() can throw. The parent contract said: “returns the discounted total.” The subclass’s stronger precondition (“caller must not pass totals under 200”) violates it.

The principle. LSP says: if S is a subtype of T, then any property that holds for objects of type T must also hold for objects of type S — a subclass must be usable anywhere its parent is used, without the caller knowing. Concretely, it is about three kinds of contract clauses:

ClauseWhat it meansSubtype rule
PreconditionWhat the caller must guarantee before callingMay only be weakened, never strengthened
PostconditionWhat the call guarantees after returningMay only be strengthened, never weakened
InvariantWhat is true before and after every callMust be preserved
ExceptionsThe failure vocabulary of the methodMust remain within the parent’s vocabulary

MinimumSpendVoucher strengthened a precondition (“total must be >= 200”) and added a new exception the callers did not handle. @Override compiled fine — LSP is a behavioral contract, and the compiler checks signatures, not behavior.

More violation patterns to recognize. These are the shapes you meet in real code, with the telltale symptom:

// Pattern 1: subclass throws what the parent explicitly allows
public class ReadOnlyAccount extends BankAccount {
    @Override
    public void withdraw(BigDecimal amount) {
        throw new UnsupportedOperationException("read-only account");
    }
}
// Caller: account.withdraw(...) — compiled fine, blows up at runtime
// because the caller could not know this subtype forbids the operation.

// Pattern 2: subclass returns weaker results
public class CacheOrderRepository extends OrderRepository {
    @Override
    public Order findById(String id) {
        Order cached = cache.get(id);
        return cached != null ? cached : null;   // parent never returns null
    }
}
// Caller relies on non-null; gets NPE two frames later, far from the cause.

// Pattern 3: the caller defends with instanceof
public void refund(Payment payment) {
    if (payment instanceof CryptoPayment) {
        // crypto can't be refunded the same way — special-case it
    }
    ...
}
// The moment client code has to know which subclass it holds,
// polymorphism is dead and the LSP is violated.

Every one of these is a behavioral mismatch that compiles. That is the whole point of the section:

Compile-time compatibility is about signatures. Behavioral compatibility is about contracts. @Override proves the first; only design discipline gives you the second.

Why does this happen? Because “is-a” was decided on structure (“a read-only account is an account”) instead of behavior (“can this subclass honor everything the parent promises?”). The fix is rarely “fix the subclass” — usually the hierarchy was wrong:

The rule of thumb: before writing extends, ask: can every caller of the parent behave exactly the same with this subclass, without knowing it is there? If the answer needs a caveat, the hierarchy is wrong.

Trade-offs and special cases. LSP strictness is about where the hierarchy is real. For deep library hierarchies (Exception, InputStream) the contract matters enormously. For tiny internal hierarchies with one caller, a pragmatic shortcut (e.g., a subclass that special-cases one method) may be cheaper than redesigning — but you are borrowing from correctness to pay for speed, and you should know it. Sealed classes (Part VII) give Java a built-in tool to limit who can subclass what, which turns “everyone may extend” into “only these known variants” — the cleanest LSP protection Java offers.

3.4 I — Interface Segregation Principle: Interfaces Sized for Their Clients

The real problem. A ReportService interface, grown by accretion:

public interface ReportService {
    byte[] generateCsv(ReportQuery query);
    byte[] generatePdf(ReportQuery query);
    byte[] generateXlsx(ReportQuery query);
    void sendByEmail(byte[] report, String recipient);
    void uploadToS3(byte[] report, String path);
}

Three concrete implementations exist: CsvReportService, PdfReportService, XlsxReportService. Each implements the interface, and each must provide all five methods. CsvReportService cannot generate PDFs and has no reason to upload to S3 — so it contains:

public class CsvReportService implements ReportService {
    @Override
    public byte[] generateCsv(ReportQuery query) { /* real logic */ }

    @Override
    public byte[] generatePdf(ReportQuery query) {
        throw new UnsupportedOperationException("CSV service cannot generate PDFs");
    }

    @Override
    public byte[] generateXlsx(ReportQuery query) {
        throw new UnsupportedOperationException("CSV service cannot generate XLSX");
    }

    @Override
    public void sendByEmail(byte[] report, String recipient) {
        throw new UnsupportedOperationException("email not supported");
    }

    @Override
    public void uploadToS3(byte[] report, String path) {
        throw new UnsupportedOperationException("upload not supported");
    }
}

Why it is painful. The interface forces every client of ReportService to know about methods it will never call, and every implementation to carry dead code. The signature sendByEmail(byte[] report, String recipient) is also a leak — “byte[]” is a PDF-ish concept; the CSV client doesn’t think in byte arrays. Clients depend on the interface, the interface depends on the union of all clients’ needs, so every client conceptually depends on every other client’s requirements. A change to the PDF flow touches the interface, which forces recompilation and retesting of the CSV flow — the same shared-file risk as OCP, but at the interface level.

The principle. Interface Segregation says: no client should be forced to depend on methods it does not use. Split the fat interface along client boundaries — each interface expresses one coherent capability:

public interface CsvReportGenerator {
    byte[] generateCsv(ReportQuery query);
}

public interface PdfReportGenerator {
    byte[] generatePdf(ReportQuery query);
}

public interface ReportDeliverable {
    void sendByEmail(byte[] report, String recipient);
}

Now CsvReportService implements CsvReportGenerator (and, if the CSV flow does email, also ReportDeliverable). Clients take exactly the interface they need:

public class ReportBatchJob {
    private final CsvReportGenerator csvGenerator;   // only what it uses
    private final ReportDeliverable deliverable;

    public ReportBatchJob(CsvReportGenerator csvGenerator, ReportDeliverable deliverable) {
        this.csvGenerator = csvGenerator;
        this.deliverable = deliverable;
    }
}

Default methods and the Adapter as mitigations. Real-world libraries (JDK interfaces, third-party SDKs) are fat and you cannot change them. Two tools:

The opposite problem: interface fragmentation. Segregation can be over-applied. Consider:

public interface Identifiable { String getId(); }
public interface Timestamped { Instant getCreatedAt(); }
public interface Auditable extends Timestamped { String getCreatedBy(); }
public interface HasName { String getName(); }
public interface HasEmail { String getEmail(); }
public interface Validatable { void validate(); }
public interface Serializable { ... }   // already exists, but you get the idea

public class User implements Identifiable, Timestamped, HasName, HasEmail, Validatable { ... }

When does this become over-engineering? When the interfaces are data markers rather than behavioral capabilities: every client that uses a User uses all its fields; nothing depends on HasEmail alone; and the codebase now has seven names to navigate to find one class. Segregation pays when clients differ in what they need — a ReportBatchJob that only needs CSV generation is a real, separate client. If every client needs everything, the interface is cohesive as-is, and splitting it is ceremony. The test:

A split interface is justified when a real client exists that needs only the split part. Hypothetical clients do not count — the same volatility rule as every other principle.

3.5 D — Dependency Inversion Principle: High-Level Policy Owns the Abstraction

This is the deepest of the five, and the most abused — because it is constantly confused with its cousin, Dependency Injection. They are not the same thing:

Dependency Inversion (DIP)Dependency Injection (DI)
What it isA rule about direction of dependenciesA mechanism for supplying dependencies
The question it answersWhich module should own the abstraction?How does an object get its dependencies?
Where it livesIn the architecture (package/component shape)In the construction of objects
The relationshipDI is one way to implement DIPDIP is one reason to use DI

The real problem. A high-level business flow that depends on a low-level implementation:

public class OrderService {
    private final MySqlOrderRepository repository;

    public OrderService(MySqlOrderRepository repository) {
        this.repository = repository;
    }

    public void save(Order order) {
        repository.insert(order);
    }

    public Order find(String id) {
        return repository.selectById(id);
    }
}

OrderService — the module that encodes business policy — is pinned to MySqlOrderRepository — a module that encodes SQL, connections, and a specific vendor. Every policy decision now depends on a storage decision: switching to PostgreSQL, adding a cache in front, or introducing an in-memory implementation for tests means editing the policy class or faking a concrete class with a specific vendor’s details in its methods. The dependency arrow points up: policy → vendor.

The principle. Dependency Inversion inverts that arrow:

A. High-level modules should not depend on low-level modules. Both should depend on abstractions. B. Abstractions should not depend on details. Details should depend on abstractions.

The key insight — the one tutorials skip — is the second sentence of A: the abstraction is owned by the high-level module. It expresses what the policy needs, not what the vendor offers:

public interface OrderRepository {          // owned by the policy layer
    void save(Order order);
    Order findById(String id);
}
public class OrderService {
    private final OrderRepository repository;   // depends on the abstraction

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public void save(Order order) {
        repository.save(order);
    }

    public Order find(String id) {
        return repository.findById(id);
    }
}
public class MySqlOrderRepository implements OrderRepository {
    // SQL, connections, vendor specifics — a detail, implementing the policy
    // layer's contract
    @Override
    public void save(Order order) { /* INSERT INTO orders ... */ }

    @Override
    public Order findById(String id) { /* SELECT * FROM orders WHERE id = ? */ }
}

Now the dependency graph is:

                OrderService (policy)


              OrderRepository (abstraction)      ← owned by policy layer


              MySqlOrderRepository (detail)      ← implements the contract

Both modules depend on the abstraction; the abstraction expresses the policy’s needs; the detail conforms. This is the Ports and Adapters style: the interface is the port (the policy’s requirement), the implementation is the adapter (the detail that fulfills it).

What this buys. The high-level module is now reusable and testable independent of storage: an InMemoryOrderRepository for tests, a CachedOrderRepository for production hot paths, a PostgreSQL implementation swapped in without touching policy. The change that used to threaten OrderService (storage replacement) now produces a new adapter — the classic OCP effect at the dependency level.

Dependency Injection and Spring. DIP is the what; DI is the how. Construction of the graph — who creates what and hands it where — is Dependency Injection, and the standard Java tool is a container. Spring Boot’s version:

@Service
public class OrderService {
    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {   // constructor injection
        this.repository = repository;
    }
}

What Spring actually does here:

  1. Component scanning discovers @Service, @Repository, @Component classes on startup.
  2. Bean instantiation creates one singleton instance per component (lazily, and in dependency order).
  3. Constructor injection resolves OrderService’s constructor parameter — finds the single OrderRepository bean in the container — and passes it in.

Crucially, Spring did not need to know which implementation to choose — that decision was made either by type (one implementation) or by explicit @Primary/@Qualifier/@Configuration beans when several exist:

@Configuration
public class RepositoryConfig {
    @Bean
    @Primary
    public OrderRepository orderRepository(DataSource dataSource) {
        return new MySqlOrderRepository(dataSource);
    }

    @Bean
    public OrderRepository cacheableOrderRepository(OrderRepository delegate) {
        return new CachedOrderRepository(delegate);
    }
}

The container is only ever a composition root — the single place where the dependency graph is assembled. The OrderService above knows nothing about MySqlOrderRepository or CachedOrderRepository; it knows only the port.

When introducing interfaces purely for DI is unnecessary. This is the abuse case, and it is everywhere. The anti-pattern:

// One implementation. Ever. And the interface expresses nothing the
// policy layer needs beyond what the class already offers.
public interface UserRepository { ... }

public class UserRepositoryImpl implements UserRepository {
    // the ONLY implementation
}

When is this interface pointless? When:

  1. There is exactly one implementation and no real second one in sight.
  2. The interface mirrors the class’s methods instead of expressing a policy requirement — it is a clone, not a contract.
  3. The only consumer that “needs” it is the test suite — and your mocking framework (Mockito can mock concrete classes) can handle that without an interface.

In that case the interface is not doing DIP work; it is naming ceremony. Spring does not require interfaces — @Service on the concrete class, constructor injection of the concrete class, is perfectly idiomatic for a single implementation. The interface earns its existence the same way every abstraction does in this article: a real second variant, or a real policy contract the concrete class cannot express.

The DIP summary. Point the dependency arrows at abstractions owned by the high-level module; use constructor injection to assemble the graph; and refuse interfaces that serve only convention. DIP is about which direction change travels — policy should never change because storage did.


4. Part III — Design Patterns: Problems They Solve, Prices They Charge

The 23 GoF patterns are not a catalog to memorize; they are 23 proven solutions to recurring problems, and each one has a price. This part covers the six that a Java backend actually meets constantly — organized by the problem they solve, not by name. For each: the problem, the bad shape, the pattern, the trade-offs, and when it is overkill.

PatternProblem it solves
StrategyA family of interchangeable behaviors
FactoryObject creation with selection logic
BuilderConstructing objects with many parameters
AdapterAn external API that does not match your model
DecoratorAdding behavior without touching the core
ObserverOne event, many interested parties

4.1 Strategy: A Family of Interchangeable Behaviors

The problem. You have already seen it twice — the payment dispatcher (Section 2.4) is the canonical Strategy scenario:

if (payment.getType() == PaymentType.CARD) {
    cardGateway.charge(...);
} else if (payment.getType() == PaymentType.BANK_TRANSFER) {
    bankGateway.transfer(...);
} else if (payment.getType() == PaymentType.E_WALLET) {
    walletGateway.pay(...);
}

The pain, precisely: the decision (which type → which behavior) and the behaviors are fused in one method; adding a type edits the fusion.

The pattern. Extract the behaviors behind one interface and let the caller hold a strategy — either injected directly or looked up by type:

public interface PaymentStrategy {
    PaymentResult pay(Payment payment);
}
public class CardPaymentStrategy implements PaymentStrategy {
    private final CardGateway cardGateway;

    public CardPaymentStrategy(CardGateway cardGateway) {
        this.cardGateway = cardGateway;
    }

    @Override
    public PaymentResult pay(Payment payment) {
        return cardGateway.charge(payment.getReference(), payment.getAmount());
    }
}

Selection via a registry (constructed once, in the composition root):

public class PaymentStrategyFactory {
    private final Map<PaymentType, PaymentStrategy> strategies;

    public PaymentStrategyFactory(Map<PaymentType, PaymentStrategy> strategies) {
        this.strategies = strategies;
    }

    public PaymentStrategy forType(PaymentType type) {
        PaymentStrategy strategy = strategies.get(type);
        if (strategy == null) {
            throw new UnsupportedPaymentTypeException(type);
        }
        return strategy;
    }
}
PaymentStrategy strategy = factory.forType(payment.getType());
PaymentResult result = strategy.pay(payment);

Why Strategy works. It is three ideas you have already met, in one place:

Trade-offs. The chain becomes a class per variant plus a registry. The flow is now read through indirection: strategy.pay(...) does not tell you what happens — you must know which strategies exist. For three stable branches, the indirection may be pure cost. Strategy is also the modern poster child for simplification, because Java 8 turned it into a one-liner: the interface has one method, so it is a functional interface and callers can pass lambdas instead of classes (Part VII).

When Strategy is overkill. The decision rule from Section 2.4, stated as a checklist:

4.2 Factory: Object Creation That Has Decisions in It

The problem. new is the simplest way to create an object, and it is the wrong tool exactly when creation involves a decision. Where does decision-laden creation happen in a real backend? Selecting a strategy by type (seen above), building a provider for a given country, choosing a discount policy from config, or constructing a domain object whose wiring depends on context.

Simple Factory. The payment example, formalized:

public class PaymentProcessorFactory {
    private final Map<PaymentType, PaymentStrategy> strategies;

    public PaymentProcessorFactory(Map<PaymentType, PaymentStrategy> strategies) {
        this.strategies = strategies;
    }

    public PaymentStrategy create(PaymentType type) {
        PaymentStrategy strategy = strategies.get(type);
        if (strategy == null) {
            throw new UnsupportedPaymentTypeException(type);
        }
        return strategy;
    }
}

That’s it — the entire pattern. (Note: this “Simple Factory” is not one of the 23 GoF patterns; it is the pragmatic helper that everyone uses, and the GoF Factory Method / Abstract Factory are its more rigorous cousins. Start here.) The caller’s creation logic disappears:

// before: the caller knew the map, the fallback, the error type
// after:  the caller asks; the factory owns the decision
PaymentStrategy strategy = processorFactory.create(payment.getType());

Factory Method. When the subclass should decide which object a template flow creates. Classic backend shape: a base flow with a creation hook:

public abstract class RefundFlow {
    public final void execute(RefundRequest request) {
        PaymentRefundHandler handler = createRefundHandler();   // hook
        handler.refund(request);
        notifyCustomer(request);
    }

    protected abstract PaymentRefundHandler createRefundHandler();
}
public class CardRefundFlow extends RefundFlow {
    @Override
    protected PaymentRefundHandler createRefundHandler() {
        return new CardRefundHandler(cardGateway);
    }
}

The template (refund → notify) is fixed; the created object is the variation. Factory Method is appropriate when the flow itself is stable and the created thing varies per subclass. If only the created thing varies and the flow is trivial, the Simple Factory above is enough — the Factory Method’s inheritance tax is not earned.

Abstract Factory. When you need a family of related objects that must stay consistent. One provider is not just a gateway — it is a gateway, a refund handler, a receipt generator, and a currency validator, and they must all come from the same vendor:

public interface PaymentProviderFactory {
    PaymentGateway createGateway();
    RefundHandler createRefundHandler();
    ReceiptGenerator createReceiptGenerator();
}
public class StripeProviderFactory implements PaymentProviderFactory {
    private final StripeSdk stripe;

    public StripeProviderFactory(StripeSdk stripe) {
        this.stripe = stripe;
    }

    @Override
    public PaymentGateway createGateway() { return new StripeGatewayAdapter(stripe); }

    @Override
    public RefundHandler createRefundHandler() { return new StripeRefundHandler(stripe); }

    @Override
    public ReceiptGenerator createReceiptGenerator() { return new StripeReceiptGenerator(stripe); }
}

The guarantee: code that builds a payment flow through a factory never mixes Stripe’s gateway with Adyen’s refund handler. In Spring, the factory is often implicit — each @Bean factory method produces one member of the family, and the container enforces consistency by construction. Abstract Factory is the pattern you reach for when the family is real (mixing vendors would be a bug); it is pure ceremony when there is only one vendor and one member per family.

The special case: a factory that just wraps new. This is the pattern that deserves the most suspicion:

public class CarFactory {
    public Car createCar() {
        return new Car();          // the factory adds nothing
    }
}

A factory’s value is the decision it hides — the map lookup, the config check, the family consistency. A factory that unconditionally calls new hides nothing and adds an indirection; the caller should write new Car() (or, in Spring, declare a @Bean). The test: would the factory ever return something different given different input or context? If no — no factory.

4.3 Builder: When Constructors Can No Longer Communicate

The problem. Positional constructors stop scaling at about three or four arguments, and the failure is silent:

User user = new User(
    "Hung",
    "hung@example.com",
    28,
    "12 Nguyen Hue, Ho Chi Minh City",
    "0901234567",
    "ACCOUNTANT",
    true,          // isActive — or was that isVerified?
    "VN"
);

Which boolean was that? Is the phone before the address or after? Swap two arguments and the code still compiles — the bug is a semantic one, undetectable by the compiler, sometimes invisible for months. Then the business adds referralCode and marketingOptIn, and every call site must be updated to pass two more positional arguments, most of them null.

The pattern. A nested builder, fluent, validating at build():

public final class User {
    private final String name;
    private final String email;
    private final int age;
    private final String address;
    private final String phone;
    private final boolean active;

    private User(UserBuilder builder) {
        this.name = builder.name;
        this.email = builder.email;
        this.age = builder.age;
        this.address = builder.address;
        this.phone = builder.phone;
        this.active = builder.active;
    }

    public static UserBuilder builder() {
        return new UserBuilder();
    }

    public static final class UserBuilder {
        private String name;
        private String email;
        private int age;
        private String address;
        private String phone;
        private boolean active = true;

        public UserBuilder name(String name) { this.name = name; return this; }
        public UserBuilder email(String email) { this.email = email; return this; }
        public UserBuilder age(int age) { this.age = age; return this; }
        public UserBuilder address(String address) { this.address = address; return this; }
        public UserBuilder phone(String phone) { this.phone = phone; return this; }
        public UserBuilder active(boolean active) { this.active = active; return this; }

        public User build() {
            if (name == null || name.isBlank()) {
                throw new IllegalArgumentException("name is required");
            }
            if (email == null || !email.contains("@")) {
                throw new IllegalArgumentException("email is invalid");
            }
            if (age < 0) {
                throw new IllegalArgumentException("age cannot be negative");
            }
            return new User(this);
        }
    }
}

The call site reads like a specification:

User user = User.builder()
        .name("Hung")
        .email("hung@example.com")
        .age(28)
        .address("12 Nguyen Hue, Ho Chi Minh City")
        .active(true)
        .build();

Why Builder earns its keep here. It gives: readability (every argument is labeled), immutability (the class has no setters; the builder is the only construction path), optional parameters (you simply omit steps, and sensible defaults hold), and validation in one place (build() checks, so no half-built User can exist).

Special cases — when Builder is the wrong tool.

  1. Small objects. Three or four parameters, all mandatory: a plain constructor is better — a builder for Address(street, city, country) is noise. new Address(...) with three args is readable.
  2. Java records. A record (Part VII) gives immutability, equals/hashCode, and a canonical constructor in one declaration — for typical value objects, the record is the improvement, with no builder needed. A record plus a compact constructor validating its args replaces the builder’s build() check.
  3. Static factory methods. When the variations are named concepts rather than field combinationsUser.createCustomer(...) vs User.createAdmin(...) — a static factory communicates the concept; a builder communicates the fields. Use the one that matches the meaning.
  4. The “builder with defaults” trap. Builders with hidden defaults (is active true or false if not set?) can silently construct objects with the wrong business meaning. Prefer explicit required fields — make the builder fail on omissions the domain treats as mandatory.

The judgment: Builder solves argument readability and immutability. The moment records and static factories cover those, the Builder’s boilerplate (a dozen lines per field) is a cost to justify — for 8+ args with mixed optionality it still wins; for 3 args it loses.

4.4 Adapter: When the External API Does Not Speak Your Language

The problem. Your domain speaks PaymentRequest, PaymentResult, orderId, BigDecimal. The payment provider SDK speaks Stripe’s vocabulary — StripeCharge, amountInCents, token, StripeException. The naive integration writes provider code straight into the business flow:

public class PaymentService {
    private final StripeSdk stripe;

    public void pay(Order order, String cardToken) {
        try {
            StripeCharge charge = stripe.charge(cardToken, toCents(order.getTotal()), "USD");
            // business logic now knows about StripeCharge, StripeException,
            // cents conversion, charge ids...
            order.setStatus(charge.getPaid() ? OrderStatus.PAID : OrderStatus.FAILED);
        } catch (StripeDeclinedException e) {
            // translate exceptions inline — in every call site
            order.setStatus(OrderStatus.PAYMENT_DECLINED);
        } catch (StripeNetworkException e) {
            throw new RuntimeException("stripe down", e);
        }
    }
}

Why it is bad. Three failure modes, compounding:

  1. The SDK’s types leak everywhere. StripeCharge, StripeException, and toCents() appear in the service, the controller, the refund flow. The domain model is colonized by the vendor.
  2. Every call site re-implements the translation. The try/catch translation above will be copy-pasted into refund, retry, and reconciliation flows — and each copy will drift.
  3. The vendor owns your architecture. Changing providers means editing every business class that touches Stripe types — the exact coupling DIP (Section 3.5) exists to prevent.

The pattern. The Adapter sits at the boundary. Your domain defines the interface (PaymentGateway); an adapter class translates domain calls into SDK calls and SDK responses back into domain results:

public class StripeGatewayAdapter implements PaymentGateway {
    private final StripeSdk stripe;

    public StripeGatewayAdapter(StripeSdk stripe) {
        this.stripe = stripe;
    }

    @Override
    public PaymentResult pay(PaymentRequest request) {
        try {
            StripeCharge charge = stripe.charge(
                request.getMethod().getToken(),
                toCents(request.getAmount()),
                request.getCurrency());
            return PaymentResult.success(charge.getId());
        } catch (StripeDeclinedException e) {
            return PaymentResult.failure("declined: " + e.getMessage());
        } catch (StripeNetworkException e) {
            throw new PaymentUnavailableException("stripe unreachable", e);
        }
    }

    private static long toCents(BigDecimal amount) {
        return amount.movePointRight(2).longValueExact();
    }
}

The flow is now vendor-free:

public class PaymentService {
    private final PaymentGateway gateway;

    public void pay(Order order, PaymentRequest request) {
        PaymentResult result = gateway.pay(request);
        order.setStatus(result.isSuccess() ? OrderStatus.PAID : OrderStatus.FAILED);
        // nothing here knows about Stripe, cents, or tokens
    }
}

The consequences. The domain boundary is restored: business logic knows only PaymentGateway; SDK types live inside the adapter; provider swapping, SDK upgrades, and exception translation happen in exactly one place per provider. This is also the DIP in its purest practical form — the adapter is the “adapter” of Ports & Adapters (Section 3.5).

Trade-offs and special cases.

4.5 Decorator: Adding Behavior Without Touching the Core

The problem. The payment flow needs, in addition to paying: retry on transient failures, audit logging of every attempt, and metrics for latency. The naive approach — edit the implementation — has a compounding cost: the retry logic written into StripeGatewayAdapter must be re-written for every future provider; the metrics are interleaved with the business logic; and testing “retry” requires a real provider.

The naive alternative — inheritance — is worse. To give two providers (retry, logging, metrics), you need:

RetryingStripeAdapter   LoggingStripeAdapter     MetricsStripeAdapter
RetryingBankAdapter     LoggingBankAdapter       MetricsBankAdapter
RetryingLoggingStripeAdapter   LoggingMetricsBankAdapter  ...

Every new behavior × every new provider = a new class. The combinatorial explosion is the smell that says “inheritance is the wrong mechanism.”

The pattern. Decorator composes behavior recursively through the same interface: each decorator implements PaymentGateway, holds another PaymentGateway, does its thing, and delegates:

public class RetryDecorator implements PaymentGateway {
    private final PaymentGateway delegate;
    private final int maxAttempts;
    private final Duration backoff;

    public RetryDecorator(PaymentGateway delegate, int maxAttempts, Duration backoff) {
        this.delegate = delegate;
        this.maxAttempts = maxAttempts;
        this.backoff = backoff;
    }

    @Override
    public PaymentResult pay(PaymentRequest request) {
        PaymentResult result = delegate.pay(request);
        for (int attempt = 1; !result.isSuccess() && attempt < maxAttempts; attempt++) {
            sleepQuietly(backoff.multipliedBy(attempt));
            result = delegate.pay(request);
        }
        return result;
    }

    private static void sleepQuietly(Duration duration) {
        try {
            Thread.sleep(duration);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
public class LoggingDecorator implements PaymentGateway {
    private final PaymentGateway delegate;
    private final AuditLogger auditLog;

    public LoggingDecorator(PaymentGateway delegate, AuditLogger auditLog) {
        this.delegate = delegate;
        this.auditLog = auditLog;
    }

    @Override
    public PaymentResult pay(PaymentRequest request) {
        long started = System.nanoTime();
        PaymentResult result = delegate.pay(request);
        auditLog.log("payment", request.getOrderId(), result, elapsedMillis(started));
        return result;
    }

    private static long elapsedMillis(long startedNanos) {
        return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
    }
}

Composition at the wiring point — the flow, the core, and the provider stay untouched:

PaymentGateway gateway = new RetryDecorator(
        new LoggingDecorator(
                new MetricsDecorator(
                        new StripeGatewayAdapter(stripe))),
        3, Duration.ofMillis(200));
PaymentService

RetryDecorator        ← behavior, independent of provider

LoggingDecorator      ← behavior, independent of provider

MetricsDecorator      ← behavior, independent of provider

StripeGatewayAdapter  ← provider, independent of behavior

Why it works. Adding a behavior = adding a class + one wiring change; combining N behaviors with M providers takes N + M classes instead of N × M. The core implementation is never edited. The pipeline is visible at the composition point — you can read the whole execution order in one place. And each decorator is testable alone (wrap a fake delegate).

Trade-offs — and the ordering trap. Decorators are order-sensitive, and the order is semantic:

When Decorator is overkill. If the extra behavior is a permanent, single, unconditional aspect of the operation (every payment always gets audit-logged), it can live inside the implementation — the decorator buys flexibility you do not need. Decorator earns its layers when behaviors combine, vary, or switch off (retry in production but not in tests; metrics for some channels only). Note also the modern Java shortcut (Part VII): with a functional interface, decorators become one-liners — gateway = withRetry(gateway, 3) via static factory or lambda composition.

4.6 Observer / Event-Driven: One Event, Many Interested Parties

The problem. placeOrder grows a notification list:

public void placeOrder(Order order) {
    orderRepository.save(order);

    emailService.sendOrderConfirmation(order);     // party 1
    inventoryService.reserveStock(order);          // party 2
    analyticsService.trackOrderPlaced(order);      // party 3
    notificationService.pushToCustomer(order);     // party 4
    fraudService.screenOrder(order);               // party 5 — added this sprint
}

Why it is bad. The order flow now knows about email, inventory, analytics, push, and fraud — and must change whenever a party joins or leaves. A failure in any one of them (email down?) takes down order placement — the parties’ failure modes are the flow’s failure modes. Each party’s latency is the flow’s latency. This is the Observer problem: many independent consequences of one event, hardwired into the source.

The pattern, in-process: Spring events. The publisher owns the event; interested parties subscribe; the publisher does not know them:

public record OrderCreatedEvent(String orderId, BigDecimal total, String customerId) {
}
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher events;

    public OrderService(OrderRepository orderRepository, ApplicationEventPublisher events) {
        this.orderRepository = orderRepository;
        this.events = events;
    }

    @Transactional
    public void placeOrder(Order order) {
        orderRepository.save(order);
        events.publishEvent(new OrderCreatedEvent(order.getId(), order.getTotal(), order.getCustomerId()));
    }
}
@Component
public class EmailOnOrderPlaced {
    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        emailService.sendOrderConfirmation(event.orderId());
    }
}

OrderService no longer knows email, inventory, analytics, or fraud exist. Adding a party = adding a @EventListener class; removing one = deleting it; a party failing no longer breaks order placement (unless you want transactional semantics — see below).

The architecture scale-up: from Observer to Kafka. This is where the confusion lives, and it must be said plainly:

Observer pattern and Kafka are not the same thing. Observer is an in-process programming construct; Kafka is a cross-process infrastructure. Kafka is to Observer roughly what a database is to ArrayList — a distant cousin that solves a different scale of problem.

AspectObserver (in-process events)Kafka (distributed event stream)
Process boundarySame JVM, same memoryMultiple services, multiple machines
DeliveryDirect method call (or async executor)Network, brokers, partitions
DurabilityNone — event dies with the JVMRetained on disk, replayable
GuaranteesBest-effort; failure visible to publisherAt-least-once by default; per-partition ordering
Consistency modelSynchronous — often within one DB txnAsynchronous — eventual consistency
How consumers arriveRegistry of listeners at startupConsumer groups, independent lifecycle

The conceptual relationship is real: both are “publishers emit events, subscribers react, publisher doesn’t know subscribers.” But the architectural differences are enormous:

Failure handling — the decision that matters. The first question with events is what should happen when a consumer fails?:

Synchronous, in-transaction listener (default @EventListener):
  - failure rolls back the order AND the consequence together
  - guarantee: no order without confirmation email
  - price: an email outage blocks order placement (same coupling as before,
    minus the code coupling)

Async in-process listener (@Async + @EnableAsync):
  - publisher succeeds, listener runs later on a pool thread
  - price: listener failure is invisible to the publisher; events can be
    lost on restart unless you add an outbox table

Kafka:
  - consumer reads from its own offset; failures are retried, offsets lag
  - price: duplicates (idempotency required), eventual consistency,
    observable lag, and — critically — order placement does NOT wait

The rule of thumb: use the strongest consistency you can afford, and push consequences to Kafka only when the consequence’s failure must not affect the flow — email and analytics are the classic Kafka candidates; inventory reservation and payment are usually not, because the flow must not complete without them. The email after payment is the second Kafka-era lesson: OrderCreatedEmailServiceKafka is a pattern many teams migrate from, back to synchronous — once they realize the confirmation email is a business requirement, not a side effect. (The saga-pattern articles on this blog are the deeper treatment of this exact trade-off in distributed systems.)

5. Part IV — Pattern Combinations: The Real Payment System

Real systems never use one pattern in isolation. Here is the payoff section: the complete picture of what the payment service from the introduction actually becomes when the design pressure is handled honestly — five patterns, each solving the problem it was introduced for, composed at a single point.

The full shape:

PaymentService (business flow)
      │  depends only on abstractions (DIP)

PaymentStrategy interface ────────────── Strategy (the family)

      │  chosen by type
PaymentStrategyFactory ────────────────── Factory (selection logic)

      └──► CardPaymentStrategy     ──► PaymentGateway (DIP port)
      └──► BankTransferStrategy    ──► PaymentGateway
      └──► WalletPaymentStrategy   ──► PaymentGateway

                    Decorator stack       ▼
      ┌───────────────────────────────► RetryDecorator
      │  composed once, at wiring time   LoggingDecorator
      │                                  MetricsDecorator
      │                                  ▼
      │                          StripeGatewayAdapter ──► StripeSdk (Adapter)
      │                          AdyenGatewayAdapter  ──► AdyenSdk

The code. The strategies and the gateway interface you have already seen. What remains is the composition point — in Spring, one configuration class — because that is where patterns become a system:

@Configuration
public class PaymentConfig {

    @Bean
    public PaymentGateway stripeGateway(StripeSdk stripe) {
        return new StripeGatewayAdapter(stripe);          // Adapter
    }

    @Bean
    public PaymentGateway adyenGateway(AdyenSdk adyen) {
        return new AdyenGatewayAdapter(adyen);            // Adapter
    }

    @Bean
    public PaymentGateway paymentGateway(PaymentGateway stripeGateway,
                                         PaymentGateway adyenGateway) {
        return new RetryDecorator(                        // Decorator: retry
                new LoggingDecorator(                     // Decorator: audit
                        new MetricsDecorator(             // Decorator: metrics
                                new RoutingGateway(stripeGateway, adyenGateway))),
                3, Duration.ofMillis(200));
    }

    @Bean
    public PaymentStrategyFactory paymentStrategyFactory(
            PaymentGateway gateway, CardGateway card, BankGateway bank, WalletGateway wallet) {
        Map<PaymentType, PaymentStrategy> strategies = new EnumMap<>(PaymentType.class);
        strategies.put(PaymentType.CARD, new CardPaymentStrategy(gateway, card));
        strategies.put(PaymentType.BANK_TRANSFER, new BankTransferStrategy(gateway, bank));
        strategies.put(PaymentType.E_WALLET, new WalletStrategy(gateway, wallet));
        return new PaymentStrategyFactory(strategies);    // Factory + Strategy
    }

    @Bean
    public PaymentService paymentService(PaymentStrategyFactory factory) {
        return new PaymentService(factory);               // DI everywhere
    }
}

Why each piece exists — and who pays for what:

PieceProblem it solvesPrice
PaymentStrategy + factoryAdding a payment type must not edit the flowIndirection + registry
PaymentGateway interfaceThe flow must not depend on providers (DIP)One abstraction
StripeGatewayAdapter/AdyenSDK types must not leak into business logicTranslation layer per vendor
Retry/Logging/MetricsDecoratorCross-cutting behavior without editing coreWrapping-order decisions
@Bean wiring (composition root)The graph is assembled in exactly one placeConfiguration lives centrally

How you should read this code. The PaymentService — the business flow — is ~15 lines and depends on nothing concrete. The complexity lives in the composition root, in one file, where it is visible. That is the entire argument for doing this: not fewer lines, but fewer places where change hurts.

What happens when developers overuse patterns in the same system. Here is the failure mode to recognize — the pattern-driven version of the same service:

// a real (compressed) example of pattern excess
public class PaymentProcessingCoordinatorService {
    private final PaymentStrategySelectorFactory providerFactory;
    private final PaymentGatewayRouterFacade gatewayRouter;
    private final PaymentRequestBuilderFactory requestBuilderFactory;
    private final PaymentAuditDecoratorFactory auditDecoratorFactory;
    ...
}

Twenty classes, each with a factory, each delegating to the next. A developer tracing “how does a card payment work” must navigate: service → selector → factory → strategy → router → facade → decorator → adapter → SDK, six indirections deep, before seeing a single line of business logic. Every class has a unit test that asserts “it calls the next class.” The architecture has become its own debugging tax.

The difference between the good version and the bad version is not the number of patterns — both use the same five. It is:

  1. Each abstraction is a boundary — it separates code that changes for different reasons. In the bad version, the boundaries are between code that changes for the same reason (they are decorators and routers of the same flow, split for the sake of names).
  2. The composition is visible and central. In the bad version, wiring is scattered (factories of factories); in the good version, one config class shows the whole system.
  3. The business flow is short. If your orchestration class still needs five layers to do its job, the patterns were added around it, not for it.

The check that keeps pattern usage honest:

For every layer in your system, ask: does this layer make some change cheaper than it would otherwise be? If the layer only adds a name, delete the name.


6. Part V — When SOLID and Design Patterns Make Your Code Worse

SOLID and the patterns are tools, and like all tools, they can be applied to the wrong material. This part is the mirror image of everything above: seven real failure modes, each with the recognition pattern and the correction. Read this part as the checklist you apply before applying anything from Parts II and III.

Case 1 — Interface for every class

public interface UserService {
    void register(RegisterRequest request);
    void changePassword(String userId, String oldPassword, String newPassword);
}

public class UserServiceImpl implements UserService {
    // the ONLY implementation, and the interface adds nothing beyond
    // what the class already expresses
}

When this adds little value: one implementation, no second one on the horizon, interface methods identical to the class’s — and the only client is Spring, which does not need the interface. This is the naming ceremony from Section 3.5. The correction: delete the interface; @Service on the concrete class; add the interface when a second implementation exists or a real contract must be stated.

Case 2 — Pattern-driven development

A developer who learned Strategy, Factory, Builder, Adapter, Facade this week, and a small problem arrives:

// the problem: send an email to the customer after checkout
public class EmailNotificationFactory {
    public NotificationSender create() { ... }
}

public class EmailNotificationStrategy implements NotificationSender {
    // one implementation, one strategy, no family
}

public class NotificationBuilder {
    public EmailNotificationBuilder withRecipient(...) { ... }
    public NotificationBuilder withMessage(...) { ... }
}

public class NotificationFacade {
    // forwards to the strategy, which wraps the builder, which wraps...
}

Why it is wrong: the patterns were chosen because they are patterns — “we use Strategy because Strategy is a good pattern” — not because a family of algorithms exists. The single email send is buried under five layers that each add nothing. Correction: notificationService.send(new Email(...)) — one class, one call. If a second channel arrives later, then the Strategy interface earns its existence.

Case 3 — Over-abstraction of a single implementation

OrderRepository          // interface
OrderRepositoryImpl      // the only implementation
OrderRepositoryFactory   // returns OrderRepositoryImpl
OrderRepositoryProvider  // provides the factory
OrderRepositoryManager   // manages the provider
OrderRepositoryResolver  // resolves the manager

When this happens: teams that believe “abstraction = architecture.” Every level is a synonym for the previous one. Correction: stop at the first level. One interface (or none, see Case 1) is the boundary; the remaining five names are navigation tax. The naming cascade is a conversation you should be able to have out loud: “which one does the service depend on?” — if the answer requires a flowchart, the design failed, not the reader.

Case 4 — SRP fragmentation

public class OrderValidationService {
    private final OrderItemsValidator itemsValidator;
    private final CustomerValidator customerValidator;
    private final PaymentMethodValidator paymentMethodValidator;
    private final ShippingAddressValidator shippingAddressValidator;
    private final OrderValidationAudit audit;

    public void validate(Order order) {
        itemsValidator.validate(order);
        customerValidator.validate(order);
        ...
    }
}

The original OrderValidator.validate(order) was 20 lines — readable in one screen, changeable in one place. Now a change to validation requires editing five classes, and the order of validations is implicit in the calling sequence. The correction test: split when the parts change at different rates or for different actors (Section 3.1); do not split when you are only reorganizing lines. A 20-line method is not a design problem; the class that owns it is not a god class.

Case 5 — OCP obsession

public interface PriceProvider {
    BigDecimal getPrice(Order order);
}

public interface PriceProviderFactory {
    PriceProvider create(OrderType type);
}

public class StandardPriceProvider implements PriceProvider { ... }
public class PremiumPriceProvider implements PriceProvider { ... }

…for a pricing rule that has not changed in three years and has two branches. Every rule in the system was interface-ified “so we never modify existing code again.” The result: every small rule change now touches an interface, a factory, and a registration — the indirection is more fragile than the original if. The correction: OCP protects a real variation point; the interface exists to absorb a known family of changes, not every conceivable one. Constant, low-volatility logic can be a plain if — and “we might need this later” is not a variation point, it is a guess.

Case 6 — Composition everywhere

Composition is powerful — this article has argued for it repeatedly. But “favor composition” does not mean “never inherit.” A legitimate inheritance case:

// all subclasses DO satisfy the parent's contract, are sealed, and are
// pure extensions — this hierarchy has been stable for years
public sealed class DomainException extends RuntimeException
        permits OrderNotFoundException, PaymentDeclinedException, InventoryUnavailableException {
    private final String code;

    public DomainException(String message, String code) {
        super(message);
        this.code = code;
    }

    public String code() {
        return code;
    }
}

Exceptions are one of the few places where inheritance is the right mechanism: every catch site that handles DomainException genuinely handles all three subtypes, the hierarchy adds no new contract clauses, and sealing prevents surprise subclasses. Forcing composition here (“each exception has a cause object”…) would make the code worse, not better. The correction: the decision is about substitutability (Section 2.3), not fashion. If every caller of the parent can accept the subclass unchanged — inheritance is a valid choice.

Case 7 — Design pattern as cargo cult

The wrong reasoning, stated plainly:

“We use Strategy because Strategy is a good pattern.”

The right reasoning:

“We have a family of interchangeable algorithms and expect new variants — Strategy gives us a place for each variant and keeps the dispatcher closed.”

The difference is not cosmetic — the wrong reasoning starts from the pattern and looks for a problem; the right reasoning starts from the problem and checks whether the pattern addresses it. Every pattern in this article was introduced problem-first for exactly this reason. When you see a pattern in a codebase, the review question is never “is this a correct Strategy?” — it is “what problem is this solving, and is the price proportional?”

The one-sentence summary of this whole part: patterns and principles are solutions in search of problems; the engineer’s job is to keep them honest by demanding the problem first.

7. Part VI — The Refactoring Case Study: OrderService, Step by Step

Everything so far, applied to one class in front of you. This is the most important part of the article, so we go slowly: intentionally bad code, then seven small steps — each one justified, each one priced. We do not jump from bad to perfect in one leap, because real refactoring never does. At every step, four questions are answered:

What problem are we solving?
Why is this abstraction justified?
What complexity did we introduce?
Was the change worth it?

The starting point. The OrderService you saw in Section 3.1, with the payment logic inlined to make the journey complete:

public class OrderService {
    public void processOrder(Order order) {
        // 1. validation
        if (order.getItems().isEmpty()) {
            throw new IllegalArgumentException("order has no items");
        }
        if (order.getCustomer() == null || order.getCustomer().getEmail() == null) {
            throw new IllegalArgumentException("customer email is required");
        }

        // 2. pricing
        BigDecimal total = BigDecimal.ZERO;
        for (OrderItem item : order.getItems()) {
            total = total.add(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())));
        }
        if (total.compareTo(BigDecimal.valueOf(500)) > 0) {
            total = total.multiply(BigDecimal.valueOf(0.9));
        }
        order.setTotal(total);

        // 3. persistence
        // ... JDBC code: INSERT INTO orders ...

        // 4. payment
        if (order.getPayment().getType().equals("CARD")) {
            // ... 40 lines of card SDK calls ...
        } else if (order.getPayment().getType().equals("BANK_TRANSFER")) {
            // ... 40 lines of bank SDK calls ...
        } else {
            throw new UnsupportedOperationException("unknown payment type");
        }

        // 5. email
        // ... SMTP code: build and send confirmation ...

        // 6. logging
        System.out.println("Order processed: " + order.getId());
    }
}

About 90 lines, six responsibilities, four external systems hardwired, and the SDK types from step 4 are leaking into every line around them. Now we walk the seven steps. After each step you get the verdict — most steps are worth it, one of them is a judgment call, and the final step is the one you could honestly skip.

Step 1 — Identify the responsibilities

What we did: nothing but read the class and write down the change drivers.

#ResponsibilityReason it changesChanges at its own rate?
1ValidationBusiness rules for who may orderYes
2PricingDiscounts, tiers, taxesYes
3PersistenceSchema, vendor, query tuningYes
4PaymentProviders, payment types, retriesYes — fastest
5EmailTemplates, providersYes
6LoggingObservability requirementsYes

Six responsibilities, six change drivers — one class. That is the diagnosis (SRP, Section 3.1), and the cost-benefit is already visible: every one of the six changes demands a modification to the same 90 lines.

Worth it? This step costs nothing and tells you exactly where to cut.

Step 2 — Apply SRP: one class per responsibility

The problem we solve: the six drivers must stop sharing one file.

public class OrderValidator {
    public void validate(Order order) {
        if (order.getItems().isEmpty()) {
            throw new IllegalArgumentException("order has no items");
        }
        if (order.getCustomer() == null || order.getCustomer().getEmail() == null) {
            throw new IllegalArgumentException("customer email is required");
        }
    }
}

public class OrderPricing {
    public BigDecimal calculateTotal(Order order) {
        BigDecimal total = BigDecimal.ZERO;
        for (OrderItem item : order.getItems()) {
            total = total.add(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())));
        }
        if (total.compareTo(BigDecimal.valueOf(500)) > 0) {
            total = total.multiply(BigDecimal.valueOf(0.9));
        }
        return total;
    }
}

public class OrderService {
    private final OrderValidator validator;
    private final OrderPricing pricing;
    private final OrderRepository repository;
    private final PaymentService paymentService;
    private final NotificationService notificationService;

    public OrderService(OrderValidator validator, OrderPricing pricing,
                        OrderRepository repository, PaymentService paymentService,
                        NotificationService notificationService) {
        this.validator = validator;
        this.pricing = pricing;
        this.repository = repository;
        this.paymentService = paymentService;
        this.notificationService = notificationService;
    }

    public void processOrder(Order order) {
        validator.validate(order);
        order.setTotal(pricing.calculateTotal(order));
        repository.save(order);
        paymentService.charge(order);
        notificationService.sendConfirmation(order);
    }
}

What we introduced: five new classes, five constructor parameters, and the flow is now a sequence of calls. What we bought: each responsibility is independently testable; a pricing change touches only OrderPricing; an email outage no longer lives in the order flow’s class.

Worth it? Yes — and it was cheap. This step alone would already have stopped the opening story’s PaymentService from reaching 1,500 lines.

Step 3 — Introduce abstractions where variation exists

The problem we solve: step 2 still leaves the payment variation buried — PaymentService.charge() is a switchboard. Look at what varies: payment types, providers, retry behavior. That is a family, and the abstraction belongs where the family is.

public interface PaymentGateway {
    PaymentResult pay(PaymentRequest request);
}

Why this abstraction is justified: a real, observed variation exists (CARD / BANK_TRANSFER today; the business has already announced crypto for next quarter). The abstraction sits at the boundary between the order flow (which should not know payment mechanics) and the providers (which should not be dictated by the order flow).

What we introduced: an interface and domain types (PaymentRequest, PaymentResult) that replace SDK vocabulary in the flow.

Worth it? Yes — but note what did NOT happen: we did not create interfaces for OrderValidator or OrderPricing. They have no variation. The abstraction was introduced only where variation is real — that is the judgment call working (OCP, Section 3.2).

Step 4 — Apply Strategy: the family gets a place to live

The problem we solve: “add a payment type” must stop being “edit the switchboard.”

public interface PaymentStrategy {
    PaymentResult pay(Payment payment);
}

public class CardPaymentStrategy implements PaymentStrategy {
    private final PaymentGateway gateway;
    private final CardGateway cardGateway;

    public CardPaymentStrategy(PaymentGateway gateway, CardGateway cardGateway) {
        this.gateway = gateway;
        this.cardGateway = cardGateway;
    }

    @Override
    public PaymentResult pay(Payment payment) {
        return gateway.pay(PaymentRequest.from(payment, cardGateway));
    }
}

…plus BankTransferPaymentStrategy, plus a factory that owns the Map<PaymentType, PaymentStrategy> (Section 4.1). The payment flow is now:

PaymentStrategy strategy = strategyFactory.forType(order.getPayment().getType());
strategy.pay(order.getPayment());

What we introduced: one interface, N implementations, one registry — and the dispatch decision moved out of business flow into data (EnumMap). Was it worth it? Yes, measured against the actual business roadmap (a new type per quarter). If the roadmap had said “never,” step 4 would be the step to skip — a two-branch if in PaymentService would be simpler (Section 2.4’s special case).

Step 5 — Apply Dependency Inversion: point the arrows

The problem we solve: the classes now depend on each other (concrete types), not on contracts. Testability and replacement are coupled to construction.

Before — the flow depends on concrete classes, and swapping storage means editing the flow:

private final OrderRepository repository;      // concrete — see step 6
private final PaymentService paymentService;   // concrete

After — everything the flow needs is behind an interface it owns:

public interface OrderRepository {
    void save(Order order);
    Order findById(String id);
}

public interface PaymentService {               // the policy's requirement
    void charge(Order order);
}
public class OrderService {
    private final OrderValidator validator;
    private final OrderPricing pricing;
    private final OrderRepository repository;    // now an interface
    private final PaymentService paymentService; // now an interface
    private final NotificationService notificationService;
    ...
}

Construction happens in the composition root (a Spring @Configuration class or main()):

OrderService service = new OrderService(
        new OrderValidator(),
        new OrderPricing(),
        new MySqlOrderRepository(dataSource),    // detail, chosen at the root
        new PaymentService(paymentStrategyFactory),
        new SmtpNotificationService());

Why this abstraction is justified: the storage and payment details change at their own rates; the policy must not change when they do. What we introduced: two interfaces and a composition root — but note the absence: no interfaces for OrderValidator and OrderPricing, again. Worth it? Yes — this is what makes the whole system testable with in-memory fakes and swappable in production.

Step 6 — Introduce the Adapter for the external payment provider

The problem we solve: the SDK types are still leaking. Somewhere inside the strategies, the code calls stripe.charge(...) and catches StripeDeclinedException — in the business flow. One provider’s vocabulary is in the flow; the second provider is coming, and each will try to bring its own vocabulary.

Before (inside a strategy):

public class CardPaymentStrategy implements PaymentStrategy {
    private final StripeSdk stripe;

    @Override
    public PaymentResult pay(Payment payment) {
        try {
            StripeCharge charge = stripe.charge(payment.getToken(), toCents(...), "USD");
            return PaymentResult.success(charge.getId());
        } catch (StripeDeclinedException e) {
            return PaymentResult.failure(e.getMessage());
        }
    }
}

After — the SDK call moves behind the adapter (Section 4.4), and the strategy talks to the domain boundary:

public class CardPaymentStrategy implements PaymentStrategy {
    private final PaymentGateway gateway;

    @Override
    public PaymentResult pay(Payment payment) {
        return gateway.pay(PaymentRequest.from(payment));
    }
}

Why this abstraction is justified: the provider boundary is the single most volatile place in the system — new vendors, SDK upgrades, exception vocabularies. The adapter absorbs all of it. What we introduced: one adapter per provider, with a small translation ceremony. Worth it? Yes — this is the cheapest insurance in the whole article: when Stripe releases SDK v2, exactly one class changes.

Step 7 — Add the Decorator for retry, logging, metrics

The problem we solve: retry/audit/metrics are cross-cutting — every payment, every provider, and they must combine. Hardwiring them into strategies or the adapter duplicates them per provider (Section 4.5).

PaymentGateway gateway = new RetryDecorator(
        new LoggingDecorator(
                new MetricsDecorator(
                        new StripeGatewayAdapter(stripe))),
        3, Duration.ofMillis(200));

Why this abstraction is justified: the behaviors combine with the providers multiplicatively — decorators keep that N + M instead of N × M. What we introduced: three small classes and a wrapping order that must be deliberate. Worth it? Yes when behaviors vary or are configurable; this is also the step to skip when retry is a simple, unconditional loop inside the adapter — the decorator’s flexibility would be unused (the “when overkill” case in Section 4.5).

The final state — and the honest accounting

public class OrderService {
    // 5 injected collaborators, all interfaces except the validators
    // processOrder: 6 lines — validation, pricing, save, pay, notify, log

    public void processOrder(Order order) {
        validator.validate(order);
        order.setTotal(pricing.calculateTotal(order));
        repository.save(order);
        paymentService.charge(order);
        notificationService.sendConfirmation(order);
        logger.info("Order {} processed", order.getId());
    }
}
StepComplexity addedRisk removedVerdict
1nonenone (diagnosis)always
25 classes + 5 ctor paramsSix change drivers stop sharing a filecheap win
31 interface + domain typesSDK vocabulary out of the flowcheap win
4strategy interface + N classes + mapNew payment types no longer edit the flowwin only if types grow
52 interfaces + composition rootPolicy decoupled from storage/payment detailswin
61 adapter per providerProvider swaps/SDK upgrades isolatedwin
73 decorator classes + wrapping orderRetry/audit/metrics composable per providerwin only if behaviors vary

The original 90-line class became ~25 lines of orchestration plus well-placed collaborators. The class did not get smaller — the system did. That is the only metric that matters: the number of places where a given change can hurt.

And what we deliberately did NOT do — the discipline is as important as the pattern:

8. Part VII — Java-Specific Considerations: What Modern Java Changes

Every principle and pattern in this article was designed for a Java that did not have records, sealed classes, lambdas, or pattern matching. Modern Java does not invalidate the principles — but it relocates the boilerplate and, in several places, makes a pattern unnecessary. This part goes through the features that matter and what each one does to the decision-making above.

8.1 Records: The Value Object With No Boilerplate

The immutable domain types that Parts II–III built by hand (PaymentRequest, PaymentResult, OrderCreatedEvent, the User from the Builder section) are exactly what records were created for:

public record PaymentRequest(
        String orderId,
        BigDecimal amount,
        String currency,
        PaymentMethod method) {

    public PaymentRequest {                          // compact constructor
        if (amount == null || amount.signum() <= 0) {
            throw new IllegalArgumentException("amount must be positive");
        }
        if (currency == null || currency.length() != 3) {
            throw new IllegalArgumentException("invalid currency: " + currency);
        }
    }
}

One declaration gives: immutable fields, canonical constructor, equals/ hashCode/toString, and the validation that BankAccount’s constructor and the Builder’s build() performed by hand. The encapsulation lesson (Section 2.1) still applies — records are only as good as their invariant checks — but the ceremony is gone.

Records vs Builder — the head-to-head the intro promised:

ConcernBuilder (Section 4.3)record + static factory
Boilerplate~10 lines per field1 line per field
ReadabilityFluent, labeled stepsnew with positional args
ValidationIn build()In compact constructor
Optional fieldsOmitting a step, naturallyEvery field required (by design)
Best for8+ fields with mixed optionality≤ 6 fields, all mandatory
Custom variantsStatic factory: User.createCustomer(...)

The rule: a record is now the default for value objects; the Builder remains for the genuinely large, partly-optional construction — and even then, a record can be the built product. The Builder’s remaining virtue is argument labeling; if records and static factories cover your cases, the Builder is the pattern modern Java made optional.

8.2 Sealed Classes: Making the Family Explicit

The recurring theme of this article — the variation point is real, the family is known — is precisely what sealed formalizes:

public sealed interface PaymentMethod permits Card, BankTransfer, Wallet {
}

public record Card(String token, String brand) implements PaymentMethod { }
public record BankTransfer(String iban, String accountName) implements PaymentMethod { }
public record Wallet(String walletId) implements PaymentMethod { }

The compiler now enforces the family: no unknown subclass can exist, and a switch over PaymentMethod can be exhaustive — the compiler proves you handled every variant:

public PaymentResult pay(PaymentMethod method, PaymentGateway gateway) {
    return switch (method) {
        case Card card -> gateway.pay(cardPaymentRequest(card));
        case BankTransfer bank -> gateway.pay(bankPaymentRequest(bank));
        case Wallet wallet -> gateway.pay(walletPaymentRequest(wallet));
    };
}

What sealed changes for this article:

8.3 Enums: Strategy in Disguise

For behavior that varies by a fixed, known set, the enum is the classic Java trick that predates all of this:

public enum ShippingMethod {
    STANDARD {
        @Override
        public Duration estimateDelivery() {
            return Duration.ofDays(5);
        }
    },
    EXPRESS {
        @Override
        public Duration estimateDelivery() {
            return Duration.ofDays(1);
        }
    };

    public abstract Duration estimateDelivery();
}

…or, cleaner in modern Java, an interface field:

public enum ShippingMethod {
    STANDARD(shipping -> shipping.getWeight().multiply(BigDecimal.valueOf(2))),
    EXPRESS(shipping -> shipping.getWeight().multiply(BigDecimal.valueOf(8)));

    private final Function<Shipping, BigDecimal> price;
    ShippingMethod(Function<Shipping, BigDecimal> price) {
        this.price = price;
    }

    public BigDecimal priceFor(Shipping shipping) {
        return price.apply(shipping);
    }
}

Enum-based behavior is the simplest Strategy: no classes, no registry, dispatch by the enum constant itself. It is the right call when the family is closed (sealed by nature: shipping methods, order statuses, report formats) and the behavior is small. It is the wrong call when variants carry their own dependencies (a strategy needing a gateway, a repository) — an enum cannot be constructed per-dependency.

8.4 Lambdas and Functional Interfaces: Strategy Without Classes

The single-method interfaces from Parts II–IV are all functional interfaces. A PaymentStrategy can be a lambda; decorators can be composed functions. The classic comparison:

// the Strategy pattern, classically
public class CardPaymentStrategy implements PaymentStrategy { ... }
strategies.put(CARD, new CardPaymentStrategy(cardGateway));

// the same strategy as a lambda
strategies.put(CARD, payment -> cardGateway.charge(payment.getReference(), payment.getAmount()));

And the decorator as function composition:

static PaymentGateway withRetry(PaymentGateway delegate, int attempts, Duration backoff) {
    return request -> {
        PaymentResult result = delegate.pay(request);
        for (int i = 1; !result.isSuccess() && i < attempts; i++) {
            sleepQuietly(backoff.multipliedBy(i));
            result = delegate.pay(request);
        }
        return result;
    };
}

private static void sleepQuietly(Duration duration) {
    try {
        Thread.sleep(duration);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

PaymentGateway gateway = withRetry(stripeAdapter, 3, Duration.ofMillis(200));

What lambdas change: the family still exists (the problem is real), but the classes often do not need to. When the variant is stateless or small, a lambda is the whole Strategy; when it accumulates state and dependencies, upgrade to a class. The caution: lambdas make over-abstraction cheaper to write, not more justified — a two-branch if is still better than a two-entry lambda map (Section 2.4’s special case applies unchanged).

8.5 Pattern Matching: The if/else Replacement That Is Not Polymorphism

// before: instanceof chain
if (payment instanceof CardPayment cardPayment) {
    ...
} else if (payment instanceof BankTransferPayment bankPayment) {
    ...
}

// after: exhaustive sealed switch (Section 8.2)

Pattern matching does not replace polymorphism — it replaces the instanceof/cast idiom, and it changes where the dispatch lives. Use the sealed switch when the family is closed and the handling is local; use polymorphism when each variant must own its behavior and be extended independently. The distinction is the same one from Section 2.4: who owns the behavior — the caller or the variant?

8.6 Optional: Honest Absence

Optional<T> is the modern answer to the LSP problem from Section 3.3 — the repository that “returns null sometimes”:

public interface OrderRepository {
    Optional<Order> findById(String id);   // the contract says absence is possible
}

Used at the boundary, Optional makes the contract honest, so implementations cannot silently weaken it. The special cases matter:

8.7 Immutability: The Language Finally Helps

List.copyOf, Set.copyOf, Map.copyOf, Stream.toList(), unmodifiable collection views, String/BigDecimal/LocalDate already immutable, records for value objects — modern Java makes the immutable default cheap. This directly reinforces Section 2.1: the defensive copies are often still needed (constructors taking mutable collections), but the mutability surface you must defend has shrunk dramatically.

8.8 What modern Java does NOT change

Nothing above removes the judgment: which variation point is real, which abstraction earns its indirection, whether the family is closed or open. Records do not make Builder-thinking obsolete for large objects; sealed does not make Strategy obsolete for heavy variants; lambdas do not make the “when NOT to” cases from Part V disappear. The tools got sharper — the engineering judgment is still yours.


9. Part VIII — Mental Models: Questions to Ask in Code Review

An article cannot hand you judgment; it can hand you questions. Here are the questions that force the judgment — the ones to run through when a class, a design, or a pull request needs evaluation. They map one-to-one onto the sections above; each question has a “signal” — the answer that means a problem.

OOP

QuestionSignal of trouble
Who owns this state?Several classes mutate the same object
Who is allowed to modify it?Fields or internal collections escape
What invariant must be protected?No method enforces it — callers must remember
Is this really an “is-a” relationship?instanceof checks in callers; extends without substitutability
What does this abstraction hide?SDK types, provider names, or config in callers
Does this abstraction hide a real variation?One implementation, no second in sight

SOLID

QuestionSignal of trouble
What is likely to change here?The class changes for many different reasons
What dependency is causing the coupling?A change in X forces changes in Y
Are we adding this interface because we need it?The interface mirrors the class; no second variant
Are we splitting responsibilities or just splitting files?10 classes, each forwarding to the next
Could a subclass weaken the parent’s contract?Subclasses throw UnsupportedOperationException, return null, strengthen preconditions
Who does this interface serve?Clients depend on methods they never call
Which direction do the dependencies point?Policy classes reference vendor/SDK classes
Is Spring doing DI or is the code doing DIP?Interfaces exist only because Spring “requires” them (it does not)

Design Patterns

QuestionSignal of trouble
What problem does this pattern solve?No problem stated before the pattern
What complexity does it introduce?Layers that only add names
What happens if we don’t use it?”We’ll add it later” is easy → don’t build it now
Is the variation real or hypothetical?”We might need…” with no roadmap
Where is the composition/wiring visible?Factories-of-factories, wiring scattered
Would a switch or an if be simpler?One-line branches behind interfaces

How to use these: code review is not pattern-spotting (“that’s a Strategy, approved!”). It is question-asking with evidence. When a review comment says “why not use X pattern here?”, the honest reply is either a problem statement — or the deletion of the pattern.


10. Part IX — The Final Decision Framework

Put it all together. When you face a design decision in real code, the flow is a tree — and the branches are about what is changing, not about pattern names:

Do I have a design problem? (symptoms: Section 1)
        |
        +-- No  → stop. Don't decorate working code.
        |
        v
What is changing?
        |
        +--> Behavior/algorithm varies by type
        |        → Strategy / polymorphism / sealed switch
        |
        +--> New variants keep appearing
        |        → Strategy + registry (OCP)
        |
        +--> Object creation involves decisions
        |        → Simple Factory
        |        → Factory Method (flow fixed, object varies)
        |        → Abstract Factory (families must stay consistent)
        |
        +--> Constructing an object is unreadable
        |        → Builder (many optional fields)
        |        → record + static factory (most value objects)
        |
        +--> External API does not match my model
        |        → Adapter (keep SDK types out of the domain)
        |
        +--> Need behavior added without touching core
        |        → Decorator (N + M, not N × M)
        |
        +--> One event, many consequences
        |        → Spring events (in-process, sync/async)
        |        → Kafka (cross-process, async, eventual)
        |
        +--> High-level code depends on implementation
        |        → Dependency Inversion (own the abstraction)
        |        → constructor injection + composition root
        |
        +--> Multiple responsibilities, multiple change drivers
                 → SRP (split by rate/actor of change — not by lines)

After choosing, ask three times:
  1. What complexity did I just add?
  2. What happens if I don't add it?
  3. Is the variation real, or hypothetical?
If answer 3 is "hypothetical" — remove it.

And the sentence that should survive every design discussion:

Patterns are tools, not goals. A Strategy that solves no variation, a Factory that only wraps new, an interface with one implementation, an SRP split that fragments without isolating — these are the cargo cults of Section 6, and they outnumber the genuine designs in most codebases. The goal is never “use the right pattern.” The goal is “make the change that is coming cheap, and everything else simple.”


Conclusion

You started with a PaymentService that grew to 1,500 lines — six responsibilities, hardwired dependencies, and SDK vocabulary leaking into the business flow. Then you followed the whole arc: what encapsulation actually protects (invariants, not fields), what abstraction should and should not hide (real variation, not structure), when inheritance is a trap (whenever substitutability fails), and when polymorphism is overkill (when the branches are rules, not algorithms). The SOLID principles were each priced, not praised: one reason to change, protect the variation point, honor the behavioral contract, size interfaces for clients, and point dependencies at abstractions you own. The patterns were introduced as problem solutions with stated costs — Strategy, Factory, Builder, Adapter, Decorator, Observer — and the combinations section showed what a real payment system looks like when each abstraction is a genuine boundary. Then the mirror: seven ways SOLID and patterns make code worse, and the seven-step refactoring where each step had to justify itself. Finally, modern Java’s features — records, sealed classes, enums, lambdas, pattern matching, Optional — relocated the boilerplate but not the judgment.

The whole article reduces to four sentences:

  1. Every design decision is a bet on what will change.
  2. Abstractions are boundaries between things that change at different rates — nothing else justifies them.
  3. A principle or pattern is a solution to a problem; if you cannot state the problem, the solution is cargo cult.
  4. The skill is not knowing the patterns — it is looking at a real codebase, identifying the design pressure, choosing the smallest technique that relieves it, and refusing the ones that would add complexity without removing risk.

That is the difference between knowing what SOLID is and being able to use it. Next time a PaymentService lands in your review, don’t ask “which pattern should this use?” — ask what is changing, and who should pay for it.



Share this post on:

Next Post
Java Interview Prep #8: Senior Mindset & Behavioral — Junior to Senior