Design patterns are not arbitrary recipes: each one exists to enforce one or more design principles. Principles are the why; patterns, the how. If you study patterns without principles, you will apply them mechanically and in the wrong places; if you master the principles, you will understand each pattern at first sight and know how to judge when it is worth it. In this lesson we will study the five SOLID principles with violation-and-fix examples on PideYa, and complement them with other essential foundations: DRY, KISS, YAGNI and the Gang of Four's two maxims. It is probably the most important lesson of the module.

Contents

  1. What a design principle is and why it comes before patterns
  2. S — Single Responsibility Principle (SRP)
  3. O — Open/Closed Principle (OCP)
  4. L — Liskov Substitution Principle (LSP)
  5. I — Interface Segregation Principle (ISP)
  6. D — Dependency Inversion Principle (DIP)
  7. Other foundations: DRY, KISS and YAGNI
  8. The GoF's two maxims
  9. From principles to patterns

What a design principle is and why it comes before patterns

A design principle is a general guideline on how to organize code so it is easy to understand, change and test. Unlike a pattern, a principle does not propose a concrete class structure: it proposes a quality criterion. The five most cited form the acronym SOLID, popularized by Robert C. Martin ("Uncle Bob"):

Letter Principle One-sentence idea
S Single Responsibility A class should have a single reason to change
O Open/Closed Open for extension, closed for modification
L Liskov Substitution Subclasses must be usable wherever the superclass is expected
I Interface Segregation Many small interfaces beat one large one
D Dependency Inversion Depend on abstractions, not on concrete implementations

For each principle we will follow the same method: statement, violation in PideYa (real Java code and its problem) and fix.

S — Single Responsibility Principle (SRP)

Statement: a class should have a single responsibility; more precisely, a single reason to change. If two different requirements (from two different "clients": business, accounting, marketing...) force you to touch the same class, that class does too much.

Violation in PideYa

public class Order {
    private List<OrderLine> lines;
    private Customer customer;

    public double calculateTotal() {
        return lines.stream()
                    .mapToDouble(l -> l.getPrice() * l.getQuantity())
                    .sum();
    }

    // An order's responsibility? Generating its own PDF invoice...
    public byte[] generateInvoicePdf() {
        // ...layout logic, fonts, logo...
        return new byte[0];
    }

    // ...and saving itself to the database too?
    public void saveToDatabase() {
        // ...SQL, connections, transactions...
    }
}

This class has three reasons to change: it changes if the business logic changes (new discounts), if the invoice format changes (new logo, legal requirement), and if persistence changes (migrating from MySQL to PostgreSQL). Three different teams touching the same file: conflicts, risk and tests that are impossible to isolate.

Fix

public class Order {                  // Only the order's business logic
    private List<OrderLine> lines;
    public double calculateTotal() { /* ... */ return 0; }
}

public class InvoiceGenerator {       // Only invoice presentation
    public byte[] generatePdf(Order order) { /* ... */ return new byte[0]; }
}

public class OrderRepository {        // Only persistence
    public void save(Order order) { /* ... */ }
}

Each class now has one responsibility and one reason to change. Note that the SRP does not say "small classes": it says cohesive ones. A 300-line class with a single responsibility satisfies the SRP; a 30-line class with two does not.

O — Open/Closed Principle (OCP)

Statement: software entities should be open for extension (able to gain new behavior) but closed for modification (without editing existing code that already works). The tool for achieving this is abstraction: extension points via interfaces.

Violation in PideYa

PideYa applies discounts depending on the type of promotion:

public class DiscountCalculator {
    public double calculate(Order order, String promotionType) {
        if (promotionType.equals("FIRST_ORDER")) {
            return order.calculateTotal() * 0.10;
        } else if (promotionType.equals("FREE_DELIVERY")) {
            return order.getDeliveryFee();
        } else if (promotionType.equals("COUPON_5_EUR")) {
            return 5.0;
        }
        return 0;
    }
}

