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 resourcepublicclassDatabaseConnectionPool {
// volatile ensures the instance is correctly visible across threadsprivatestaticvolatileDatabaseConnectionPool instance;
privatefinalString databaseUrl;
privateint activeConnections;
// Private constructor prevents anyone from calling 'new DatabaseConnectionPool()'privateDatabaseConnectionPool(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 callpublicstaticDatabaseConnectionPoolgetInstance(String databaseUrl) {
if (instance == null) { // First check (no lock)synchronized (DatabaseConnectionPool.class) {
if (instance == null) { // Second check (with lock)
instance = newDatabaseConnectionPool(databaseUrl);
}
}
}
return instance;
}
publicvoidborrowConnection() {
activeConnections++;
System.out.println("Connection borrowed. Active: " + activeConnections);
}
publicvoidreturnConnection() {
if (activeConnections > 0) activeConnections--;
System.out.println("Connection returned. Active: " + activeConnections);
}
// --- Builder Pattern — for objects with many optional configuration fields ---publicstaticclassReportBuilder {
// Required fieldprivatefinalString reportTitle;
// Optional fields with sensible defaultsprivateString dateRange = "Last 30 days";
privateboolean includePdf = false;
privateint maxRows = 100;
publicReportBuilder(String reportTitle) {
this.reportTitle = reportTitle;
}
// Each setter returns 'this' so calls can be chained fluentlypublicReportBuilderwithDateRange(String dateRange) {
this.dateRange = dateRange;
returnthis;
}
publicReportBuilderwithPdfExport(boolean includePdf) {
this.includePdf = includePdf;
returnthis;
}
publicReportBuilderwithMaxRows(int maxRows) {
this.maxRows = maxRows;
returnthis;
}
publicStringbuild() {
// In real code this would return a Report objectreturnString.format(
"Report[title=%s, range=%s, pdf=%b, maxRows=%d]",
reportTitle, dateRange, includePdf, maxRows
);
}
}
publicstaticvoidmain(String[] args) {
// Both calls return the exact same object — only one pool is ever createdDatabaseConnectionPool 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 nightmareString report = newReportBuilder("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) ---classEmailService {
publicvoidsendEmail(String recipient, String subject, String body) {
System.out.println("[EMAIL] To: " + recipient + " | Subject: " + subject);
}
}
// --- Subsystem 2: SMS gateway ---classSmsGateway {
publicvoidsendSms(String phoneNumber, String message) {
System.out.println("[SMS] To: " + phoneNumber + " | Msg: " + message);
}
}
// --- Subsystem 3: Push notification service ---classPushNotificationService {
publicvoidpushToDevice(String deviceToken, String payload) {
System.out.println("[PUSH] Token: " + deviceToken + " | Payload: " + payload);
}
}
// --- Adapter Pattern embedded: normalize an incompatible legacy alerter ---interfaceModernAlerter {
voidalert(String userId, String message);
}
classLegacyAlertSystem {
// Old API — completely different signature, can't touch this codepublicvoidtriggerAlertForEmployee(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 interfaceclassLegacyAlertAdapterimplementsModernAlerter {
privatefinalLegacyAlertSystem legacySystem;
publicLegacyAlertAdapter(LegacyAlertSystem legacySystem) {
this.legacySystem = legacySystem;
}
@Overridepublicvoidalert(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 ---classNotificationFacade {
privatefinalEmailService emailService;
privatefinalSmsGateway smsGateway;
privatefinalPushNotificationService pushService;
privatefinalModernAlerter alerter;
publicNotificationFacade() {
this.emailService = newEmailService();
this.smsGateway = newSmsGateway();
this.pushService = newPushNotificationService();
// Plugging in the adapted legacy system — callers never know it's legacythis.alerter = newLegacyAlertAdapter(newLegacyAlertSystem());
}
// Caller doesn't know about three subsystems — just calls this one methodpublicvoidnotifyUser(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 systemSystem.out.println("--- All notifications dispatched ---\n");
}
}
publicclassNotificationFacadeDemo {
publicstaticvoidmain(String[] args) {
NotificationFacade notifier = newNotificationFacade();
// 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 contractinterfaceDiscountStrategy {
doubleapply(double originalPrice);
Stringdescribe();
}
// Concrete Strategy 1: No discount (the default)classNoDiscountimplementsDiscountStrategy {
@Overridepublicdoubleapply(double originalPrice) {
return originalPrice; // Price unchanged
}
@OverridepublicStringdescribe() { return"No discount applied"; }
}
// Concrete Strategy 2: Percentage-based discount (e.g. summer sale)classPercentageDiscountimplementsDiscountStrategy {
private final double percentOff; // e.g. 20.0 means 20% offpublicPercentageDiscount(double percentOff) {
this.percentOff = percentOff;
}
@Overridepublicdoubleapply(double originalPrice) {
return originalPrice * (1 - percentOff / 100.0);
}
@OverridepublicStringdescribe() { return percentOff + "% off"; }
}
// Concrete Strategy 3: Flat amount off (e.g. $10 coupon code)classFlatAmountDiscountimplementsDiscountStrategy {
privatefinaldouble discountAmount;
publicFlatAmountDiscount(double discountAmount) {
this.discountAmount = discountAmount;
}
@Overridepublicdoubleapply(double originalPrice) {
// Guard: price can't go below zeroreturnMath.max(0, originalPrice - discountAmount);
}
@OverridepublicStringdescribe() { return"$" + discountAmount + " flat off"; }
}
// Observer Pattern — notify multiple listeners when cart total changesinterfaceCartObserver {
voidonTotalChanged(double newTotal);
}
// Context class: the ShoppingCart holds a strategy and notifies observersclassShoppingCart {
privatefinalList<Double> itemPrices = newArrayList<>();
privatefinalList<CartObserver> observers = newArrayList<>();
privateDiscountStrategy discountStrategy;
publicShoppingCart() {
this.discountStrategy = new NoDiscount(); // Sensible default
}
// Swap the discount strategy at any point without rebuilding the cartpublicvoidsetDiscountStrategy(DiscountStrategy strategy) {
this.discountStrategy = strategy;
System.out.println("Discount updated: " + strategy.describe());
notifyObservers(); // Immediately tell listeners about the pricing change
}
publicvoidaddItem(String itemName, double price) {
itemPrices.add(price);
System.out.println("Added: " + itemName + " ($" + price + ")");
notifyObservers();
}
publicvoidaddObserver(CartObserver observer) {
observers.add(observer);
}
privatevoidnotifyObservers() {
double rawTotal = itemPrices.stream().mapToDouble(Double::doubleValue).sum();
double discountedTotal = discountStrategy.apply(rawTotal);
// Every registered observer gets the updated total automaticallyfor (CartObserver observer : observers) {
observer.onTotalChanged(discountedTotal);
}
}
}
publicclassShoppingCartStrategy {
publicstaticvoidmain(String[] args) {
ShoppingCart cart = newShoppingCart();
// 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(newPercentageDiscount(20));
System.out.println("\n=== User applies $15 coupon code ===");
// Swapping strategy mid-session — observers are notified again automatically
cart.setDiscountStrategy(newFlatAmountDiscount(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 createabstractclassDocument {
publicabstractvoidopen();
publicabstractvoidsave();
}
classPDFDocumentextendsDocument {
@Overridepublicvoidopen() { System.out.println("Opening PDF..."); }
@Overridepublicvoidsave() { System.out.println("Saving PDF..."); }
}
classWordDocumentextendsDocument {
@Overridepublicvoidopen() { System.out.println("Opening Word document..."); }
@Overridepublicvoidsave() { System.out.println("Saving Word document..."); }
}
classSpreadsheetDocumentextendsDocument {
@Overridepublicvoidopen() { System.out.println("Opening spreadsheet..."); }
@Overridepublicvoidsave() { System.out.println("Saving spreadsheet..."); }
}
// The factory method is declared abstract in the creatorabstractclassDocumentCreator {
// This is the factory method — subclasses implement itprotectedabstractDocumentcreateDocument();
// Template method using the factory methodpublicvoidnewDocument() {
Document doc = createDocument();
doc.open();
// ... do some work ...
doc.save();
}
}
classPDFCreatorextendsDocumentCreator {
@OverrideprotectedDocumentcreateDocument() {
returnnewPDFDocument();
}
}
classWordCreatorextendsDocumentCreator {
@OverrideprotectedDocumentcreateDocument() {
returnnewWordDocument();
}
}
classSpreadsheetCreatorextendsDocumentCreator {
@OverrideprotectedDocumentcreateDocument() {
returnnewSpreadsheetDocument();
}
}
publicclassDocumentFactoryDemo {
publicstaticvoidmain(String[] args) {
// The client only knows about DocumentCreator and DocumentDocumentCreator creator = newPDFCreator();
creator.newDocument();
creator = newWordCreator();
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 expectsinterfaceModernPaymentProcessor {
booleancharge(String customerId, double amount);
StringgetTransactionId();
}
// Adaptee: legacy payment system with a different APIclassLegacyPaymentSystem {
// Returns a transaction ID as an integer, amount in centspublicintexecutePayment(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 APIclassLegacyPaymentAdapterimplementsModernPaymentProcessor {
privatefinalLegacyPaymentSystem legacySystem;
privateString lastTransactionId;
publicLegacyPaymentAdapter(LegacyPaymentSystem legacySystem) {
this.legacySystem = legacySystem;
}
@Overridepublicbooleancharge(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;
}
@OverridepublicStringgetTransactionId() {
return lastTransactionId;
}
}
publicclassPaymentAdapterDemo {
publicstaticvoidmain(String[] args) {
ModernPaymentProcessor processor = newLegacyPaymentAdapter(newLegacyPaymentSystem());
// Client code calls the modern interface — doesn't know it's talking to legacyboolean 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()
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
Aspect
Creational
Structural
Behavioral
Core Question
How are objects created?
How are classes/objects composed?
How do objects communicate?
Key Problem Solved
Decouples creation from usage
Prevents rigid coupling between classes
Prevents god objects and tangled control flow
Common Patterns
Singleton, Builder, Factory, Prototype
Adapter, Decorator, Facade, Composite
Strategy, Observer, Command, Iterator
Real-World Trigger
Constructor logic spreading everywhere
Incompatible interfaces, complex subsystems
Big if-else chains, objects doing too much
Java Standard Library Example
Calendar.getInstance()
BufferedReader(FileReader)
Iterator on Collections, ActionListener
Risk of Overuse
Hidden 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.
Q02 of 05SENIOR
Walk me through how you'd use the Strategy pattern to replace a complex payment-method if-else chain. What are the SOLID principles it satisfies and which does it risk violating if misapplied?
ANSWER
First, I'd define a PaymentStrategy interface with a pay(Order order) method. Then implement CreditCardStrategy, PayPalStrategy, ApplePayStrategy. The checkout class would accept a strategy via setter or constructor. This satisfies Open/Closed (new methods need a new class, not modification), Single Responsibility (each strategy focuses on one payment method), and Dependency Inversion (high-level module depends on abstraction). Risk: you can violate Interface Segregation if the strategy interface is too large. Violates Liskov if strategies have side effects the caller doesn't expect.
Q03 of 05SENIOR
You're told a codebase uses the Singleton pattern extensively. What alarm bells does that raise for you, and how would you refactor it to be more testable without rewriting every caller?
ANSWER
Red flags: tight coupling to concrete implementations, impossible to mock in unit tests, hidden global state causing flaky tests, no control over lifecycle. Refactoring plan: 1) Extract an interface from the Singleton class. 2) Replace getInstance() calls with dependency injection (constructor injection in new code). 3) Add a static factory that can return a mock instance during tests. 4) Gradually migrate callers to receive the interface via injection. This is a Strangler Fig approach — you don't rewrite everything at once.
Q04 of 05SENIOR
When would you choose the Decorator pattern over inheritance? Give a concrete Java example.
ANSWER
Use Decorator when you need to add responsibilities to individual objects dynamically, without affecting other objects of the same class. Inheritance would force you to create a subclass for every combination of features. Example: BufferedReader decorates FileReader — you add buffering capability without changing the reader class. Similarly, you could decorate a Coffee class with MilkDecorator, SugarDecorator — inheritance would create an explosion of subclasses (MilkCoffee, SugarCoffee, MilkSugarCoffee).
Q05 of 05JUNIOR
Explain the Observer pattern's role in event-driven architectures. How do you prevent memory leaks in Java?
ANSWER
Observer allows one-to-many notifications: a Subject maintains a list of Observers and notifies them on state changes. In Java, typical implementations include PropertyChangeSupport or custom interfaces. Memory leaks happen when an Observer is registered but never unregistered, preventing garbage collection of the observer. Fixes: use WeakReference (e.g., WeakHashMap), implement removeObserver() and call it in cleanup, or use lifecycle-aware frameworks (Spring's @EventListener with @PreDestroy).
01
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?
SENIOR
02
Walk me through how you'd use the Strategy pattern to replace a complex payment-method if-else chain. What are the SOLID principles it satisfies and which does it risk violating if misapplied?
SENIOR
03
You're told a codebase uses the Singleton pattern extensively. What alarm bells does that raise for you, and how would you refactor it to be more testable without rewriting every caller?
SENIOR
04
When would you choose the Decorator pattern over inheritance? Give a concrete Java example.
SENIOR
05
Explain the Observer pattern's role in event-driven architectures. How do you prevent memory leaks in Java?
JUNIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Do I need to memorize all 23 Gang of Four design patterns?
No — and trying to is counterproductive. Focus on mastering the seven most commonly used ones: Singleton, Builder, Factory, Adapter, Facade, Strategy, and Observer. These cover the vast majority of real-world scenarios and are the ones that come up in interviews and code reviews. Learn the rest by recognition — know they exist so you can look them up when the problem fits.
Was this helpful?
02
Are design patterns language-specific?
The concepts are language-agnostic, but implementations vary. In languages with first-class functions (Python, JavaScript, Kotlin), Strategy can be implemented with a simple lambda rather than a full interface hierarchy. In Java, you need explicit interfaces. The pattern is the idea; the code is just one way to express it in a given language's constraints.
Was this helpful?
03
How do I know which design pattern to use for a given problem?
Start by categorizing your problem: is it about how objects are created (Creational), how they're wired together (Structural), or how they interact and divide responsibility (Behavioral)? Then look for the specific symptom — a ballooning constructor means Builder, an incompatible third-party interface means Adapter, an if-else forest of algorithms means Strategy. Patterns are most recognizable by the pain they relieve, not by abstract definitions.
Was this helpful?
04
What's the biggest anti-pattern when using design patterns?
Pattern-forcing: applying a pattern because 'it should be here' rather than because the problem demands it. Real-world example: a team implemented Abstract Factory for a feature that had only one product family — they spent a week on abstraction with zero flexibility benefit. Always ask: does this pattern solve a problem I have today? If the answer is 'maybe later', don't do it.
Was this helpful?
05
Can I use multiple patterns together?
Absolutely. In fact, they often complement each other. A common combo: Factory Method creates objects, Strategy switches behavior on those objects, and Observer notifies other parts of the system about changes. The NotificationFacade example in this article uses both Facade and Adapter. The key is not to overcomplicate — each pattern should serve a clear purpose.