EthicsComputerCourses
Join training +91 90532 10052

Design Patterns & SOLID

Java · Lesson 40 of 44 Advanced 7 min read Updated

Two programs can print exactly the same output, yet one is a pleasure to change and the other breaks every time someone touches it. The difference is design: how responsibilities are split between classes and how those classes depend on each other. When a client in Pune asks for a new payment method or a new tax rule, good design means adding a class, not rewriting ten.

This lesson covers the SOLID principles, five guidelines for object-oriented design, and the design patterns you will meet most often in Java codebases and interviews. A design pattern is a named, reusable solution to a common design problem. Many were catalogued in the 1994 book Design Patterns by four authors, often called the "Gang of Four". Modern Java (interfaces, lambdas, records, enums) makes most of them much shorter than the original versions.

The SOLID principles

LetterPrincipleIn plain words
SSingle responsibilityA class should have one reason to change.
OOpen/closedAdd new behaviour by adding code, not by editing working code.
LLiskov substitutionA subclass must work anywhere its parent type is expected.
IInterface segregationPrefer several small interfaces over one large one.
DDependency inversionDepend on interfaces, and receive dependencies from outside.

Imagine an InvoiceService that calculates GST, writes to MySQL and sends SMS, all inside one 300-line method. A change to the SMS provider, the tax rule or the database forces edits to the same class, and testing it needs a real database and a real phone. Here is the same job split along SOLID lines (the 18% rate is only an example; real GST rates depend on the item, so confirm them with a CA):

Java
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;

public class Solid {
    record Invoice(String customer, BigDecimal amount) {}

    // Small interfaces, each with one job
    interface TaxPolicy { BigDecimal taxOn(BigDecimal net); }
    interface InvoiceStore { void save(Invoice invoice); List<Invoice> all(); }
    interface Sender { void send(String to, String text); }

    static class FlatGst implements TaxPolicy {
        private final BigDecimal rate;
        FlatGst(String rate) { this.rate = new BigDecimal(rate); }
        public BigDecimal taxOn(BigDecimal net) {
            return net.multiply(rate).setScale(2, RoundingMode.HALF_UP);
        }
    }

    static class InMemoryStore implements InvoiceStore {
        private final List<Invoice> saved = new ArrayList<>();
        public void save(Invoice invoice) { saved.add(invoice); }
        public List<Invoice> all() { return List.copyOf(saved); }
    }

    static class ConsoleSender implements Sender {
        public void send(String to, String text) { System.out.println("[to " + to + "] " + text); }
    }

    // Depends only on interfaces, which are handed in from outside
    static class InvoiceService {
        private final TaxPolicy tax;
        private final InvoiceStore store;
        private final Sender sender;

        InvoiceService(TaxPolicy tax, InvoiceStore store, Sender sender) {
            this.tax = tax;
            this.store = store;
            this.sender = sender;
        }

        Invoice bill(String customer, BigDecimal net) {
            Invoice invoice = new Invoice(customer, net.add(tax.taxOn(net)));
            store.save(invoice);
            sender.send(customer, "Your invoice total is Rs " + invoice.amount());
            return invoice;
        }
    }

    public static void main(String[] args) {
        InvoiceStore store = new InMemoryStore();
        Sender sender = new ConsoleSender();

        InvoiceService domestic = new InvoiceService(new FlatGst("0.18"), store, sender);
        domestic.bill("Kabir", new BigDecimal("1000.00"));
        domestic.bill("Priya", new BigDecimal("2499.00"));

        TaxPolicy zeroRated = net -> BigDecimal.ZERO.setScale(2);
        InvoiceService export = new InvoiceService(zeroRated, store, sender);
        export.bill("Overseas client", new BigDecimal("5000.00"));

        System.out.println("Invoices saved: " + store.all().size());
    }
}
Output
[to Kabir] Your invoice total is Rs 1180.00
[to Priya] Your invoice total is Rs 2948.82
[to Overseas client] Your invoice total is Rs 5000.00
Invoices saved: 3

Each principle is visible in the code:

  • S: tax maths, storage and messaging live in separate classes, so each changes for one reason only.
  • O: a zero-rated export invoice needed a new TaxPolicy (here a one-line lambda), not an edit to InvoiceService.
  • L: any InvoiceStore must honour the contract "what you save, all() returns". A store that silently drops invoices would compile but break every caller, which is exactly what Liskov forbids.
  • I: Sender has one method. A class that only sends SMS is not forced to implement email, push and WhatsApp methods it does not need.
  • D: InvoiceService never writes new MySqlStore(); it receives its dependencies through the constructor. In tests you pass fakes or Mockito mocks (Mocking with Mockito); in production, Spring Boot wires the real ones.

Strategy, Factory and Observer

These three patterns appear in almost every business application.

  • Strategy: a family of interchangeable algorithms behind one interface, such as delivery charges, discounts or payment methods. With functional interfaces, each strategy can be a lambda.
  • Factory: one method decides which implementation to create, often from configuration or user input, so callers do not need if chains.
  • Observer: an object publishes events, and any number of listeners react without the publisher knowing who they are. It is the idea behind event listeners, message queues and Spring's application events.