Every new promotion from marketing forces us to modify this class: another else if, another chance to break the promotions that already worked, another risky deployment. The if/else if chain over a "type" is the classic symptom of an OCP violation.

Fix

public interface Promotion {
    double calculateDiscount(Order order);
}

public class FirstOrderPromotion implements Promotion {
    public double calculateDiscount(Order order) {
        return order.calculateTotal() * 0.10;
    }
}

public class FreeDeliveryPromotion implements Promotion {
    public double calculateDiscount(Order order) {
        return order.getDeliveryFee();
    }
}

public class DiscountCalculator {
    public double calculate(Order order, Promotion promotion) {
        return promotion.calculateDiscount(order);  // Closed: never changes
    }
}

Adding the "2-for-1 on pizzas" promotion now means adding a new class, without touching the calculator or the existing promotions. Tested code stays intact. (If this smells like a pattern with a proper name, you smell right: several module 4 patterns are exactly this move; let's not get ahead of ourselves.)

L — Liskov Substitution Principle (LSP)

Statement (Barbara Liskov, 1987): if S is a subtype of T, objects of S must be usable anywhere a T is expected without the program ceasing to be correct. In practice: a subclass may not tighten the input conditions, weaken the output guarantees, or throw surprises the superclass did not announce.

Violation in PideYa

PideYa models payment methods, and someone thought it natural that cash on delivery "is a" payment method:

public class PaymentMethod {
    /** Charges the amount and returns a transaction identifier. */
    public String charge(double amount) {
        // ...online charge...
        return "TX-123";
    }
}

public class CashOnDeliveryPayment extends PaymentMethod {
    @Override
    public String charge(double amount) {
        // Cannot be charged online: the courier is paid at the door
        throw new UnsupportedOperationException("Not applicable");
    }
}

Client code trusts the PaymentMethod contract:

public void confirmOrder(Order order, PaymentMethod payment) {
    String tx = payment.charge(order.calculateTotal()); // Blows up with cash on delivery!
    order.markAsPaid(tx);
}

CashOnDeliveryPayment is a PaymentMethod according to the compiler, but not according to the contract: substituting it breaks the program. Syntactically valid inheritance, semantically fraudulent.

Fix

The solution is to redesign the abstraction so the contract can be honored by all subtypes:

public interface PaymentMethod {
    /** Returns the outcome of the attempt: charged now or pending on delivery. */
    PaymentResult process(double amount);
}

public class CardPayment implements PaymentMethod {
    public PaymentResult process(double amount) {
        return PaymentResult.charged("TX-123");
    }
}

public class CashOnDeliveryPayment implements PaymentMethod {
    public PaymentResult process(double amount) {
        return PaymentResult.pendingOnDelivery(amount);
    }
}

Now every implementer honors the same contract ("process returns a result, never throws by design") and the client can handle the result uniformly. Rule of thumb: if, when inheriting, you need to override a method with an exception or leave it empty, the hierarchy is badly conceived.

I — Interface Segregation Principle (ISP)

Statement: no client should be forced to depend on methods it does not use. Several small, specific interfaces beat one "fat" interface that mixes everything together.

Violation in PideYa

public interface Worker {
    void prepareDish(Dish dish);
    void deliverOrder(Order order);
    void answerCall(Call call);
}

public class Courier implements Worker {
    public void prepareDish(Dish dish) {
        throw new UnsupportedOperationException(); // A courier doesn't cook
    }
    public void deliverOrder(Order order) { /* ... */ }
    public void answerCall(Call call) {
        throw new UnsupportedOperationException(); // ...or answer the phone
    }
}

Courier is forced to "implement" methods that are not its business (and notice: the fat interface has forced it to violate the LSP as well). Moreover, if the signature of prepareDish changes, everyone implementing Worker must be recompiled and reviewed, whether they cook or not.

Fix

public interface Cook {
    void prepareDish(Dish dish);
}

public interface Delivers {
    void deliverOrder(Order order);
}

public interface AnswersPhone {
    void answerCall(Call call);
}

public class Courier implements Delivers {
    public void deliverOrder(Order order) { /* ... */ }
}

// A versatile employee in a small restaurant can implement several:
public class VersatileEmployee implements Cook, AnswersPhone {
    public void prepareDish(Dish dish) { /* ... */ }
    public void answerCall(Call call) { /* ... */ }
}

Each client depends only on what it needs, and roles compose freely.

D — Dependency Inversion Principle (DIP)

Statement: (1) high-level modules (business logic) must not depend on low-level modules (technical details): both must depend on abstractions; (2) abstractions must not depend on details — details must depend on abstractions.

Violation in PideYa

public class OrderService {
    private TwilioSmsService sms = new TwilioSmsService(); // Concrete detail

    public void confirm(Order order) {
        // ...business logic...
        sms.sendSms(order.getCustomer().getPhone(), "Order confirmed!");
    }
}

The business logic (high level) depends on a concrete SMS provider (low level), and it even creates it with new. Consequences: impossible to switch providers without touching the business code, and impossible to test OrderService without sending real SMS messages.

Fix

public interface Notifier {                        // Abstraction, defined
    void notify(Customer customer, String message); // by the HIGH level
}

public class TwilioSmsNotifier implements Notifier {  // Detail
    public void notify(Customer customer, String message) {
        // ...Twilio API...
    }
}

public class OrderService {
    private final Notifier notifier;

    public OrderService(Notifier notifier) {  // Constructor injection
        this.notifier = notifier;
    }

    public void confirm(Order order) {
        // ...business logic...
        notifier.notify(order.getCustomer(), "Order confirmed!");
    }
}

The "inversion" in the name is in the direction of the dependency: before, business → Twilio; now, business → Notifier ← Twilio. The detail depends on the business's abstraction, not the other way around. In tests it is enough to pass a fake Notifier. (The dependency injection performed by frameworks like Spring is the industrial automation of this principle.)

flowchart LR
    subgraph Before
        A[OrderService] --> B[TwilioSmsService]
    end
    subgraph After
        C[OrderService] --> I[«interface» Notifier]
        D[TwilioSmsNotifier] -.->|implements| I
    end

Other foundations: DRY, KISS and YAGNI

Three more principles, less formal but just as widely cited:

  • DRY (Don't Repeat Yourself): every piece of knowledge must have a single representation in the system. If the rule "delivery is free from 20 EUR upwards" is copied in PideYa's cart, checkout and confirmation email, the day it changes to 25 EUR someone will forget one of the three places. Careful: DRY is about knowledge, not similar-looking lines; two accidentally identical fragments that will evolve separately should not be unified.
  • KISS (Keep It Simple, Stupid): between two designs that solve the problem, the simpler one wins. Complexity is only justified when it buys something (flexibility that will actually be used, needed performance). This principle is the great counterweight to patterns: every pattern adds indirection, and KISS forces you to ask whether you need it.
  • YAGNI (You Aren't Gonna Need It): do not build today the flexibility you "might" need tomorrow. If PideYa only charges by card and there are no plans for more, creating the full PaymentMethod hierarchy "just in case" is cost without benefit. YAGNI does not forbid good design: it forbids speculating. The signal to generalize is a real requirement, not a hunch.
Principle Protects against... Tension with patterns
DRY Knowledge duplication Patterns help centralize variations
KISS Unnecessary complexity Every pattern must justify its indirection
YAGNI Speculative flexibility Do not apply a pattern for an imaginary future

The GoF's two maxims

The Gang of Four book condenses its philosophy into two maxims that will reappear in every module:

"Program to an interface, not an implementation"

Declare your variables, parameters and return types with the abstract type (interface or abstract class), not the concrete one. You have already seen it in action in the OCP and the DIP:

// Bad: coupled to the implementation
ArrayList<Dish> menu = new ArrayList<>();
TwilioSmsNotifier notifier = new TwilioSmsNotifier();

// Good: coupled only to the contract
List<Dish> menu = new ArrayList<>();
Notifier notifier = getNotifier();

The only place that knows the concrete class is the point of creation (new). Reducing and centralizing those points is, exactly, the mission of the creational patterns in module 2.

"Favor object composition over class inheritance"

Inheritance is tempting for reusing code, but it couples the child to the parent's innards, is fixed at compile time and cannot be combined (in Java you can only inherit from one class). Composition — holding a reference to another object and delegating to it — is flexible, combinable and changeable at runtime.

Example in PideYa: to model couriers on motorbikes or bicycles, inheriting MotorbikeCourier and BicycleCourier from Courier explodes as soon as another dimension appears (day/night shift → NightMotorbikeCourier?). With composition:

public class Courier {
    private Vehicle vehicle;     // Composition: "has a" vehicle

    public void assignVehicle(Vehicle vehicle) {  // Hot-swappable
        this.vehicle = vehicle;
    }

    public int estimatedTime(double km) {
        return vehicle.calculateMinutes(km);      // Delegation
    }
}

A courier can switch from motorbike to bicycle mid-shift without changing class. Inheritance is reserved for true "is-a" relationships with a respected contract (LSP). Most of the structural and behavioral patterns you will see are, at bottom, clever ways of using composition where a beginner would use inheritance.

From principles to patterns

Let's close with the lesson's central idea: patterns are concrete, packaged, named applications of these principles. Whenever you study a pattern in the coming modules, always ask which principles it is serving; as a general preview (without going into any pattern yet):

  • The creational patterns (module 2) exist above all to serve the DIP and "program to interfaces": they isolate the news so the rest of the code depends only on abstractions.
  • The structural patterns (module 3) are exercises in composition over inheritance: wrapping, adapting and composing objects.
  • The behavioral patterns (module 4) exploit the OCP and the SRP: they extract varying behaviors into their own hierarchies to extend without modifying.

And conversely: when you detect a principle violation (an if/else if chain over a type, a new in the middle of business logic, a subclass throwing UnsupportedOperationException), you will have the signal that a pattern probably exists for that situation.

Common Mistakes and Tips

  • Applying SRP as "tiny classes". The criterion is reasons to change, not lines of code. Over-fragmenting creates a different problem: logic pulverized across twenty anemic classes.
  • Chasing the OCP preemptively. Do not put an interface in front of everything "just in case": that violates YAGNI. Close against modification the axes where change has already happened or is announced (PideYa's promotions change every month: there, yes).
  • Verifying the LSP only with the compiler. Compiling does not mean substituting. Ask yourself: can all code using the superclass receive this subclass without surprises? Unexpected exceptions and empty methods are the alarm signal.
  • Confusing DIP with "use interfaces everywhere". The inversion lies in who defines the abstraction (the high level) and in which direction the dependencies point, not in the number of interfaces.
  • Treating DRY, KISS and YAGNI as absolutes. They are forces to balance: DRY pushes toward abstracting, KISS and YAGNI toward not over-abstracting. Good design lives in the tension, not at the extreme.
  • Tip: in your next code review, look for just two symptoms: if/else if chains over a "type" and news of concrete classes inside business logic. They are the two most frequent violations and the ones that motivate the most patterns.

Exercises

Exercise 1: diagnosing violations

This PideYa class violates several SOLID principles. Identify at least three, stating which ones and why:

public class RestaurantManager {
    private MySqlConnection connection = new MySqlConnection();

    public void addDish(String name, double price, String type) {
        if (type.equals("PIZZA")) {
            // pizza-specific validations
        } else if (type.equals("SUSHI")) {
            // sushi-specific validations
        }
        connection.execute("INSERT INTO dishes ...");
        emailOwner(name);
    }

    private void emailOwner(String dishName) {
        // SMTP, HTML templates, retries...
    }
}

Exercise 2: refactoring toward OCP + DIP

PideYa calculates delivery fees by zone with this code. Refactor it so that adding a new zone does not require modifying the class, and so the business class does not create its dependencies:

public class DeliveryFeeCalculator {
    public double calculate(Order order) {
        String zone = order.getAddress().getZone();
        if (zone.equals("CITY_CENTER")) return 1.50;
        else if (zone.equals("OUTSKIRTS")) return 3.00;
        else return 5.00;
    }
}

Exercise 3: inheritance versus composition

A colleague proposes modeling discounted dishes as subclasses: DishWithDiscount10 extends Dish, DishWithDiscount20 extends Dish. Argue why this is a bad idea (cite at least two principles or maxims from this lesson) and sketch, in pseudocode, a composition-based alternative.

Solutions

Solution 1:

  • SRP: the class mixes business validation, persistence (SQL) and email sending: three reasons to change.
  • OCP: the if/else if chain over the dish type forces modifying the method with every new type.
  • DIP: the business code depends on the concrete MySqlConnection and creates it with new; it should depend on an injected persistence abstraction. (It can also be argued that the embedded email violates SRP/DIP again: a technical detail inside the high level.)

Solution 2 (one possible solution):

public interface ZoneRate {
    boolean appliesTo(Address address);
    double cost();
}

public class CityCenterRate implements ZoneRate {
    public boolean appliesTo(Address a) { return a.getZone().equals("CITY_CENTER"); }
    public double cost() { return 1.50; }
}
// OutskirtsRate, StandardRate... analogous

public class DeliveryFeeCalculator {
    private final List<ZoneRate> rates;
    private final double defaultCost;

    public DeliveryFeeCalculator(List<ZoneRate> rates, double defaultCost) {
        this.rates = rates;                   // Injected: DIP
        this.defaultCost = defaultCost;
    }

    public double calculate(Order order) {
        return rates.stream()
                .filter(r -> r.appliesTo(order.getAddress()))
                .findFirst()
                .map(ZoneRate::cost)
                .orElse(defaultCost);         // OCP: new zones = new classes
    }
}

Adding the "SUBURBS" zone means creating a class and including it in the injected list: the calculator is untouched.

Solution 3: arguments: (1) combinatorial explosion and the rigidity of inheritance: every new percentage is a subclass, and a dish cannot gain or lose the discount at runtime (a discount is temporary by nature), which clashes with "favor composition over inheritance"; (2) OCP/DRY: the "apply a percentage" logic is duplicated in every subclass, and adding 15% requires a new class identical to the others; moreover, the real relationship is not "is-a" (a discounted dish is not a different kind of dish), a sign of misused inheritance (the spirit of the LSP). Alternative in pseudocode:

class Dish:
    basePrice
    discount: Discount          // composition, may be "no discount"
    finalPrice() = discount.applyTo(basePrice)

interface Discount:
    applyTo(price)

class PercentageDiscount implements Discount(percentage)
class NoDiscount implements Discount   // returns the price as is

The discount is assigned, changed or removed at runtime without touching the Dish class.

Conclusion

You now have the value system of object-oriented design: SOLID (one responsibility per class, extending without modifying, subtypes that truly substitute, interfaces tailored to the client and dependencies pointing at abstractions), tempered by DRY, KISS and YAGNI, and crowned by the GoF's two maxims: program to interfaces and favor composition. And, above all, the keystone of the course: every pattern you will study is one or more of these principles turned into concrete structure; the principles will also tell you when a pattern is overkill.

To study those structures we need to be able to draw and read them: patterns are communicated with class and sequence diagrams. In the next lesson you will learn precisely the UML this course requires: Essential UML for Understanding Patterns.

© Copyright 2026. All rights reserved