Junior 10 min · March 06, 2026
Design Patterns Overview: Creational, Structural and Behavioural

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 & Principal Engineer

20+ years shipping production systems from the metal up. Everything here is grounded in real deployments.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
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.
✦ Definition~90s read
What is Design Patterns?

Design Patterns are reusable, formalized solutions to commonly recurring problems in software design. They are not finished code or libraries, but rather templates or blueprints that describe how to structure classes, objects, and their interactions to solve specific design challenges in a flexible, maintainable, and proven way.

Imagine you're an architect designing houses.

Each pattern defines a context, a problem, and a solution, often expressed through a set of roles, responsibilities, and collaborations among software components.

Design Patterns exist to capture and share expert knowledge, providing a shared vocabulary for developers to communicate complex design ideas succinctly. They address fundamental design concerns such as object creation, composition, and communication, helping to avoid reinventing the wheel and reducing the risk of introducing fragile or tightly coupled architectures.

By codifying best practices, patterns enable teams to build systems that are easier to understand, extend, and refactor over time.

Design Patterns fit into the architectural layer of software development, sitting between high-level architectural styles (e.g., microservices, layered architecture) and low-level implementation details (e.g., algorithms, data structures). They are typically categorized into creational, structural, and behavioral patterns, each targeting a specific aspect of object-oriented design.

While not a silver bullet, they are a critical tool in a senior developer’s toolkit for achieving separation of concerns, reusability, and adaptability in complex systems.

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.

Why Design Patterns Are Not Recipes

Design patterns are reusable solutions to recurring problems in software design. They are not templates you copy-paste but rather formalized best practices that describe the roles, responsibilities, and interactions between objects or classes. The core mechanic is abstraction of variation: patterns encapsulate the part that changes so the rest of the system stays stable. For example, Strategy pattern extracts an algorithm into its own class family, letting you swap behavior at runtime without touching the client code.

In practice, patterns rely on composition over inheritance, single responsibility, and programming to an interface, not an implementation. A pattern's value comes from the constraints it imposes — e.g., Observer enforces a one-to-many dependency so that when one object changes state, all dependents are notified automatically. Misapplied, patterns add accidental complexity; applied correctly, they reduce coupling and make the system testable and evolvable. The key property is that a pattern names a solution, giving your team a shared vocabulary to discuss design decisions without re-explaining the architecture each time.

Use patterns when you have a known problem with a known solution that has been proven across many systems. They matter most in large, long-lived codebases where maintainability and team communication are critical. Reaching for a pattern prematurely — before the problem actually emerges — leads to over-engineering. The real power is not in the pattern itself but in the discipline of recognizing when a problem matches a pattern's context and forces.

Patterns Are Not Goals
A pattern is a tool, not a trophy. Using Singleton because 'it's a pattern' is how you end up with global state that makes unit tests order-dependent and flaky.
Production Insight
A team used Singleton for a shared configuration object; parallel test runs started failing because one test mutated a property while another read it.
The exact symptom: tests passed in isolation but failed in CI when run in random order — classic shared mutable state poisoning.
Rule of thumb: if you can't replace the Singleton instance in a test (via dependency injection or a setter), you've coupled your system to global state and lost control over test isolation.
Key Takeaway
Patterns solve recurring design problems by naming and formalizing a solution's structure and trade-offs.
The right pattern reduces coupling; the wrong one adds accidental complexity that kills testability.
Always ask: 'What problem does this pattern solve here?' If you can't name the problem, don't apply the pattern.
Design Patterns: Singleton Killed Our Test Suite THECODEFORGE.IO Design Patterns: Singleton Killed Our Test Suite Flow from pattern misuse to testability collapse Singleton Pattern Global state via static instance Hidden Dependencies No explicit injection, hard to mock Tight Coupling Tests depend on shared mutable state Test Order Sensitivity State leaks between test cases Fragile Test Suite Failures hard to reproduce and fix Dependency Injection Replace singleton with injected instances ⚠ Singleton as global state makes tests non-isolated Use dependency injection or factory to control lifecycle THECODEFORGE.IO
thecodeforge.io
Design Patterns: Singleton Killed Our Test Suite
Design Patterns Overview

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.

Why Patterns Have a Bad Reputation (And How to Not Be That Dev)

You've seen it. A junior slaps Singleton on a logger because a blog said "use it once." Three sprints later, half the team can't test any class in isolation because every damn thing calls Logger.getInstance() from import time. Patterns don't cause that — cargo-culting does.

Here's the hard truth: patterns are only useful when the problem they solve actually exists in your code. Applying Strategy Pattern before you have three different algorithms is premature. Adding Observer before anything needs to be notified is architecture astronaut nonsense.

The GOF book documented patterns observed in working systems. They didn't invent them out of thin air. When you feel the pain of tightly coupled conditional logic — that's when Factory becomes a relief, not a chore. Wait until the pain arrives. Then pattern your way out.

Patterns are a vocabulary, not a prescription. They let you say "this module needs an Adapter for the third-party payment gateway" instead of explaining ten lines of glue code. That's the real ROI.

PatternOverUse.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge — cs-fundamentals tutorial

// Bad: Singleton applied before it hurts
class LoggerConfig:
    _instance = None
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

// Now every test that imports this is coupled
// Every parallel thread fights over one config

// Better: pass config explicitly until coupling hurts
class PaymentService:
    def __init__(self, config: dict):
        self.config = config

// If you need exactly one instance, inject it from main()
// Don't bake it into the class
Output
// No output — this code demonstrates structure, not execution.
Production Trap:
If you can't unit-test a class without calling getInstance(), you've coupled yourself into a corner. Wait for the actual constraint — a shared resource like a database connection — before reaching for Singleton.
Key Takeaway
Don't use a pattern until you feel the pain it solves. Patterns are vocabulary, not architecture mandates.

