Junior 5 min · March 06, 2026

Design Patterns — Why Singleton Killed Our Test Suite

Tests pass alone but fail in batch? A static Singleton exhausted our connection pool.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Design patterns are reusable blueprints for solving common OOP problems, not copy-paste code.
  • Creational patterns control object creation; Structural patterns define class composition; Behavioral patterns manage object communication.
  • Singleton ensures one instance; Factory defers creation to subclasses; Builder constructs complex objects step by step.
  • Strategy eliminates if-else chains; Adapter translates interfaces; Observer enables event broadcasting.
  • Performance impact: Singleton adds synchronization overhead (~10ns per call); Strategy adds class overhead but reduces branching.
  • Biggest mistake: forcing a pattern where a simple solution suffices — patterns are not trophies.
Plain-English First

Imagine you're an architect designing houses. You don't invent a new way to build a staircase every time — you reuse a proven blueprint. Design patterns are exactly that: proven blueprints for solving common software problems that every experienced developer has already bumped into and figured out. They're not copy-paste code; they're named, documented strategies you can pull from your mental toolbox the moment you recognize a familiar problem.

Every codebase eventually grows into a maze of tangled classes, mysterious dependencies, and objects that somehow know too much about each other. Junior devs patch these problems with workarounds; senior devs prevent them by recognizing the problem type early and applying a battle-tested structural solution. That's the real power of design patterns — they're the vocabulary that lets experienced engineers say 'this calls for a Strategy pattern' in five words instead of spending two hours designing something from scratch.

Design patterns exist because object-oriented programming gives you enormous freedom, and enormous freedom means enormous ways to shoot yourself in the foot. Without patterns, every developer independently rediscovers the same painful lessons: tight coupling, fragile inheritance chains, objects exploding in complexity, and code that's impossible to extend without breaking something else. Patterns crystallize decades of collective engineering pain into reusable solutions with well-understood trade-offs.

By the end of this article you'll be able to identify which of the three pattern families — Creational, Structural, or Behavioral — applies to a given problem, implement the most critical patterns in Java with confidence, spot pattern opportunities in code reviews, and walk into an interview able to discuss trade-offs rather than just regurgitate definitions.

Creational Patterns — Controlling How Objects Are Born

Creational patterns tackle a deceptively simple question: who is responsible for creating objects, and how? In small programs, you just call new. But the moment your codebase scales, that innocent new keyword scatters object-creation logic everywhere. Change the constructor signature? Congratulations — you've got fifty compilation errors.

The Singleton pattern ensures a class has exactly one instance — useful for shared resources like a configuration manager or database connection pool. The Factory Method pattern hands object creation to subclasses, so the calling code never knows or cares which concrete type it's getting. The Builder pattern lets you construct complex objects step by step, eliminating constructors with eight parameters where you can never remember which boolean means what.

The key insight is this: creational patterns decouple the 'what gets created' from the 'who creates it.' That indirection feels like overhead until the day you need to swap a real database client for a mock in tests — and you realize you can do it in one place rather than hunting through the entire codebase.

DatabaseConnectionPool.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
// Singleton Pattern — guarantees one shared instance of an expensive resource
public class DatabaseConnectionPool {

    // volatile ensures the instance is correctly visible across threads
    private static volatile DatabaseConnectionPool instance;

    private final String databaseUrl;
    private int activeConnections;

    // Private constructor prevents anyone from calling 'new DatabaseConnectionPool()'
    private DatabaseConnectionPool(String databaseUrl) {
        this.databaseUrl = databaseUrl;
        this.activeConnections = 0;
        System.out.println("Pool created — connecting to: " + databaseUrl);
    }

    // Double-checked locking: thread-safe without locking on every call
    public static DatabaseConnectionPool getInstance(String databaseUrl) {
        if (instance == null) {                          // First check (no lock)
            synchronized (DatabaseConnectionPool.class) {
                if (instance == null) {                  // Second check (with lock)
                    instance = new DatabaseConnectionPool(databaseUrl);
                }
            }
        }
        return instance;
    }

    public void borrowConnection() {
        activeConnections++;
        System.out.println("Connection borrowed. Active: " + activeConnections);
    }

