Design Patterns Explained: Creational, Structural & Behavioral Patterns with Real Java Examples
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.
// 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); } }
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]
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.
// 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!" ); } }
[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 ---
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.
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)); } }
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
| 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 |
🎯 Key Takeaways
- Patterns are solutions to recurring design problems — not prescriptions; apply them when you can name the problem they solve, not before.
- 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.
- 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.
- 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.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Pattern-Fitting — forcing a pattern onto a problem it doesn't actually solve — symptoms include extra abstraction that adds complexity with no benefit, code harder to read than the original — 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.
- ✕Mistake 2: Making Singleton a dumping ground for global state — symptoms include untestable code, mysterious state mutations between test runs, and 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.
- ✕Mistake 3: Conflating Observer with event buses — beginner Observer implementations add observers but never remove them — symptoms include memory leaks where listeners stay alive long after their UI component or service was destroyed — fix: always implement an unsubscribe or removeObserver method and call it in teardown/cleanup logic; in Java, weak references or explicit lifecycle management in frameworks like Spring prevent this entirely.
Interview Questions on This Topic
- QCan 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?
- QWalk 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?
- QYou'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?
Frequently Asked Questions
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.
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.
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.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.