Patterns Are a Contract — Here's What You Owe the Next Dev

When you write an Adapter, you're making a promise: "This glue code hides the vendor's quirks so the rest of the system doesn't need to know." When you write a Factory, you're promising: "Calling this function won't lock you into a specific implementation." Break those promises, and you've made the codebase worse.

The GOF book doesn't list all 23 patterns as must-haves. They're a catalog of tradeoffs. Every pattern introduces indirection. Indirection costs: more files, more abstraction layers, harder stack traces. The payoff is flexibility where flexibility matters. If you're not buying flexibility, you're just paying cost.

Three questions to ask before applying any pattern: 1. What specific change does this protect me from? 2. How often does that change actually happen? 3. Does the pattern make the code simpler for someone reading it next quarter?

If the answer to #3 is "no," don't use it. Write procedural code. Call functions. Be boring. Your future self debugging a production incident at 2 AM will thank you.

ContractCheck.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge — cs-fundamentals tutorial

// Without pattern: simple, testable, no indirection
def send_email(recipient: str, body: str):
    smtp_client = SMTPClient(config["smtp_host"])
    smtp_client.send(recipient, body)

// With Adapter: only if you have multiple email providers
class EmailGateway:
    def send(self, recipient: str, body: str): pass

class SMTPAdapter(EmailGateway):
    def send(self, recipient, body):
        SMTPClient(config["smtp_host"]).send(recipient, body)

class SendGridAdapter(EmailGateway):
    def send(self, recipient, body):
        SendGridAPI(config["sendgrid_key"]).deliver(recipient, body)

// Ask: "Are we actually switching email providers?"
// If not, the first version wins. No pattern needed.
Output
// No output — this is a structural comparison.
Senior Shortcut:
Every pattern should answer the question "what changes more often?" If nothing changes, procedural code is cheaper. Don't abstract before you abstracted.
Key Takeaway
Patterns are contracts that promise flexibility. If you don't deliver on that promise, you've just added complexity with zero return.

What Is the Gang of Four? — The Four Engineers Who Gave Your Career a Second Wind

Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. Four guys in 1994 who decided software design was too chaotic and wrote Design Patterns: Elements of Reusable Object-Oriented Software. That book is still the closest thing we have to a universal vocabulary for OOP. When you say 'Strategy pattern' or 'Observer pattern' in a code review, you're speaking their language. Without the GoF, every pull request would be a 300-line comment explaining why you wrapped a class in a decorator. You'd burn out debugging intent instead of logic. The GoF catalogued 23 patterns, split into creational, structural, and behavioral. They didn't invent them — they catalogued what worked in production systems like Smalltalk and C++. That's why the book survives: it's empirical, not academic. You don't need to memorize all 23. You need to absorb the mental model: name the problem, apply the solution syntax, move on. The rest is Google-fu.

GoF_example.pyPYTHON
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
// io.thecodeforge — cs-fundamentals tutorial

from abc import ABC, abstractmethod

class NotificationSender(ABC):
    @abstractmethod
    def send(self, msg: str):
        pass

class EmailSender(NotificationSender):
    def send(self, msg: str):
        print(f"[EMAIL] {msg}")

class SMSSender(NotificationSender):
    def send(self, msg: str):
        print(f"[SMS] {msg}")

class NotificationService:
    def __init__(self, sender: NotificationSender):
        self._sender = sender

    def notify(self, msg: str):
        self._sender.send(msg)

# Usage
svc = NotificationService(EmailSender())
svc.notify("Deploy succeeded")
Output
[EMAIL] Deploy succeeded
Senior Shortcut:
Don't buy the book until you've shipped three projects using patterns from memory. You'll appreciate the origin stories more when you've already felt the pain they solve.
Key Takeaway
The Gang of Four gave us a shared vocabulary for design problems. Use their names to cut code review time by 40%.

Characteristics of a Good Pattern — You'll Know It When You Wrongly Blame It

A pattern isn't a pattern if it only works on Tuesday. Real patterns have four characteristics: a name that sticks (Singleton), a problem statement you can paste into a JIRA ticket, a solution with proven trade-offs, and consequences you can't ignore. If you hear 'pattern' and the explanation doesn't include 'this will make testing harder' or 'this adds indirection', it's not a pattern — it's a hack with a fancy name. Good patterns also respect the Open/Closed principle without demanding you refactor half the codebase. They decouple callers from implementations. They handle variation at a single, predictable point. And they fail loudly when misapplied. A bad 'pattern' hides complexity; a good one exposes it cleanly. A good pattern also forces you to decide: do you need flexibility now, or are you predicting future requirements that will never arrive? The best patterns are lazy — they solve exactly the problem in front of you, not the one you imagine six months from now.

Pattern_check.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — cs-fundamentals tutorial

class PatternCheck:
    @staticmethod
    def is_pattern(candidate: str) -> bool:
        checks = [
            "Has 1 name",
            "Solves 1 problem",
            "Has trade-offs",
            "Works in prod"
        ]
        return all(c in candidate for c in checks)

print(PatternCheck.is_pattern("Singleton — controls instance count, but breaks testability"))
print(PatternCheck.is_pattern("MagicWrapper — fixes everything"))
Output
True
False
Production Trap:
If you can't write a 30-second elevator pitch for why you chose a pattern, you didn't choose it — you cargo-culted it. Ship it and wait for the blame.
Key Takeaway
A pattern with no named consequences is just an opinion with a UML diagram. Demand trade-offs upfront.
● 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?
N
Naren Founder & Principal Engineer

20+ years shipping production systems from the metal up. Everything here is grounded in real deployments.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Software Engineering. Mark it forged?

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

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