    public void returnConnection() {
        if (activeConnections > 0) activeConnections--;
        System.out.println("Connection returned. Active: " + activeConnections);
    }

    // --- Builder Pattern — for objects with many optional configuration fields ---
    public static class ReportBuilder {
        // Required field
        private final String reportTitle;

        // Optional fields with sensible defaults
        private String dateRange   = "Last 30 days";
        private boolean includePdf  = false;
        private int     maxRows     = 100;

        public ReportBuilder(String reportTitle) {
            this.reportTitle = reportTitle;
        }

        // Each setter returns 'this' so calls can be chained fluently
        public ReportBuilder withDateRange(String dateRange) {
            this.dateRange = dateRange;
            return this;
        }

        public ReportBuilder withPdfExport(boolean includePdf) {
            this.includePdf = includePdf;
            return this;
        }

        public ReportBuilder withMaxRows(int maxRows) {
            this.maxRows = maxRows;
            return this;
        }

        public String build() {
            // In real code this would return a Report object
            return String.format(
                "Report[title=%s, range=%s, pdf=%b, maxRows=%d]",
                reportTitle, dateRange, includePdf, maxRows
            );
        }
    }

    public static void main(String[] args) {
        // Both calls return the exact same object — only one pool is ever created
        DatabaseConnectionPool poolA = DatabaseConnectionPool.getInstance("jdbc:postgresql://prod-db/sales");
        DatabaseConnectionPool poolB = DatabaseConnectionPool.getInstance("jdbc:postgresql://prod-db/sales");

        System.out.println("Same instance? " + (poolA == poolB));  // true

        poolA.borrowConnection();
        poolB.borrowConnection(); // poolB IS poolA, so activeConnections reaches 2
        poolA.returnConnection();

        // Builder: readable construction, no 8-parameter constructor nightmare
        String report = new ReportBuilder("Monthly Sales")
            .withDateRange("2024-01-01 to 2024-01-31")
            .withPdfExport(true)
            .withMaxRows(500)
            .build();

        System.out.println(report);
    }
}
Output
Pool created — connecting to: jdbc:postgresql://prod-db/sales
Same instance? true
Connection borrowed. Active: 1
Connection borrowed. Active: 2
Connection returned. Active: 1
Report[title=Monthly Sales, range=2024-01-01 to 2024-01-31, pdf=true, maxRows=500]
Watch Out: Singleton ≠ Always Good Design
Singletons introduce hidden global state — the #1 enemy of testability. Before reaching for Singleton, ask: can I inject this dependency instead? Dependency injection gives you the 'one instance' benefit without the testing nightmare. Use Singleton only for truly global, stateless-ish resources like connection pools or config readers.

Structural Patterns — Wiring Classes Together Without Gluing Them Shut

Structural patterns are about composition — how you assemble classes and objects into larger, more capable structures while keeping those structures flexible. The recurring villain here is rigidity: code that works today but forces you to refactor half the system the moment requirements shift.

The Adapter pattern is your translator. You have a third-party library with an incompatible interface — instead of rewriting your code or the library, you drop an Adapter in between that speaks both languages. The Decorator pattern lets you add behavior to an object without modifying its class — you wrap it in another object that adds the new capability. Java's own BufferedReader wrapping a FileReader is a live Decorator in the JDK.

The Facade pattern hides complexity behind a clean, simple interface. Think of a smart home app: you press 'Good Night' and it locks the doors, dims the lights, and sets the thermostat. You don't call each subsystem directly — the Facade coordinates them. This is the pattern you reach for when onboarding new team members who shouldn't need to understand the entire subsystem to do useful work.

NotificationFacade.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// Facade Pattern — hides a complex multi-subsystem workflow behind a single clean method

// --- Subsystem 1: Email service (imagine this is a vendor SDK) ---
class EmailService {
    public void sendEmail(String recipient, String subject, String body) {
        System.out.println("[EMAIL] To: " + recipient + " | Subject: " + subject);
    }
}

// --- Subsystem 2: SMS gateway ---
class SmsGateway {
    public void sendSms(String phoneNumber, String message) {
        System.out.println("[SMS] To: " + phoneNumber + " | Msg: " + message);
    }
}

