Design Patterns in Java — Complete Guide
In this tutorial, you will learn about Design Patterns in Java. We cover key concepts, practical examples, and best practices to help you master this topic.
Why Design Patterns Matter
Design patterns are reusable solutions to common software design problems. They represent distilled best practices that have evolved over decades of software engineering. Patterns provide a shared vocabulary for developers, making it easier to communicate complex design concepts concisely. When you say "we should use a Strategy pattern here," other developers immediately understand the structure and intent of the solution.
The Gang of Four (GoF) book catalogued 23 patterns into three categories: creational, structural, and behavioral. While patterns are language-agnostic, Java's features (interfaces, abstract classes, generics, lambdas, records) make certain patterns particularly elegant. Modern Java idioms have also evolved some patterns: lambdas simplify Strategy and Command, records simplify Value Object, and enums simplify Singleton.
flowchart TB
subgraph Creational
Singleton[Singleton
One instance]
Factory[Factory Method
Object creation delegation]
Builder[Builder
Step-by-step construction]
end
subgraph Structural
Adapter[Adapter
Compatible interfaces]
Decorator[Decorator
Dynamic behavior addition]
Proxy[Proxy
Controlled access]
end
subgraph Behavioral
Strategy[Strategy
Interchangeable algorithms]
Observer[Observer
Event notification]
Template[Template Method
Algorithm skeleton]
end
Creational Patterns
Singleton
The Singleton ensures a class has only one instance and provides a global access point.
public class DatabaseConnectionPool {
private static final DatabaseConnectionPool INSTANCE =
new DatabaseConnectionPool();
private final HikariDataSource dataSource;
private DatabaseConnectionPool() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
config.setMaximumPoolSize(20);
this.dataSource = new HikariDataSource(config);
}
public static DatabaseConnectionPool getInstance() {
return INSTANCE;
}
public Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
}
For lazy initialization with thread safety:
public class LazySingleton {
private LazySingleton() {}
private static class Holder {
static final LazySingleton INSTANCE = new LazySingleton();
}
public static LazySingleton getInstance() {
return Holder.INSTANCE;
}
}
Factory Method
The Factory Method defines an interface for creating objects but lets subclasses decide which class to instantiate.
// Product interface
public interface PaymentProcessor {
PaymentResult process( Payment payment);
}
// Concrete products
public class CreditCardProcessor implements PaymentProcessor {
public PaymentResult process(Payment payment) {
// Process credit card payment
return new PaymentResult(true, "cc_txn_" + UUID.randomUUID());
}
}
public class PayPalProcessor implements PaymentProcessor {
public PaymentResult process(Payment payment) {
// Process PayPal payment
return new PaymentResult(true, "pp_txn_" + UUID.randomUUID());
}
}
// Factory
public class PaymentProcessorFactory {
public static PaymentProcessor create(PaymentMethod method) {
return switch (method) {
case CREDIT_CARD -> new CreditCardProcessor();
case PAYPAL -> new PayPalProcessor();
case CRYPTO -> new CryptoProcessor();
};
}
}
// Usage
PaymentProcessor processor = PaymentProcessorFactory.create(payment.getMethod());
processor.process(payment);
Builder
The Builder pattern constructs complex objects step by step.
public class EmailMessage {
private final String from;
private final List<String> to;
private final String subject;
private final String body;
private final List<String> cc;
private final List<String> bcc;
private final boolean html;
private final List<Attachment> attachments;
private EmailMessage(Builder builder) {
this.from = builder.from;
this.to = List.copyOf(builder.to);
this.subject = builder.subject;
this.body = builder.body;
this.cc = List.copyOf(builder.cc);
this.bcc = List.copyOf(builder.bcc);
this.html = builder.html;
this.attachments = List.copyOf(builder.attachments);
}
public static class Builder {
private String from;
private List<String> to = new ArrayList<>();
private String subject;
private String body;
private List<String> cc = new ArrayList<>();
private List<String> bcc = new ArrayList<>();
private boolean html;
private List<Attachment> attachments = new ArrayList<>();
public Builder from(String from) {
this.from = Objects.requireNonNull(from);
return this;
}
public Builder to(String recipient) {
this.to.add(recipient);
return this;
}
public Builder subject(String subject) {
this.subject = subject;
return this;
}
public Builder body(String body, boolean html) {
this.body = body;
this.html = html;
return this;
}
public Builder addAttachment(Attachment attachment) {
this.attachments.add(attachment);
return this;
}
public EmailMessage build() {
Objects.requireNonNull(from, "from is required");
Objects.requireNonNull(subject, "subject is required");
Objects.requireNonNull(body, "body is required");
if (to.isEmpty()) throw new IllegalStateException("at least one recipient required");
return new EmailMessage(this);
}
}
}
// Usage
EmailMessage email = new EmailMessage.Builder()
.from("noreply@example.com")
.to("user@example.com")
.subject("Welcome!")
.body("<h1>Welcome to our service</h1>", true)
.addAttachment(new Attachment("terms.pdf", pdfData))
.build();
Structural Patterns
Adapter
The Adapter allows incompatible interfaces to work together.
// Existing interface
public interface LegacyUserService {
Map<String, String> getUserData(int id);
}
// New interface
public record User(Long id, String name, String email) {}
public class UserServiceAdapter {
private final LegacyUserService legacyService;
public UserServiceAdapter(LegacyUserService legacyService) {
this.legacyService = legacyService;
}
public User findById(int id) {
Map<String, String> data = legacyService.getUserData(id);
return new User(
Long.parseLong(data.get("id")),
data.get("name"),
data.get("email")
);
}
}
Decorator
The Decorator dynamically adds behavior to objects without modifying their code.
// Component interface
public interface DataSource {
void writeData(String data);
String readData();
}
// Concrete component
public class FileDataSource implements DataSource {
private final String filename;
public FileDataSource(String filename) {
this.filename = filename;
}
@Override
public void writeData(String data) {
Files.writeString(Path.of(filename), data);
}
@Override
public String readData() {
return Files.readString(Path.of(filename));
}
}
// Base decorator
public abstract class DataSourceDecorator implements DataSource {
protected final DataSource wrappee;
public DataSourceDecorator(DataSource wrappee) {
this.wrappee = wrappee;
}
@Override
public void writeData(String data) {
wrappee.writeData(data);
}
@Override
public String readData() {
return wrappee.readData();
}
}
// Concrete decorators
public class EncryptionDecorator extends DataSourceDecorator {
public EncryptionDecorator(DataSource wrappee) {
super(wrappee);
}
@Override
public void writeData(String data) {
String encrypted = Base64.getEncoder().encodeToString(data.getBytes());
super.writeData(encrypted);
}
@Override
public String readData() {
String encrypted = super.readData();
return new String(Base64.getDecoder().decode(encrypted));
}
}
public class CompressionDecorator extends DataSourceDecorator {
public CompressionDecorator(DataSource wrappee) {
super(wrappee);
}
@Override
public void writeData(String data) {
// Compress data before writing
super.writeData(compress(data));
}
@Override
public String readData() {
return decompress(super.readData());
}
}
// Usage
DataSource source = new CompressionDecorator(
new EncryptionDecorator(
new FileDataSource("data.txt")));
source.writeData("Hello, World!");
Proxy
The Proxy controls access to another object.
public interface Image {
void display();
}
public class RealImage implements Image {
private final String filename;
public RealImage(String filename) {
this.filename = filename;
loadFromDisk();
}
private void loadFromDisk() {
System.out.println("Loading image: " + filename);
}
@Override
public void display() {
System.out.println("Displaying: " + filename);
}
}
public class CachingProxyImage implements Image {
private RealImage realImage;
private final String filename;
public CachingProxyImage(String filename) {
this.filename = filename;
}
@Override
public void display() {
if (realImage == null) {
realImage = new RealImage(filename);
}
realImage.display();
}
}
Behavioral Patterns
Strategy
The Strategy pattern defines a family of algorithms and makes them interchangeable.
// Strategy interface
@FunctionalInterface
public interface DiscountStrategy {
BigDecimal applyDiscount(BigDecimal amount);
}
// Concrete strategies
public class NoDiscount implements DiscountStrategy {
public BigDecimal applyDiscount(BigDecimal amount) {
return amount;
}
}
public class PercentageDiscount implements DiscountStrategy {
private final BigDecimal percentage;
public PercentageDiscount(BigDecimal percentage) {
this.percentage = percentage;
}
public BigDecimal applyDiscount(BigDecimal amount) {
return amount.multiply(BigDecimal.ONE.subtract(percentage));
}
}
public class ThresholdDiscount implements DiscountStrategy {
private final BigDecimal threshold;
private final BigDecimal discountAmount;
public ThresholdDiscount(BigDecimal threshold, BigDecimal discountAmount) {
this.threshold = threshold;
this.discountAmount = discountAmount;
}
public BigDecimal applyDiscount(BigDecimal amount) {
return amount.compareTo(threshold) >= 0
? amount.subtract(discountAmount)
: amount;
}
}
// Context
public class Order {
private final BigDecimal total;
private final DiscountStrategy discountStrategy;
public Order(BigDecimal total, DiscountStrategy discountStrategy) {
this.total = total;
this.discountStrategy = discountStrategy;
}
public BigDecimal calculateTotal() {
return discountStrategy.applyDiscount(total);
}
}
// Usage with lambdas (simplified strategy)
Order order1 = new Order(
new BigDecimal("100.00"),
amount -> amount); // No discount
Order order2 = new Order(
new BigDecimal("200.00"),
amount -> amount.multiply(new BigDecimal("0.9"))); // 10% off
Observer
The Observer pattern defines a one-to-many dependency between objects.
// Event interface
public record OrderEvent(Long orderId, OrderStatus status, Instant timestamp) {}
@FunctionalInterface
public interface OrderObserver {
void onOrderEvent(OrderEvent event);
}
// Subject
public class OrderEventPublisher {
private final List<OrderObserver> observers = new ArrayList<>();
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public void subscribe(OrderObserver observer) {
observers.add(observer);
}
public void unsubscribe(OrderObserver observer) {
observers.remove(observer);
}
public void publish(OrderEvent event) {
// Notify observers asynchronously
observers.forEach(observer ->
executor.submit(() -> observer.onOrderEvent(event)));
}
}
// Concrete observers
@Component
public class EmailNotificationObserver implements OrderObserver {
private final EmailService emailService;
public EmailNotificationObserver(EmailService emailService) {
this.emailService = emailService;
}
@Override
public void onOrderEvent(OrderEvent event) {
if (event.status() == OrderStatus.CONFIRMED) {
emailService.sendOrderConfirmation(event.orderId());
}
}
}
@Component
public class InventoryObserver implements OrderObserver {
private final InventoryService inventoryService;
@Override
public void onOrderEvent(OrderEvent event) {
if (event.status() == OrderStatus.CANCELLED) {
inventoryService.releaseReservedStock(event.orderId());
}
}
}
Template Method
The Template Method defines the skeleton of an algorithm, letting subclasses fill in steps.
public abstract class DataImporter {
// Template method
public final ImportResult importData(String source) {
try {
byte[] data = readData(source);
List<String> records = parseData(data);
List<Record> validated = validateRecords(records);
int count = saveRecords(validated);
logImport(source, count);
return ImportResult.success(count);
} catch (Exception e) {
logError(source, e);
return ImportResult.failure(e.getMessage());
}
}
protected abstract byte[] readData(String source);
protected abstract List<String> parseData(byte[] data);
// Common implementation - can be overridden
protected List<Record> validateRecords(List<String> records) {
return records.stream()
.map(this::parseRecord)
.filter(this::isValidRecord)
.toList();
}
protected Record parseRecord(String line) {
return new Record(line);
}
protected boolean isValidRecord(Record record) {
return record != null && record.isValid();
}
private int saveRecords(List<Record> records) {
// Common database save logic
return repository.saveAll(records).size();
}
}
public class CsvDataImporter extends DataImporter {
@Override
protected byte[] readData(String source) {
return Files.readAllBytes(Path.of(source));
}
@Override
protected List<String> parseData(byte[] data) {
String content = new String(data);
return Arrays.asList(content.split("\n"));
}
@Override
protected Record parseRecord(String line) {
String[] fields = line.split(",");
return new Record(fields[0], fields[1], fields[2]);
}
}
Modern Java Pattern Idioms
Strategy with Lambdas
// Instead of implementing the interface, pass lambdas
List<BigDecimal> prices = List.of(new BigDecimal("10"), new BigDecimal("20"));
List<BigDecimal> discounted = prices.stream()
.map(price -> applyDiscount(price, amount ->
amount.multiply(new BigDecimal("0.95"))))
.toList();
Builder with Records and Lombok
// Lombok @Builder generates the builder
@Builder
public record Product(
@NotBlank String name,
@Positive BigDecimal price,
String description,
List<String> tags,
Map<String, String> attributes
) {}
Observer with Event Listeners
// Spring's @EventListener replaces explicit observer pattern
@Component
public class OrderEventListener {
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
// React to order creation
}
}
Common Mistakes
1. Overusing Singleton
Singletons introduce global state and make testing difficult. Prefer dependency injection (Spring manages singletons by default for you).
2. Pattern for Pattern's Sake
Do not force a pattern where a simpler solution works. Patterns should simplify, not complicate.
3. Ignoring Modern Language Features
Lambdas, streams, records, sealed classes, and pattern matching can replace several GoF patterns with less code.
4. Tight Coupling with Factory
Using factories for every object creates unnecessary indirection. Use factories only when object creation is complex or must vary.
5. Violating the Liskov Substitution Principle
Subclasses must be substitutable for their base classes. Decorator and Strategy patterns are particularly prone to LSP violations.
6. Not Considering Thread Safety
Singleton, Observer, and State patterns often need synchronization in multi-threaded environments.
Practice Questions
- What is the difference between the Factory Method and Abstract Factory patterns?
- How does the Decorator pattern differ from subclassing?
- Why does the Strategy pattern benefit from Java's lambda expressions?
- What are the disadvantages of the Singleton pattern?
- When would you use the Proxy pattern instead of the Decorator pattern?
Challenge: Refactor a legacy codebase that has a monolithic service class handling data access, business logic, caching, logging, and authorization. Apply the Decorator pattern for cross-cutting concerns (caching, logging), the Strategy pattern for interchangeable algorithms, and the Repository Patternory" >}} pattern for data access.
FAQ
Mini Project: Pluggable E-Commerce Engine
Build a discount engine that applies multiple discount strategies to an order:
- Fixed amount off (Strategy)
- Percentage off (Strategy)
- Buy-one-get-one-free (Strategy)
- Free shipping above threshold (Strategy)
- Combine all applicable discounts (Composite pattern)
Implement the following architecture:
- DiscountStrategy interface with functional composition
- DiscountCalculator that chains multiple strategies
- OrderService that delegates to DiscountCalculator
- Each discount loaded via ServiceLoader (pluggable)
- Caching proxy for discount rules
- Event publishing when discounts are applied
Test with JUnit 5, verifying that combinations of strategies produce correct totals.
What's Next
You have completed the design patterns lesson. The final lesson in this series prepares you for Java Interview Preparation, consolidating all key topics for technical interviews.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro