Design Patterns in Java: Build Better Software Like Building with LEGO
A beginner-friendly guide to six essential Java design patterns — Strategy, Factory, Builder, Observer, Decorator, and Template Method — with real-world analogies, working code examples, and clear guidance on when to use each one.
Why Design Patterns Matter
Imagine you are building a house with LEGO bricks. You could throw bricks together randomly and hope it works. Or you could follow proven blueprints that thousands of builders have already tested. Design patterns are those blueprints for software.
A design pattern is a reusable solution to a common problem. You do not copy-paste code from a pattern. Instead, you learn the idea and adapt it to your situation. Think of it like a recipe: the recipe tells you the steps, but you choose your own ingredients.
In this guide, we will cover six essential patterns that every Java developer should know. For each one, you will learn: what problem it solves, a simple real-world analogy, working Java code, and when to use it.
1. Strategy Pattern — Choose Your Method at Runtime
The Problem
You have a task that can be done in multiple ways. For example, a customer can pay using a credit card, PayPal, or bank transfer. If you use if-else chains to handle each method, your code becomes messy. Every time you add a new payment method, you have to open the same class and add another branch. This breaks the Open/Closed Principle — classes should be open for extension but closed for modification.
Real-World Analogy
Think about traveling from home to school. You can take a car, a bus, or a train. The destination is the same, but the method of getting there is different. You pick the strategy based on the situation: if it is raining, you take a car; if you want to save money, you take the bus. The Strategy pattern works the same way — same goal, different approaches, chosen at runtime.
Java Code
Step 1: Define the strategy interface.
public interface PaymentStrategy {
void pay(double amount);
}
Step 2: Create concrete strategies — one class per payment method.
public class CreditCardPayment implements PaymentStrategy {
private final String cardNumber;
public CreditCardPayment(String cardNumber) {
this.cardNumber = cardNumber;
}
@Override
public void pay(double amount) {
System.out.println("Paid $" + amount + " using Credit Card ending in "
+ cardNumber.substring(cardNumber.length() - 4));
}
}
public class PayPalPayment implements PaymentStrategy {
private final String email;
public PayPalPayment(String email) {
this.email = email;
}
@Override
public void pay(double amount) {
System.out.println("Paid $" + amount + " using PayPal account " + email);
}
}
public class BankTransferPayment implements PaymentStrategy {
private final String bankAccount;
public BankTransferPayment(String bankAccount) {
this.bankAccount = bankAccount;
}
@Override
public void pay(double amount) {
System.out.println("Paid $" + amount + " via Bank Transfer to account " + bankAccount);
}
}
Step 3: Use the strategy in a context class.
public class PaymentProcessor {
private PaymentStrategy strategy;
public void setStrategy(PaymentStrategy strategy) {
this.strategy = strategy;
}
public void processPayment(double amount) {
if (strategy == null) {
throw new IllegalStateException("Payment strategy not set!");
}
strategy.pay(amount);
}
}
// Usage
PaymentProcessor processor = new PaymentProcessor();
processor.setStrategy(new CreditCardPayment("4111111111111234"));
processor.processPayment(99.99);
// Output: Paid $99.99 using Credit Card ending in 1234
processor.setStrategy(new PayPalPayment("user@example.com"));
processor.processPayment(49.50);
// Output: Paid $49.50 using PayPal account user@example.com
processor.setStrategy(new BankTransferPayment("ACC-78901"));
processor.processPayment(250.00);
// Output: Paid $250.0 via Bank Transfer to account ACC-78901
When to Use Strategy
Use it when: You have multiple algorithms for the same task and want to switch between them without changing the class that uses them. Common examples: payment methods, sorting algorithms, compression formats, validation rules.
Avoid it when: You only have one or two simple variations. A simple if-else is fine when there are only two options that will never grow.
2. Factory Pattern — Create Objects Without Knowing the Exact Class
The Problem
Your code needs to create different types of objects based on some input. For example, you want to send a notification, but sometimes it is an email, sometimes an SMS, and sometimes a push notification. If the calling code uses new EmailNotification() directly, it becomes tightly coupled to that specific class. Adding a new notification type means changing every place that creates notifications.
Real-World Analogy
Think about ordering food at a restaurant. You tell the waiter "I want a pizza." You do not walk into the kitchen and make it yourself. The kitchen (factory) decides how to make it — which chef, which oven, which ingredients. You just get your pizza. The Factory pattern works the same way: you ask for what you need, and the factory figures out how to create it.
Java Code
Step 1: Define the product interface.
public interface Notification {
void send(String recipient, String message);
}
Step 2: Create concrete products.
public class EmailNotification implements Notification {
@Override
public void send(String recipient, String message) {
System.out.println("EMAIL to " + recipient + ": " + message);
}
}
public class SmsNotification implements Notification {
@Override
public void send(String recipient, String message) {
System.out.println("SMS to " + recipient + ": " + message);
}
}
public class PushNotification implements Notification {
@Override
public void send(String recipient, String message) {
System.out.println("PUSH to " + recipient + ": " + message);
}
}
Step 3: Create the factory.
public class NotificationFactory {
public static Notification create(String type) {
return switch (type.toLowerCase()) {
case "email" -> new EmailNotification();
case "sms" -> new SmsNotification();
case "push" -> new PushNotification();
default -> throw new IllegalArgumentException(
"Unknown notification type: " + type);
};
}
}
// Usage
Notification alert = NotificationFactory.create("email");
alert.send("user@example.com", "Your order has shipped!");
// Output: EMAIL to user@example.com: Your order has shipped!
Notification reminder = NotificationFactory.create("sms");
reminder.send("+1234567890", "Your appointment is tomorrow.");
// Output: SMS to +1234567890: Your appointment is tomorrow.
Notification promo = NotificationFactory.create("push");
promo.send("device-token-abc", "New features available!");
// Output: PUSH to device-token-abc: New features available!
When to Use Factory
Use it when: You need to create objects from a family of related classes and the exact type depends on input or configuration. Common examples: notification channels, database drivers, document parsers, UI components.
Avoid it when: You always create the same type. A factory with one product is over-engineering.
3. Builder Pattern — Construct Complex Objects Step by Step
The Problem
Some objects have many fields. A User might have a name, email, phone, address, age, and role. If you use a constructor with 10 parameters, it becomes impossible to remember the order. Which one is the phone number? Which one is the email? This is called the "telescoping constructor" problem.
Real-World Analogy
Think about ordering a custom burger. You do not shout all 8 toppings at once. Instead, you build it step by step: "I want a beef patty, add lettuce, add tomato, add cheese, no onions, extra pickles." The Builder pattern works the same way — you set each field one at a time, in any order, and then call build() to get the final object.
Java Code — Manual Builder
public class User {
private final String name;
private final String email;
private final String phone;
private final int age;
private final String role;
private User(Builder builder) {
this.name = builder.name;
this.email = builder.email;
this.phone = builder.phone;
this.age = builder.age;
this.role = builder.role;
}
// Getters...
public static class Builder {
private final String name; // required
private String email;
private String phone;
private int age;
private String role = "USER"; // default value
public Builder(String name) {
this.name = name;
}
public Builder email(String email) {
this.email = email;
return this;
}
public Builder phone(String phone) {
this.phone = phone;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
public Builder role(String role) {
this.role = role;
return this;
}
public User build() {
return new User(this);
}
}
}
// Usage — readable, flexible, any order
User admin = new User.Builder("Alice")
.email("alice@example.com")
.role("ADMIN")
.age(30)
.build();
User guest = new User.Builder("Bob")
.email("bob@example.com")
.build(); // phone, age use defaults
Shortcut: Lombok @Builder
Writing a builder by hand is tedious. Lombok generates it for you with one annotation:
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class Order {
private final String orderId;
private final String customerName;
private final double totalAmount;
@Builder.Default
private final String status = "PENDING";
private final String shippingAddress;
}
// Usage — identical fluent API, zero boilerplate
Order order = Order.builder()
.orderId("ORD-001")
.customerName("Charlie")
.totalAmount(149.99)
.shippingAddress("123 Main St")
.build();
// status defaults to "PENDING"
Java Records + Compact Constructors
For simple data carriers, Java 16+ records give you immutability for free:
public record Product(String name, double price, String category) {
// Compact constructor for validation
public Product {
if (price < 0) throw new IllegalArgumentException("Price cannot be negative");
name = name.trim();
}
}
Product item = new Product("Laptop", 999.99, "Electronics");
System.out.println(item.name()); // "Laptop" — auto-generated accessor
When to Use Builder
Use it when: Objects have more than 4 or 5 fields, or have many optional fields. Also useful when you want immutable objects. Common examples: configuration objects, HTTP requests, database queries, report parameters.
Avoid it when: The object has only 2-3 fields. A simple constructor or a record is cleaner.
4. Observer / Event Pattern — Notify When Something Happens
The Problem
When something important happens in your system (like an order being placed), multiple parts need to react: send a confirmation email, update inventory, log the event, notify the warehouse. If the order code directly calls all of these, it becomes tightly coupled to every listener. Adding a new reaction means changing the order code.
Real-World Analogy
Think about a YouTube channel. When a creator uploads a new video, every subscriber gets a notification. The creator does not personally call each subscriber. Instead, YouTube's system handles it: "Something happened (new video) — tell everyone who signed up." The Observer pattern works the same way: publish an event, and all registered listeners react independently.
Java Code — Spring Events
Step 1: Define the event.
public class OrderPlacedEvent {
private final String orderId;
private final String customerEmail;
private final double totalAmount;
public OrderPlacedEvent(String orderId, String customerEmail, double totalAmount) {
this.orderId = orderId;
this.customerEmail = customerEmail;
this.totalAmount = totalAmount;
}
// Getters
public String getOrderId() { return orderId; }
public String getCustomerEmail() { return customerEmail; }
public double getTotalAmount() { return totalAmount; }
}
Step 2: Publish the event from the service.
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
public OrderService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void placeOrder(String orderId, String email, double total) {
// Save order to database...
System.out.println("Order " + orderId + " saved to database.");
// Publish event — OrderService does NOT know who listens
eventPublisher.publishEvent(new OrderPlacedEvent(orderId, email, total));
}
}
Step 3: Create listeners — each reacts independently.
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class EmailListener {
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
System.out.println("Sending confirmation email to " + event.getCustomerEmail()
+ " for order " + event.getOrderId());
}
}
@Component
public class InventoryListener {
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
System.out.println("Updating inventory for order " + event.getOrderId());
}
}
@Component
public class AnalyticsListener {
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
System.out.println("Logging analytics: order " + event.getOrderId()
+ " total $" + event.getTotalAmount());
}
}
// When placeOrder() is called, ALL three listeners fire automatically:
// Output:
// Order ORD-42 saved to database.
// Sending confirmation email to user@example.com for order ORD-42
// Updating inventory for order ORD-42
// Logging analytics: order ORD-42 total $149.99
Bonus: Async Events
Add @Async to run listeners in separate threads — the order service does not wait for emails or analytics:
@Component
public class EmailListener {
@Async
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
// Runs in a separate thread — does not block the order flow
sendEmail(event.getCustomerEmail(), "Order Confirmed: " + event.getOrderId());
}
}
When to Use Observer/Event
Use it when: One action triggers multiple side effects that should be decoupled. Common examples: order placed (email + inventory + analytics), user registered (welcome email + default settings), payment received (receipt + ledger update).
Avoid it when: There is only one reaction to an event. A direct method call is simpler than event infrastructure.
5. Decorator Pattern — Add Features Without Changing the Original
The Problem
You have a working class, and you want to add extra behavior — like logging, caching, or retry logic — without modifying the original code. If you edit the original class, you risk breaking it. If you create subclasses for every combination (LoggingService, CachingService, LoggingCachingService), you get a class explosion.
Real-World Analogy
Think about decorating a plain donut. The donut itself is the core object. You can add chocolate glaze (one decorator), then sprinkles (another decorator), then whipped cream (a third). Each topping wraps the previous one. You can mix and match toppings without changing the donut recipe. The Decorator pattern works the same way: wrap an object with extra behavior, layer by layer.
Java Code
Step 1: Define the base interface.
public interface DataService {
String fetchData(String key);
}
Step 2: Create the core implementation.
public class DatabaseService implements DataService {
@Override
public String fetchData(String key) {
// Simulate database lookup
System.out.println(" [DB] Querying database for key: " + key);
return "data-for-" + key;
}
}
Step 3: Create decorators — each wraps the original and adds behavior.
// Logging Decorator — logs before and after
public class LoggingDecorator implements DataService {
private final DataService wrapped;
public LoggingDecorator(DataService wrapped) {
this.wrapped = wrapped;
}
@Override
public String fetchData(String key) {
System.out.println("[LOG] Fetching data for key: " + key);
String result = wrapped.fetchData(key);
System.out.println("[LOG] Result: " + result);
return result;
}
}
// Caching Decorator — returns cached value if available
public class CachingDecorator implements DataService {
private final DataService wrapped;
private final Map<String, String> cache = new HashMap<>();
public CachingDecorator(DataService wrapped) {
this.wrapped = wrapped;
}
@Override
public String fetchData(String key) {
if (cache.containsKey(key)) {
System.out.println("[CACHE] Hit for key: " + key);
return cache.get(key);
}
System.out.println("[CACHE] Miss for key: " + key);
String result = wrapped.fetchData(key);
cache.put(key, result);
return result;
}
}
Step 4: Stack decorators like layers.
// Base service
DataService service = new DatabaseService();
// Wrap with caching
service = new CachingDecorator(service);
// Wrap with logging on top
service = new LoggingDecorator(service);
// First call — cache miss, hits database
service.fetchData("user-42");
// Output:
// [LOG] Fetching data for key: user-42
// [CACHE] Miss for key: user-42
// [DB] Querying database for key: user-42
// [LOG] Result: data-for-user-42
// Second call — cache hit, skips database
service.fetchData("user-42");
// Output:
// [LOG] Fetching data for key: user-42
// [CACHE] Hit for key: user-42
// [LOG] Result: data-for-user-42
When to Use Decorator
Use it when: You want to add behavior (logging, caching, retries, metrics, auth checks) without modifying the original class. Common in middleware stacks, I/O streams (Java's own BufferedReader wrapping FileReader), and Spring AOP proxies.
Avoid it when: You only ever need one combination. A single class with the behavior built in is simpler.
6. Template Method Pattern — Define the Steps, Let Subclasses Fill in the Details
The Problem
You have several classes that follow the same overall process but differ in specific steps. For example, exporting data always involves: (1) fetch the data, (2) format it, (3) write to a file. But formatting is different for CSV, PDF, and Excel. If each class implements the entire process from scratch, you duplicate the shared logic.
Real-World Analogy
Think about a recipe template for making sandwiches. Every sandwich follows the same steps: (1) pick bread, (2) add filling, (3) add toppings, (4) wrap it up. A turkey sandwich and a veggie sandwich follow the same template, but the filling step is different. The Template Method pattern defines the shared skeleton and lets each variant fill in the specific steps.
Java Code
Step 1: Define the abstract template class.
public abstract class DataExporter {
// Template method — defines the algorithm skeleton
// "final" so subclasses cannot change the order of steps
public final void export(String reportName) {
List<Map<String, Object>> data = fetchData(reportName);
String formatted = formatData(data);
writeToFile(reportName, formatted);
System.out.println("Export complete: " + reportName);
}
// Shared step — same for all exporters
private List<Map<String, Object>> fetchData(String reportName) {
System.out.println("Fetching data for report: " + reportName);
// Simulate fetching from database
return List.of(
Map.of("name", "Alice", "score", 95),
Map.of("name", "Bob", "score", 87),
Map.of("name", "Charlie", "score", 92)
);
}
// Abstract step — each subclass implements differently
protected abstract String formatData(List<Map<String, Object>> data);
// Shared step — same for all exporters
private void writeToFile(String reportName, String content) {
System.out.println("Writing " + content.length() + " characters to file: "
+ reportName);
// Simulate file writing
}
}
Step 2: Create concrete implementations for each format.
public class CsvExporter extends DataExporter {
@Override
protected String formatData(List<Map<String, Object>> data) {
StringBuilder sb = new StringBuilder("name,score\n");
for (var row : data) {
sb.append(row.get("name")).append(",").append(row.get("score")).append("\n");
}
return sb.toString();
}
}
public class PdfExporter extends DataExporter {
@Override
protected String formatData(List<Map<String, Object>> data) {
StringBuilder sb = new StringBuilder("[PDF Document]\n");
sb.append("=== Report ===\n");
for (var row : data) {
sb.append(" ").append(row.get("name"))
.append(": ").append(row.get("score")).append("\n");
}
return sb.toString();
}
}
public class ExcelExporter extends DataExporter {
@Override
protected String formatData(List<Map<String, Object>> data) {
StringBuilder sb = new StringBuilder("[Excel Workbook]\n");
sb.append("| Name | Score |\n");
for (var row : data) {
sb.append("| ").append(String.format("%-7s", row.get("name")))
.append(" | ").append(String.format("%-5s", row.get("score")))
.append(" |\n");
}
return sb.toString();
}
}
Step 3: Use it — the calling code does not know or care about the format.
DataExporter csvExporter = new CsvExporter();
csvExporter.export("monthly-scores.csv");
// Output:
// Fetching data for report: monthly-scores.csv
// Writing 39 characters to file: monthly-scores.csv
// Export complete: monthly-scores.csv
DataExporter pdfExporter = new PdfExporter();
pdfExporter.export("monthly-scores.pdf");
DataExporter excelExporter = new ExcelExporter();
excelExporter.export("monthly-scores.xlsx");
When to Use Template Method
Use it when: Multiple classes share the same algorithm structure but differ in certain steps. Common examples: data export (CSV/PDF/Excel), data import (parse different file formats), authentication flows (OAuth/SAML/LDAP), report generation.
Avoid it when: The algorithm steps are completely different between classes. In that case, Strategy pattern is a better fit.
Quick Comparison: Which Pattern Solves What?
| Pattern | Problem it Solves | Key Idea |
|---|---|---|
| Strategy | Multiple algorithms for one task | Swap behavior at runtime via interface |
| Factory | Object creation depends on input | Centralize creation logic in one place |
| Builder | Complex objects with many fields | Build step by step, then finalize |
| Observer | One event triggers many reactions | Publish event, listeners react independently |
| Decorator | Add behavior without modifying original | Wrap objects in layers of functionality |
| Template Method | Same structure, different details | Abstract class defines skeleton, subclasses fill in steps |
Key Takeaways
Start simple. Do not force patterns into your code. Write the straightforward solution first. When you notice duplication, rigidity, or messy conditionals, then reach for a pattern.
Patterns are about structure, not code. The code examples above are templates. In real projects, you will adapt them. A Strategy might use lambdas instead of classes. A Factory might use a registry map instead of a switch statement.
Learn to recognize the problem, not just the solution. When you see a chain of if-else picking an algorithm, think Strategy. When you see new SomeClass() scattered everywhere, think Factory. When you see a constructor with 10 parameters, think Builder.
Combine patterns. Real applications use multiple patterns together. A Factory might create Strategy objects. A Builder might construct objects that fire Observer events. Patterns are building blocks — mix them as needed.
Frequently Asked Questions
What is the difference between Strategy pattern and Factory pattern?
Strategy is about choosing behavior — you pick how to do something (pay with credit card vs. PayPal). Factory is about choosing creation — you pick what to create (email notification vs. SMS notification). Strategy swaps algorithms; Factory swaps object types. Think of it this way: Strategy decides HOW, Factory decides WHAT.
When should I use the Builder pattern instead of a constructor?
Use Builder when your object has more than 4 or 5 parameters, especially if many are optional. If you find yourself writing new User(null, null, "Alice", null, 25, null, "ADMIN") with lots of nulls, that is a clear sign you need a Builder. Also use Builder when you want immutable objects (all fields final) but need flexibility during construction.
Can I use design patterns with Spring Boot?
Yes, and Spring Boot actually uses many patterns internally. @EventListener is the Observer pattern. Spring's BeanFactory is the Factory pattern. @Qualifier with dependency injection achieves what Strategy pattern does. HandlerInterceptor chain is the Decorator pattern. Learning these patterns helps you understand Spring Boot at a deeper level.
Are design patterns still relevant with modern Java features like lambdas and records?
Absolutely, but the implementation changes. The Strategy pattern, which used to require a full interface plus multiple classes, can now be a simple lambda: processor.setStrategy(amount -> System.out.println("Paid " + amount)). Records replace many Builder use cases for simple data holders. The patterns are still relevant as concepts — the code just becomes more concise with modern Java.
How do I know which design pattern to use for my problem?
Start by identifying the pain point. If you have multiple if-else branches choosing an algorithm, use Strategy. If you are creating different object types based on input, use Factory. If a constructor has too many parameters, use Builder. If one action needs to trigger many side effects, use Observer. If you want to add behavior like logging or caching without touching existing code, use Decorator. If classes share the same process but differ in specific steps, use Template Method. The key is to match the problem, not force the pattern.