// --- Subsystem 3: Push notification service ---
class PushNotificationService {
    public void pushToDevice(String deviceToken, String payload) {
        System.out.println("[PUSH] Token: " + deviceToken + " | Payload: " + payload);
    }
}

// --- Adapter Pattern embedded: normalize an incompatible legacy alerter ---
interface ModernAlerter {
    void alert(String userId, String message);
}

class LegacyAlertSystem {
    // Old API — completely different signature, can't touch this code
    public void triggerAlertForEmployee(int employeeId, String msg, boolean urgent) {
        System.out.println("[LEGACY ALERT] EmpID:" + employeeId + " | " + msg + (urgent ? " (URGENT)" : ""));
    }
}

// Adapter wraps the legacy system and exposes the ModernAlerter interface
class LegacyAlertAdapter implements ModernAlerter {
    private final LegacyAlertSystem legacySystem;

    public LegacyAlertAdapter(LegacyAlertSystem legacySystem) {
        this.legacySystem = legacySystem;
    }

    @Override
    public void alert(String userId, String message) {
        // Translate modern call → legacy call (userId parsed to int, always urgent)
        int employeeId = Integer.parseInt(userId);
        legacySystem.triggerAlertForEmployee(employeeId, message, true);
    }
}

// --- The Facade: one method hides all subsystem coordination ---
class NotificationFacade {
    private final EmailService       emailService;
    private final SmsGateway         smsGateway;
    private final PushNotificationService pushService;
    private final ModernAlerter      alerter;

    public NotificationFacade() {
        this.emailService = new EmailService();
        this.smsGateway   = new SmsGateway();
        this.pushService  = new PushNotificationService();
        // Plugging in the adapted legacy system — callers never know it's legacy
        this.alerter      = new LegacyAlertAdapter(new LegacyAlertSystem());
    }

    // Caller doesn't know about three subsystems — just calls this one method
    public void notifyUser(String userId, String email, String phone,
                           String deviceToken, String eventMessage) {
        System.out.println("--- Sending notifications for event: " + eventMessage + " ---");
        emailService.sendEmail(email, "Important Update", eventMessage);
        smsGateway.sendSms(phone, eventMessage);
        pushService.pushToDevice(deviceToken, "{\"msg\":\"" + eventMessage + "\"}");
        alerter.alert(userId, eventMessage);  // Goes through Adapter → Legacy system
        System.out.println("--- All notifications dispatched ---\n");
    }
}

public class NotificationFacadeDemo {
    public static void main(String[] args) {
        NotificationFacade notifier = new NotificationFacade();

        // Caller only needs to know about this one clean method
        notifier.notifyUser(
            "4821",
            "alice@example.com",
            "+14155552671",
            "device-token-abc123",
            "Your order has shipped!"
        );
    }
}
Output
--- Sending notifications for event: Your order has shipped! ---
[EMAIL] To: alice@example.com | Subject: Important Update
[SMS] To: +14155552671 | Msg: Your order has shipped!
[PUSH] Token: device-token-abc123 | Payload: {"msg":"Your order has shipped!"}
[LEGACY ALERT] EmpID:4821 | Your order has shipped! (URGENT)
--- All notifications dispatched ---
Pro Tip: Facade Is Your Best Friend During Legacy Migrations
When migrating a legacy system, create a Facade over the old code first. New features talk to the Facade; the Facade talks to legacy internals. You can then replace subsystems behind the Facade one at a time without any callers noticing — each swap is isolated, testable, and low-risk.

Behavioral Patterns — Who Does What, and How They Communicate

Behavioral patterns govern how objects talk to each other and who owns which responsibility. This family solves the 'god object' problem — that one class that somehow ends up knowing about everything and doing everything.

The Strategy pattern lets you define a family of algorithms, encapsulate each one, and swap them at runtime. Instead of a giant if-else chain for payment processing ('if credit card do this, if PayPal do that'), each payment method is its own class that implements a common interface. Adding a new payment method means adding one class — not touching existing code. That's the Open/Closed Principle in action.

The Observer pattern enables a one-to-many notification system. An event source (Subject) maintains a list of listeners (Observers) and notifies them all when something changes. This is the backbone of event systems, UI frameworks, and message brokers — anywhere you need 'when X happens, automatically do Y, Z, and W' without X knowing about Y, Z, or W.

