- Understand the three categories of design patterns
- Master common creational patterns: Singleton, Factory, Builder
- Learn essential structural and behavioral patterns
Introduction to Design Patterns
As a developer, you'll hit the same problems repeatedly. How do I ensure only one instance exists? How do I create objects without specifying exact classes? How do I add functionality without modifying code?
Design patterns are proven solutions. Not code you copy-paste, but templates guiding your design. Once you know them, you can say "use a Factory" or "apply Observer" and everyone gets it.
What Are Design Patterns?
The "Gang of Four" book (1994) identified 23 patterns in three categories:
| Category | Purpose | Examples |
|---|---|---|
| Creational | How objects are created | Singleton, Factory, Builder |
| Structural | How objects are composed | Adapter, Decorator, Facade |
| Behavioral | How objects interact | Observer, Strategy, Command |
Each pattern has:
- A name (the vocabulary)
- A problem it solves
- A solution (the structure)
- Consequences (trade-offs)
Creational Patterns
Singleton Pattern
Problem: You need exactly one instance of a class - a database connection pool, a configuration manager, a logger.
Solution: Make the constructor private and provide a static method to get the single instance.
public class ConfigurationManager {
// The single instance
private static ConfigurationManager instance;
private Map<String, String> settings = new HashMap<>();
// Private constructor prevents direct instantiation
private ConfigurationManager() {
loadSettings();
}
// Global access point
public static ConfigurationManager getInstance() {
if (instance == null) {
instance = new ConfigurationManager();
}
return instance;
}
public String getSetting(String key) {
return settings.get(key);
}
private void loadSettings() {
// Load from file, database, etc.
}
}
// Usage - always get the same instance
ConfigurationManager config = ConfigurationManager.getInstance();
String dbUrl = config.getSetting("database.url");
Thread-safe version (for multi-threaded applications):
public class ConfigurationManager {
private static volatile ConfigurationManager instance;
private ConfigurationManager() { }
public static ConfigurationManager getInstance() {
if (instance == null) { // First check (no locking)
synchronized (ConfigurationManager.class) {
if (instance == null) { // Second check (with lock)
instance = new ConfigurationManager();
}
}
}
return instance;
}
}
Modern approach using enum (simplest and safest):
public enum Logger {
INSTANCE; // The single instance
public void log(String message) {
System.out.println(LocalDateTime.now() + ": " + message);
}
}
// Usage
Logger.INSTANCE.log("Application started");
Factory Pattern
Problem: You need to create objects, but you don't want to specify the exact class. Or you want to centralize object creation logic.
Solution: Define a method that creates and returns objects based on input parameters.
// Product interface
public interface Notification {
void send(String message);
}
// Concrete products
public class EmailNotification implements Notification {
@Override
public void send(String message) {
System.out.println("Sending email: " + message);
}
}
public class SMSNotification implements Notification {
@Override
public void send(String message) {
System.out.println("Sending SMS: " + message);
}
}
public class PushNotification implements Notification {
@Override
public void send(String message) {
System.out.println("Sending push notification: " + message);
}
}
// Factory
public class NotificationFactory {
public static Notification createNotification(String type) {
return switch (type.toLowerCase()) {
case "email" -> new EmailNotification();
case "sms" -> new SMSNotification();
case "push" -> new PushNotification();
default -> throw new IllegalArgumentException("Unknown type: " + type);
};
}
}
// Usage - client doesn't know concrete classes
Notification notif = NotificationFactory.createNotification("email");
notif.send("Hello, World!");
Why use Factory?
- Decouples object creation from usage
- Easy to add new types without changing client code
- Can return cached or shared instances
- Hides complex construction logic
Builder Pattern
Problem: You need to create complex objects with many optional parameters. Constructors become unwieldy.
Solution: Separate the construction process from the representation using a builder object.
public class HttpRequest {
private final String url; // Required
private final String method; // Required
private final Map<String, String> headers;
private final String body;
private final int timeout;
private final boolean followRedirects;
private HttpRequest(Builder builder) {
this.url = builder.url;
this.method = builder.method;
this.headers = builder.headers;
this.body = builder.body;
this.timeout = builder.timeout;
this.followRedirects = builder.followRedirects;
}
public static class Builder {
// Required parameters
private final String url;
private final String method;
// Optional parameters with defaults
private Map<String, String> headers = new HashMap<>();
private String body = "";
private int timeout = 30000;
private boolean followRedirects = true;
public Builder(String url, String method) {
this.url = url;
this.method = method;
}
public Builder header(String name, String value) {
headers.put(name, value);
return this;
}
public Builder body(String body) {
this.body = body;
return this;
}
public Builder timeout(int milliseconds) {
this.timeout = milliseconds;
return this;
}
public Builder followRedirects(boolean follow) {
this.followRedirects = follow;
return this;
}
public HttpRequest build() {
return new HttpRequest(this);
}
}
}
// Usage - clear, readable, flexible
HttpRequest request = new HttpRequest.Builder("https://api.example.com", "POST")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer token123")
.body("{\"name\": \"John\"}")
.timeout(5000)
.build();
Structural Patterns
Adapter Pattern
Problem: You have a class with one interface, but you need it to work with code expecting a different interface.
Solution: Create a wrapper class that translates between the interfaces.
// Existing class with its own interface
public class LegacyPrinter {
public void printDocument(String text) {
System.out.println("Printing: " + text);
}
}
// Interface your code expects
public interface ModernPrinter {
void print(Document document);
}
// Adapter makes LegacyPrinter work as ModernPrinter
public class PrinterAdapter implements ModernPrinter {
private LegacyPrinter legacyPrinter;
public PrinterAdapter(LegacyPrinter legacyPrinter) {
this.legacyPrinter = legacyPrinter;
}
@Override
public void print(Document document) {
// Translate the call
String text = document.getContent();
legacyPrinter.printDocument(text);
}
}
// Usage
LegacyPrinter legacy = new LegacyPrinter();
ModernPrinter printer = new PrinterAdapter(legacy);
printer.print(new Document("Hello")); // Works with your modern code
Decorator Pattern
Problem: You want to add functionality to objects without modifying their class.
Solution: Wrap objects in decorator classes that add behavior before/after delegating to the wrapped object.
// Base interface
public interface Coffee {
String getDescription();
double getCost();
}
// Basic implementation
public class SimpleCoffee implements Coffee {
@Override
public String getDescription() {
return "Simple coffee";
}
@Override
public double getCost() {
return 2.00;
}
}
// Base decorator
public abstract class CoffeeDecorator implements Coffee {
protected Coffee decoratedCoffee;
public CoffeeDecorator(Coffee coffee) {
this.decoratedCoffee = coffee;
}
}
// Concrete decorators
public class MilkDecorator extends CoffeeDecorator {
public MilkDecorator(Coffee coffee) {
super(coffee);
}
@Override
public String getDescription() {
return decoratedCoffee.getDescription() + ", with milk";
}
@Override
public double getCost() {
return decoratedCoffee.getCost() + 0.50;
}
}
public class SugarDecorator extends CoffeeDecorator {
public SugarDecorator(Coffee coffee) {
super(coffee);
}
@Override
public String getDescription() {
return decoratedCoffee.getDescription() + ", with sugar";
}
@Override
public double getCost() {
return decoratedCoffee.getCost() + 0.25;
}
}
// Usage - stack decorators
Coffee coffee = new SimpleCoffee();
coffee = new MilkDecorator(coffee);
coffee = new SugarDecorator(coffee);
System.out.println(coffee.getDescription()); // Simple coffee, with milk, with sugar
System.out.println(coffee.getCost()); // 2.75
Behavioral Patterns
Strategy Pattern
Problem: You have multiple algorithms/behaviors and want to select one at runtime.
Solution: Define a family of algorithms, encapsulate each one, and make them interchangeable.
// Strategy interface
public interface PaymentStrategy {
void pay(double amount);
}
// Concrete strategies
public class CreditCardPayment implements PaymentStrategy {
private String cardNumber;
public CreditCardPayment(String cardNumber) {
this.cardNumber = cardNumber;
}
@Override
public void pay(double amount) {
System.out.println("Paid $" + amount + " with credit card " +
cardNumber.substring(12));
}
}
public class PayPalPayment implements PaymentStrategy {
private String email;
public PayPalPayment(String email) {
this.email = email;
}
@Override
public void pay(double amount) {
System.out.println("Paid $" + amount + " via PayPal: " + email);
}
}
// Context class
public class ShoppingCart {
private PaymentStrategy paymentStrategy;
public void setPaymentStrategy(PaymentStrategy strategy) {
this.paymentStrategy = strategy;
}
public void checkout(double total) {
paymentStrategy.pay(total);
}
}
// Usage - switch strategies at runtime
ShoppingCart cart = new ShoppingCart();
cart.setPaymentStrategy(new CreditCardPayment("1234-5678-9012-3456"));
cart.checkout(100.00);
cart.setPaymentStrategy(new PayPalPayment("user@email.com"));
cart.checkout(50.00);
Observer Pattern
Problem: When one object changes, you need to notify multiple other objects automatically.
Solution: Define a subscription mechanism where observers register to be notified of changes.
// Observer interface
public interface Observer {
void update(String event, Object data);
}
// Subject (Observable)
public class NewsAgency {
private List<Observer> observers = new ArrayList<>();
private String latestNews;
public void addObserver(Observer observer) {
observers.add(observer);
}
public void removeObserver(Observer observer) {
observers.remove(observer);
}
public void setNews(String news) {
this.latestNews = news;
notifyObservers();
}
private void notifyObservers() {
for (Observer observer : observers) {
observer.update("news", latestNews);
}
}
}
// Concrete observers
public class EmailSubscriber implements Observer {
private String email;
public EmailSubscriber(String email) {
this.email = email;
}
@Override
public void update(String event, Object data) {
System.out.println("Sending email to " + email + ": " + data);
}
}
public class MobileSubscriber implements Observer {
private String phoneNumber;
public MobileSubscriber(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
@Override
public void update(String event, Object data) {
System.out.println("Sending SMS to " + phoneNumber + ": " + data);
}
}
// Usage
NewsAgency agency = new NewsAgency();
agency.addObserver(new EmailSubscriber("user@email.com"));
agency.addObserver(new MobileSubscriber("555-1234"));
agency.setNews("Breaking: Java 21 released!");
// Both subscribers are notified automatically
Choosing the Right Pattern
| Problem | Pattern | Key Benefit |
|---|---|---|
| Need exactly one instance | Singleton | Global access, controlled instantiation |
| Object creation varies | Factory | Decouples creation from usage |
| Many optional parameters | Builder | Readable, flexible construction |
| Incompatible interfaces | Adapter | Integrates existing code |
| Add behavior dynamically | Decorator | Flexible extension without inheritance |
| Interchangeable algorithms | Strategy | Runtime behavior selection |
| Automatic notifications | Observer | Loose coupling between objects |
Design patterns are solutions to recurring problems—not code templates. Learn the names so you can communicate ("use a Factory"). Don't force patterns where they're not needed. Understand the problem first. Patterns often work together (Factory creating Strategy objects). Modern Java features (lambdas, records) can simplify some patterns.