Java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

public class Patterns {
    // Strategy: interchangeable algorithms behind one interface
    interface DeliveryCharge { int forKm(int km); }

    // Factory: one place decides which implementation to create
    static DeliveryCharge chargeFor(String plan) {
        return switch (plan) {
            case "standard" -> km -> km <= 5 ? 0 : 40;
            case "express" -> km -> 60 + km * 5;
            case "prime" -> km -> 0;
            default -> throw new IllegalArgumentException("Unknown plan: " + plan);
        };
    }

    // Observer: listeners react to an event; the publisher does not know who they are
    record OrderPlaced(String id, String customer, int total) {}

    static class OrderEvents {
        private final List<Consumer<OrderPlaced>> listeners = new ArrayList<>();
        void subscribe(Consumer<OrderPlaced> listener) { listeners.add(listener); }
        void publish(OrderPlaced event) { listeners.forEach(l -> l.accept(event)); }
    }

    public static void main(String[] args) {
        for (String plan : List.of("standard", "express", "prime")) {
            DeliveryCharge charge = chargeFor(plan);
            System.out.println(plan + ": 3 km = Rs " + charge.forKm(3) + ", 12 km = Rs " + charge.forKm(12));
        }

        OrderEvents events = new OrderEvents();
        events.subscribe(e -> System.out.println("SMS to " + e.customer() + ": order " + e.id() + " confirmed"));
        events.subscribe(e -> System.out.println("Warehouse: pack order " + e.id()));
        events.subscribe(e -> {
            if (e.total() >= 5000) {
                System.out.println("Fraud check queued for " + e.id());
            }
        });

        events.publish(new OrderPlaced("A-101", "Meera", 1299));
        events.publish(new OrderPlaced("A-102", "Rohan", 7499));
    }
}
Output
standard: 3 km = Rs 0, 12 km = Rs 40
express: 3 km = Rs 75, 12 km = Rs 120
prime: 3 km = Rs 0, 12 km = Rs 0
SMS to Meera: order A-101 confirmed
Warehouse: pack order A-101
SMS to Rohan: order A-102 confirmed
Warehouse: pack order A-102
Fraud check queued for A-102

Adding a fraud check did not touch the order code at all; it simply subscribed. When strategies need their own data or many methods, write them as classes, or as an enum whose constants implement the interface. When the set of variants is fixed and known, a sealed interface with records and a pattern-matching switch (see Sealed Classes & Pattern Matching) is often clearer than a strategy.

Builder and Adapter

A Builder constructs an object with many optional parts step by step, instead of a constructor with eight parameters where nobody remembers which true means what. You use builders constantly: HttpRequest.newBuilder(), JsonMapper.builder(), StringBuilder. An Adapter wraps an existing class with an inconvenient interface so it fits the interface your code expects, which is common when integrating an old library or a vendor SDK.

Java
import java.util.Objects;

public class Tickets {
    // Builder: readable construction of an object with many optional parts
    static final class TrainTicket {
        private final String passenger;
        private final String from;
        private final String to;
        private final String travelClass;
        private final boolean meal;
        private final String berth;

        private TrainTicket(Builder b) {
            passenger = Objects.requireNonNull(b.passenger, "passenger is required");
            from = Objects.requireNonNull(b.from, "from is required");
            to = Objects.requireNonNull(b.to, "to is required");
            travelClass = b.travelClass;
            meal = b.meal;
            berth = b.berth;
        }

        static Builder builder() { return new Builder(); }

        @Override
        public String toString() {
            return passenger + " " + from + " to " + to + " [" + travelClass
                    + ", meal=" + meal + ", berth=" + berth + "]";
        }

        static final class Builder {
            private String passenger;
            private String from;
            private String to;
            private String travelClass = "SL";          // sensible defaults
            private boolean meal = false;
            private String berth = "any";

            Builder passenger(String v) { passenger = v; return this; }
            Builder from(String v) { from = v; return this; }
            Builder to(String v) { to = v; return this; }
            Builder travelClass(String v) { travelClass = v; return this; }
            Builder meal(boolean v) { meal = v; return this; }
            Builder berth(String v) { berth = v; return this; }
            TrainTicket build() { return new TrainTicket(this); }
        }
    }

    // Adapter: make an old API fit the interface the rest of the code expects
    interface Sender { void send(String phone, String text); }

    static class OldSmsLibrary {                         // third-party code we cannot change
        int sendMessage(String text, String mobileWithCountryCode, int priority) {
            System.out.println("OLD LIB -> " + mobileWithCountryCode + " (p" + priority + "): " + text);
            return 0;                                    // 0 means success in this old API
        }
    }

    static class OldSmsAdapter implements Sender {
        private final OldSmsLibrary library = new OldSmsLibrary();

        public void send(String phone, String text) {
            int code = library.sendMessage(text, "+91" + phone, 1);
            if (code != 0) {
                throw new IllegalStateException("SMS failed with code " + code);
            }
        }
    }