The Command pattern turns a request into a standalone object. This makes operations queueable, undoable, and loggable — which is exactly how text editors implement Ctrl+Z.

ShoppingCartStrategy.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import java.util.ArrayList;
import java.util.List;

// Strategy Pattern — swap discount algorithms at runtime without changing cart logic

// The Strategy interface: every discount type must implement this contract
interface DiscountStrategy {
    double apply(double originalPrice);
    String describe();
}

// Concrete Strategy 1: No discount (the default)
class NoDiscount implements DiscountStrategy {
    @Override
    public double apply(double originalPrice) {
        return originalPrice;  // Price unchanged
    }
    @Override public String describe() { return "No discount applied"; }
}

// Concrete Strategy 2: Percentage-based discount (e.g. summer sale)
class PercentageDiscount implements DiscountStrategy {
    private final double percentOff;  // e.g. 20.0 means 20% off

    public PercentageDiscount(double percentOff) {
        this.percentOff = percentOff;
    }

    @Override
    public double apply(double originalPrice) {
        return originalPrice * (1 - percentOff / 100.0);
    }
    @Override public String describe() { return percentOff + "% off"; }
}

// Concrete Strategy 3: Flat amount off (e.g. $10 coupon code)
class FlatAmountDiscount implements DiscountStrategy {
    private final double discountAmount;

    public FlatAmountDiscount(double discountAmount) {
        this.discountAmount = discountAmount;
    }

    @Override
    public double apply(double originalPrice) {
        // Guard: price can't go below zero
        return Math.max(0, originalPrice - discountAmount);
    }
    @Override public String describe() { return "$" + discountAmount + " flat off"; }
}

// Observer Pattern — notify multiple listeners when cart total changes
interface CartObserver {
    void onTotalChanged(double newTotal);
}

// Context class: the ShoppingCart holds a strategy and notifies observers
class ShoppingCart {
    private final List<Double>       itemPrices  = new ArrayList<>();
    private final List<CartObserver> observers   = new ArrayList<>();
    private DiscountStrategy         discountStrategy;

    public ShoppingCart() {
        this.discountStrategy = new NoDiscount();  // Sensible default
    }

    // Swap the discount strategy at any point without rebuilding the cart
    public void setDiscountStrategy(DiscountStrategy strategy) {
        this.discountStrategy = strategy;
        System.out.println("Discount updated: " + strategy.describe());
        notifyObservers();  // Immediately tell listeners about the pricing change
    }

    public void addItem(String itemName, double price) {
        itemPrices.add(price);
        System.out.println("Added: " + itemName + " ($" + price + ")");
        notifyObservers();
    }

    public void addObserver(CartObserver observer) {
        observers.add(observer);
    }

    private void notifyObservers() {
        double rawTotal      = itemPrices.stream().mapToDouble(Double::doubleValue).sum();
        double discountedTotal = discountStrategy.apply(rawTotal);
        // Every registered observer gets the updated total automatically
        for (CartObserver observer : observers) {
            observer.onTotalChanged(discountedTotal);
        }
    }
}

public class ShoppingCartStrategy {
    public static void main(String[] args) {
        ShoppingCart cart = new ShoppingCart();

        // Register observers — these represent UI widgets or analytics services
        cart.addObserver(total ->
            System.out.printf("  [Price Display] Current total: $%.2f%n", total));
        cart.addObserver(total ->
            System.out.printf("  [Analytics]     Cart value logged: $%.2f%n", total));

        System.out.println("\n=== Adding items ===");
        cart.addItem("Wireless Keyboard", 79.99);
        cart.addItem("USB-C Hub",         49.99);
        cart.addItem("Monitor Stand",     35.00);

        System.out.println("\n=== Applying summer sale (20% off) ===");
        cart.setDiscountStrategy(new PercentageDiscount(20));

        System.out.println("\n=== User applies $15 coupon code ===");
        // Swapping strategy mid-session — observers are notified again automatically
        cart.setDiscountStrategy(new FlatAmountDiscount(15));
    }
}
Output
=== Adding items ===
Added: Wireless Keyboard ($79.99)
[Price Display] Current total: $79.99
[Analytics] Cart value logged: $79.99
Added: USB-C Hub ($49.99)
[Price Display] Current total: $129.98
[Analytics] Cart value logged: $129.98
Added: Monitor Stand ($35.0)
[Price Display] Current total: $164.98
[Analytics] Cart value logged: $164.98
=== Applying summer sale (20% off) ===
Discount updated: 20.0% off
[Price Display] Current total: $131.98
[Analytics] Cart value logged: $131.98
=== User applies $15 coupon code ===
Discount updated: $15.0 flat off
[Price Display] Current total: $149.98
[Analytics] Cart value logged: $149.98
Interview Gold: Strategy vs. State Pattern
Strategy and State look nearly identical in structure — both swap behavior via an interface. The difference is intent: Strategy algorithms are interchangeable and the client chooses which one. State transitions happen automatically based on the object's internal condition. If the object itself decides when to switch behavior, it's State. If the caller decides, it's Strategy.

Factory Method Pattern — Let Subclasses Decide the Object Type

The Factory Method pattern defines an interface for creating an object, but lets subclasses decide which class to instantiate. It's your go-to when you have a class that can't anticipate the type of objects it must create — you want the flexibility to hand that decision to subclasses.

You'll see this pattern everywhere in frameworks. Look at java.util.Collection's iterator() — it's a factory method. Each collection type returns its own iterator. The calling code doesn't care which iterator it gets; it just calls hasNext() and next().

Here's the litmus test: if you find yourself writing if (type == A) { return new A(); } else if (type == B) { return new B(); } you have a factory that's begging for subclassing. The Factory Method inverts that — the base class defines the contract, each subclass provides its own creation logic.

DocumentFactory.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// Factory Method Pattern — let subclasses decide which concrete document to create

abstract class Document {
    public abstract void open();
    public abstract void save();
}

class PDFDocument extends Document {
    @Override public void open() { System.out.println("Opening PDF..."); }
    @Override public void save() { System.out.println("Saving PDF..."); }
}

class WordDocument extends Document {
    @Override public void open() { System.out.println("Opening Word document..."); }
    @Override public void save() { System.out.println("Saving Word document..."); }
}

class SpreadsheetDocument extends Document {
    @Override public void open() { System.out.println("Opening spreadsheet..."); }
    @Override public void save() { System.out.println("Saving spreadsheet..."); }
}

// The factory method is declared abstract in the creator
abstract class DocumentCreator {
    // This is the factory method — subclasses implement it
    protected abstract Document createDocument();

    // Template method using the factory method
    public void newDocument() {
        Document doc = createDocument();
        doc.open();
        // ... do some work ...
        doc.save();
    }
}

class PDFCreator extends DocumentCreator {
    @Override
    protected Document createDocument() {
        return new PDFDocument();
    }
}

class WordCreator extends DocumentCreator {
    @Override
    protected Document createDocument() {
        return new WordDocument();
    }
}

class SpreadsheetCreator extends DocumentCreator {
    @Override
    protected Document createDocument() {
        return new SpreadsheetDocument();
    }
}

public class DocumentFactoryDemo {
    public static void main(String[] args) {
        // The client only knows about DocumentCreator and Document
        DocumentCreator creator = new PDFCreator();
        creator.newDocument();

        creator = new WordCreator();
        creator.newDocument();
    }
}
Output
Opening PDF...
Saving PDF...
Opening Word document...
Saving Word document...
The 'Virtual Constructor' Mental Model
  • The base class declares the creation contract (abstract method).
  • Each subclass provides its own concrete product.
  • The client calls the factory method through the base class reference — never knows the concrete type.
  • This decouples the client from the concrete product classes entirely.
Production Insight
In a payment processing system, we had a ServiceFactory that used a giant switch statement to create payment gateway objects.
Adding a new gateway meant modifying the factory class — violating Open/Closed.
Refactored to Factory Method: each gateway provider had its own subclass, and registration happened via dependency injection.
Key rule: if you're adding 'else if' every time a new product appears, you need Factory Method.
Key Takeaway
Factory Method decouples creation from usage.
Subclasses decide the concrete type — the client stays stable.
If adding a new product means editing the factory, you've missed the point.

Adapter Pattern — Bridging Incompatible Interfaces Without Breaking Existing Code