    public static void main(String[] args) {
        TrainTicket ticket = TrainTicket.builder()
                .passenger("Ananya").from("Lucknow").to("Pune")
                .travelClass("3A").berth("lower")
                .build();
        System.out.println(ticket);
        System.out.println(TrainTicket.builder().passenger("Farhan").from("Kochi").to("Chennai").build());
        try {
            TrainTicket.builder().passenger("Isha").build();
        } catch (NullPointerException e) {
            System.out.println("Rejected: " + e.getMessage());
        }

        Sender sender = new OldSmsAdapter();
        sender.send("9810000001", "Your PNR is confirmed");
    }
}
Output
Ananya Lucknow to Pune [3A, meal=false, berth=lower]
Farhan Kochi to Chennai [SL, meal=false, berth=any]
Rejected: from is required
OLD LIB -> +919810000001 (p1): Your PNR is confirmed

The builder gives defaults for optional fields and checks required ones in one place, and the finished ticket is immutable. The adapter hides three oddities of the old library (argument order, country code, numeric error codes) behind a clean Sender, so if you later switch SMS providers, only the adapter changes. For simple data with few fields, a record is better than a builder.

Singleton, and patterns to use carefully

A Singleton guarantees exactly one instance of a class. If you truly need one, the simplest safe form in Java is an enum with a single constant: enum AppConfig { INSTANCE; ... }. The JVM creates it once, lazily and thread-safely. However, singletons accessed through a static getInstance() are global state in disguise: hard to replace in tests and easy to misuse. In modern applications, a dependency-injection framework creates one shared instance of each service for you (Spring beans are singletons by default), and your classes simply receive it through their constructors.

Patterns are a vocabulary, not a checklist. Adding a factory, a builder and three interfaces to a 50-line program makes it worse. Apply a pattern when you feel the pain it solves: repeated if chains over types (Strategy), constructors with too many parameters (Builder), an awkward third-party API (Adapter), or one change rippling through many classes (Observer).

Common mistakes

  • God classes. A class named Manager, Util or Helper with 40 methods usually breaks single responsibility; split it by job.
  • Creating dependencies inside a class. new MySqlStore() in a service hard-wires it; pass it through the constructor.
  • Subclasses that throw UnsupportedOperationException for inherited methods. That breaks Liskov; rethink the hierarchy or split the interface.
  • Static singletons everywhere. They make tests share state; use dependency injection.
  • Pattern fever. Do not add patterns where a plain method or a record is enough.

Key points

  • SOLID: single responsibility, open/closed, Liskov substitution, interface segregation, dependency inversion.
  • Depend on small interfaces and receive implementations through constructors; this makes code testable and replaceable.
  • Strategy swaps algorithms; Factory centralises creation; Observer lets listeners react to events.
  • Builder handles many optional parameters; Adapter wraps an awkward API; Decorator adds behaviour around an object with the same interface.
  • Use an enum for a true singleton, but prefer dependency injection.
  • Use patterns to solve real problems, not to decorate simple code.

Exercise

Write a Decorator called SafeSender that implements Sender (void send(String phone, String text)) and wraps any other Sender. It must block messages longer than 160 characters (printing a "Blocked" line instead of sending) and count how many messages it actually passed on. Test it by wrapping a ConsoleSender and sending three messages, one of them 200 characters long, then print the count.

Show solution
Java
public class Decorate {
    interface Sender { void send(String phone, String text); }

    static class ConsoleSender implements Sender {
        public void send(String phone, String text) {
            System.out.println("[to " + phone + "] " + text);
        }
    }

    // Decorator: same interface, wraps another Sender and adds behaviour
    static class SafeSender implements Sender {
        private static final int MAX_LENGTH = 160;
        private final Sender inner;
        private int sent = 0;

        SafeSender(Sender inner) { this.inner = inner; }

        public void send(String phone, String text) {
            if (text.length() > MAX_LENGTH) {
                System.out.println("Blocked: message to " + phone + " has " + text.length() + " characters");
                return;
            }
            inner.send(phone, text);
            sent++;
        }

        int sentCount() { return sent; }
    }

    public static void main(String[] args) {
        SafeSender sender = new SafeSender(new ConsoleSender());
        sender.send("9810000001", "Your order A-101 is out for delivery");
        sender.send("9810000002", "x".repeat(200));
        sender.send("9810000003", "Your refund of Rs 499 has been processed");
        System.out.println("Messages sent: " + sender.sentCount());
    }
}
Output
[to 9810000001] Your order A-101 is out for delivery
Blocked: message to 9810000002 has 200 characters
[to 9810000003] Your refund of Rs 499 has been processed
Messages sent: 2

Because SafeSender is itself a Sender, code that expects a Sender cannot tell the difference, and decorators can be stacked: new SafeSender(new OldSmsAdapter()) would add the same checks to the old SMS library. Java's own I/O classes use this pattern, for example a BufferedReader wrapping another Reader.

Quick Quiz

1. Which SOLID principle says "add new behaviour by adding code, not by editing working code"?

2. A checkout needs different delivery-charge rules for different plans. Which pattern fits best?

3. What problem does the Builder pattern solve?

4. Why is dependency injection usually preferred over static singletons?

Learn Java with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call