The Adapter pattern translates one interface into another that the client expects. It's the software equivalent of a travel plug adapter — you don't change the wall socket, and you don't change your device's plug. You drop an adapter in between.

In practice, you'll use Adapter when integrating third-party libraries with APIs that don't match your own abstractions. Instead of forking the library or modifying all your callers, you write a small wrapper that implements your target interface and delegates to the adapted class.

This pattern is also the foundation of the Facade you saw earlier — the LegacyAlertAdapter inside the NotificationFacade is a live example. The key difference: Facade simplifies a complex subsystem; Adapter translates a mismatched interface.

PaymentAdapter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// Adapter Pattern — make a legacy payment system work with a modern checkout flow

// Target interface that the modern checkout expects
interface ModernPaymentProcessor {
    boolean charge(String customerId, double amount);
    String getTransactionId();
}

// Adaptee: legacy payment system with a different API
class LegacyPaymentSystem {
    // Returns a transaction ID as an integer, amount in cents
    public int executePayment(int clientCode, int amountCents) {
        System.out.println("[LEGACY] Charging client " + clientCode + " amount " + amountCents + " cents");
        return 12345; // pretend transaction ID
    }
}

// Adapter maps modern interface to legacy API
class LegacyPaymentAdapter implements ModernPaymentProcessor {
    private final LegacyPaymentSystem legacySystem;
    private String lastTransactionId;

    public LegacyPaymentAdapter(LegacyPaymentSystem legacySystem) {
        this.legacySystem = legacySystem;
    }

    @Override
    public boolean charge(String customerId, double amount) {
        int clientCode = Integer.parseInt(customerId.replaceAll("[^0-9]", ""));
        int cents = (int) Math.round(amount * 100);
        int legacyId = legacySystem.executePayment(clientCode, cents);
        this.lastTransactionId = String.valueOf(legacyId);
        return legacyId > 0;
    }

    @Override
    public String getTransactionId() {
        return lastTransactionId;
    }
}

public class PaymentAdapterDemo {
    public static void main(String[] args) {
        ModernPaymentProcessor processor = new LegacyPaymentAdapter(new LegacyPaymentSystem());

        // Client code calls the modern interface — doesn't know it's talking to legacy
        boolean success = processor.charge("CUST-4821", 49.99);
        System.out.println("Payment success: " + success);
        System.out.println("Transaction ID: " + processor.getTransactionId());
    }
}
Output
[LEGACY] Charging client 4821 amount 4999 cents
Payment success: true
Transaction ID: 12345
Adapter vs. Facade — Quick Distinction
Use Adapter when you need to translate one interface to another. Use Facade when you want to provide a simpler interface over a complex subsystem. Adapter changes the interface; Facade hides complexity. They often appear together — the Adapter inside a Facade is a common pattern.
Production Insight
We integrated a new credit card gateway that exposed a REST API, but the rest of the system used a legacy SOAP client interface.
Without an Adapter, every caller would need to change — a 50-file refactor.
One Adapter class later, zero callers changed.
Rule: if the library's interface doesn't match yours, write an Adapter, not a refactoring ticket.
Key Takeaway
Adapter translates an existing interface to the one your code expects.
It lets you integrate third-party code without touching your core logic.
If you find yourself modifying callers to match a library's API, you need an Adapter.

Pattern Selection — How to Choose the Right Pattern Without Over-Engineering

The hardest part isn't implementing patterns — it's knowing when to use one. Over-engineering happens when you force a pattern onto a problem that doesn't need it. Under-engineering happens when you ignore a pattern that would save you from a painful refactor next month.

Here's a practical decision framework. First, identify the problem family: is it about object creation (look at your new statements), about class structure (look at inheritance and delegation), or about object interaction (look at if-else chains and callbacks)?

Second, apply the 'pain threshold' test: if solving the problem without a pattern takes one developer-day and the pattern adds two days of upfront work, you're paying for insurance you may not need. But if the pattern saves you from a five-day refactor later, buy the insurance.

Third, remember that patterns are validated solutions, not mandatory rules. No pattern is always right. Singleton is perfect for a config reader but toxic for a mutable service locator. Strategy is great for payment algorithms but overkill for two fixed behaviors.

PatternDecisionTree.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Not runnable — this is a decision tree sketch
// Identify the problem:
// 1. Object creation problem? -> Singleton, Factory, Builder, Prototype
// 2. Composition/integration problem? -> Adapter, Decorator, Facade, Proxy
// 3. Communication/responsibility problem? -> Strategy, Observer, Command, Iterator
//
// Example:
// Problem: "We have a class that creates different types of documents based on a string parameter"
// Pain: "Every time we add a document type, we edit that class"
// Pattern: Factory Method -> each document type in its own subclass
//
// Problem: "We need to send notifications via email, SMS, and push — currently three separate method calls"
// Pain: "Every new notification channel requires changing the caller code"
// Pattern: Facade + possibly Observer
//
// Problem: "The payment processing method has a 12-branch if-else chain"
// Pain: "Adding a new payment method breaks the existing code"
// Pattern: Strategy
The 'Three Families' Mental Model
  • If you can't add a new object type without editing old code → Creational.
  • If interfaces don't fit together → Structural.
  • If control flow is tangled in if-else or switch → Behavioral.
  • Each family has a distinct pain signal — learn to recognize it.
Production Insight
A team spent a week implementing a full Abstract Factory for a feature that only had one product family.
That's six months of maintenance debt for zero flexibility.
Rule: don't generalise for hypothetical future requirements — generalise when the second variant actually arrives.
Patterns that solve real problems pay off; patterns that predict imaginary ones create overhead.
Key Takeaway
Patterns are solutions to recurring design problems — not prescriptions.
Apply them when you can name the problem they solve, not before.
If explaining the pattern takes longer than explaining the problem, skip it.
● Production incidentPOST-MORTEMseverity: high

The Singleton Service Locator That Killed Our Test Suite

Symptom
End-to-end tests pass in isolation but fail in batch. Test A succeeds, test B fails with 'connection pool exhausted' even though each test was supposed to create its own pool.
Assumption
The team assumed the Singleton's lazy initialization would create separate instances in each test, because they weren't aware the JVM reuses the same classloader across tests.
Root cause
The Singleton's static instance lives for the entire JVM lifetime. Once the first test created it with a small pool size, subsequent tests couldn't create another — they got the same exhausted pool.
Fix
Replace the Singleton with dependency injection. Used a factory that the test framework could override to create fresh pool instances per test. Added a hook to close the pool after each test suite.
Key lesson
  • Singleton + mutable state + testing = inevitable flakiness.
  • If you need global state, make it read-only or inject it through DI containers.
  • Always verify thread-safety and lifecycle isolation when using Singleton in test-heavy environments.
Production debug guideHow to spot when a pattern is doing more harm than good4 entries
Symptom · 01
Random test failures that disappear when run alone
Fix
Check for Singleton or static mutable state. Run jmap -histo and look for unexpected instances of your pattern classes.
Symptom · 02
Adding a new payment method requires touching five files
Fix
That's an if-else chain screaming for Strategy. Look for long switch statements or if-else blocks over 20 lines.
Symptom · 03
A legacy system upgrade requires rewriting half the client code
Fix
Missing Adapter or Facade. Check if the client directly calls the old system's classes.
Symptom · 04
Memory grows steadily over time, GC can't keep up
Fix
Observer pattern without unsubscribe. Search for 'addListener' or 'registerObserver' and verify removal on cleanup.
★ Pattern Selection Quick-CheckWhen you're staring at a problem and don't know which pattern fits, run through this cheat sheet.
Constructor has 8+ parameters, most optional
Immediate action
Stop writing 8-param constructors.
Commands
grep -rn 'new \(.*\(' src/ | head -20
Identify if Builder pattern applies.
Fix now
Replace with Builder: new MyObjectBuilder().withX(x).withY(y).build()
Switching behavior requires editing existing classes+
Immediate action
Look for if-else or switch-case on type codes.
Commands
grep -rn 'if.*instanceof' src/
Check if the condition is algorithmic logic.
Fix now
Extract each branch into its own Strategy implementation.
A third-party library has a completely different method signature than your app expects+
Immediate action
Don't modify the library or all callers.
Commands
grep -rn 'someLibrary\.' src/
Design an Adapter interface that your code already uses.
Fix now
Create an Adapter class that wraps the library and implements your target interface.
Pattern Family Comparison
AspectCreationalStructuralBehavioral
Core QuestionHow are objects created?How are classes/objects composed?How do objects communicate?
Key Problem SolvedDecouples creation from usagePrevents rigid coupling between classesPrevents god objects and tangled control flow
Common PatternsSingleton, Builder, Factory, PrototypeAdapter, Decorator, Facade, CompositeStrategy, Observer, Command, Iterator
Real-World TriggerConstructor logic spreading everywhereIncompatible interfaces, complex subsystemsBig if-else chains, objects doing too much
Java Standard Library ExampleCalendar.getInstance()BufferedReader(FileReader)Iterator on Collections, ActionListener
Risk of OveruseHidden global state (Singleton)Too many layers of wrappers (Decorator)Memory leaks from forgotten listeners (Observer)

Key takeaways

1
Patterns are solutions to recurring design problems
not prescriptions; apply them when you can name the problem they solve, not before.
2
Creational patterns decouple object creation from usage; Structural patterns manage composition; Behavioral patterns manage communication
knowing which family to reach for is half the battle.
3
The Strategy pattern is one of the highest-ROI patterns to learn first
it directly implements Open/Closed Principle and eliminates fragile if-else chains in business logic.
4
Observer enables decoupled event-driven architectures but requires explicit teardown
a missing removeObserver call is one of the most common sources of Java memory leaks in production.
5
Factory Method and Adapter are your go-to patterns for integrating external systems without coupling your core code to third-party APIs.
6
Over-engineering with patterns is more harmful than no patterns at all. Use the pain threshold test
if the pattern doesn't solve an active pain, don't force it.

Common mistakes to avoid

3 patterns
×

Pattern-Fitting — forcing a pattern onto a problem it doesn't actually solve

Symptom
Extra abstraction that adds complexity with no benefit; code harder to read than the original; team spends more time explaining the pattern than solving the problem.
Fix
Choose a pattern by identifying the problem first (creation, composition, or communication), then check if a pattern addresses it. If explaining the pattern takes longer than explaining the problem, skip it.
×

Making Singleton a dumping ground for global state

Symptom
Untestable code, mysterious state mutations between test runs, classes that secretly depend on each other through the Singleton.
Fix
Anything stored in a Singleton should be read-only configuration or a stateless service. Mutable state shared globally is a concurrency bug waiting to happen — use dependency injection instead.
×

Conflating Observer with event buses — no teardown

Symptom
Memory leaks: listeners stay alive long after their UI component or service was destroyed; performance degrades over time.
Fix
Always implement an unsubscribe or removeObserver method and call it in teardown/cleanup logic. In Java, use weak references or explicit lifecycle management (e.g., Spring's @PreDestroy).
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Can you explain the difference between the Factory Method pattern and th...
Q02SENIOR
Walk me through how you'd use the Strategy pattern to replace a complex ...
Q03SENIOR
You're told a codebase uses the Singleton pattern extensively. What alar...
Q04SENIOR
When would you choose the Decorator pattern over inheritance? Give a con...
Q05JUNIOR
Explain the Observer pattern's role in event-driven architectures. How d...
Q01 of 05SENIOR

Can you explain the difference between the Factory Method pattern and the Abstract Factory pattern — and give me a scenario where you'd pick one over the other?

ANSWER
Factory Method lets a subclass decide which concrete class to instantiate. Abstract Factory provides an interface for creating families of related objects without specifying their concrete classes. Use Factory Method when you have one product hierarchy (e.g., documents: PDF, Word). Use Abstract Factory when you have multiple product families that must be used together (e.g., UI toolkit: WindowsButton + WindowsScrollbar vs MacButton + MacScrollbar). The Abstract Factory often uses Factory Methods internally.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Do I need to memorize all 23 Gang of Four design patterns?
02
Are design patterns language-specific?
03
How do I know which design pattern to use for a given problem?
04
What's the biggest anti-pattern when using design patterns?
05
Can I use multiple patterns together?
🔥

That's Software Engineering. Mark it forged?

5 min read · try the examples if you haven't

Previous
SOLID Principles
4 / 16 · Software Engineering
Next
Clean Code Principles