Intent:
In this post, we’ll explore some modern Java approaches to implementing classic design patterns.
While design patterns have been around for decades, Java’s newer features—like lambdas, streams, records, and sealed classes—allow us to implement them in cleaner, more concise, and more flexible ways. We’ll look at how these enhancements simplify traditional patterns without losing their core intent.
✅ What’s different in modern style:
- Functional interfaces & lambdas remove class boilerplate.
-
Records give you immutable data holders with
toString/equals/hashCodefor free. - Sealed classes + switch expressions enforce exhaustiveness.
- Streams replace manual iteration logic.
- Enums replace string identifiers for compile-time safety.
Modern Java Features Used:
Java 8+:
- Lambda expressions and method references
- Functional interfaces
- Default methods in interfaces
- Stream API concepts
Java 10+:
- var keyword for local variable type inference
Java 14+:
- Switch expressions
- Records (preview in 14, standard in 16)
Java 17+:
- Sealed classes and interfaces
- Pattern matching enhancements
Introduction:
Design patterns are standard, reusable solutions to frequent problems in software design. Think of them as ready-made blueprints that you can adapt to address recurring challenges in your code.
Design patterns are generally grouped into three main categories, each addressing different kinds of problems in software design.
- Creational Design Patterns Focus on how objects are created while hiding the creation logic, making the system more flexible and reusable.
- Structural Design Patterns Deal with how classes and objects are composed to form larger structures while keeping them flexible and efficient.
- Behavioral Design Patterns Focus on how objects communicate and interact while keeping them loosely coupled.
1. Strategy Pattern
💡Intent
Strategy is a behavioral design pattern that lets you define a family of algorithms, put each of them into a separate class, and make their objects interchangeable.
Use
- Use the Strategy pattern when you want to use different variants of an algorithm within an object, and be able to switch from one algorithm to another during runtime.
- Use the Strategy when you have a lot of similar classes that only differ in the way they execute some behavior.
- Use the pattern when your class has a massive conditional statement that switches between different variants of the same algorithm.
Traditional Java
interface PaymentStrategy {
void pay(double amount);
}
class CreditCardPayment implements PaymentStrategy {
public void pay(double amount) {
System.out.println("Paid " + amount + " with Credit Card");
}
}
class PayPalPayment implements PaymentStrategy {
public void pay(double amount) {
System.out.println("Paid " + amount + " via PayPal");
}
}
PaymentStrategy strategy = new CreditCardPayment();
strategy.pay(100);
Modern (Spring Auto-Injection)
The interface
Unchanged — just a functional interface:
public interface PaymentStrategy {
PaymentResult process(PaymentRequest request);
}
Each strategy
Just a @Component with a meaningful bean name:
@Component("CREDIT_CARD")
public class CreditCardStrategy implements PaymentStrategy {
private final CreditCardProcessor processor;
public CreditCardStrategy(CreditCardProcessor processor) {
this.processor = processor;
}
@Override
public PaymentResult process(PaymentRequest request) {
return processor.process(request);
}
}
@Component("PAYPAL")
public class PayPalStrategy implements PaymentStrategy {
private final PayPalProcessor processor;
public PayPalStrategy(PayPalProcessor processor) {
this.processor = processor;
}
@Override
public PaymentResult process(PaymentRequest request) {
return processor.process(request);
}
}
@Component("BANK_TRANSFER")
public class BankTransferStrategy implements PaymentStrategy {
private final BankTransferProcessor processor;
public BankTransferStrategy(BankTransferProcessor processor) {
this.processor = processor;
}
@Override
public PaymentResult process(PaymentRequest request) {
return processor.process(request);
}
}
The service
Spring auto-fills the map — no registry class needed:
@Service
public class PaymentService {
private final Map<String, PaymentStrategy> strategies;
public PaymentService(Map<String, PaymentStrategy> strategies) {
this.strategies = strategies;
}
public PaymentResult pay(String type, PaymentRequest request) {
PaymentStrategy strategy = strategies.get(type);
if (strategy == null) {
throw new IllegalArgumentException("No strategy for type: " + type);
}
return strategy.process(request);
}
}
Why this works
When Spring sees a constructor parameter of type Map<String, PaymentStrategy>, it automatically collects all beans implementing PaymentStrategy and puts them into the map — using each bean's name as the key. Since the @Component name matches the payment type string ("CREDIT_CARD", "PAYPAL", etc.), the lookup just works.
No PaymentStrategyRegistry, no PaymentContext record, no Map.of() block to maintain. Adding a new payment type is just adding a new @Component class — Spring picks it up automatically.
Enum-Based Strategy Pattern
Before — chained if/else
@Service
public class PaymentServiceOld {
public PaymentResult processPayment(PaymentRequest request) {
if ("CREDIT_CARD".equals(request.getPaymentType())) {
return processCreditCard(request);
} else if ("PAYPAL".equals(request.getPaymentType())) {
return processPayPal(request);
} else if ("BANK_TRANSFER".equals(request.getPaymentType())) {
return processBankTransfer(request);
}
throw new UnsupportedPaymentTypeException(request.getPaymentType());
}
}
After — clean enum strategy (Java 17)
1. Strategy enum
Each constant owns its logic. The processor is passed in at call time via PaymentContext:
public enum PaymentStrategy {
CREDIT_CARD {
@Override
public PaymentResult process(PaymentRequest request, PaymentContext context) {
return context.getCreditCardProcessor().process(request);
}
},
PAYPAL {
@Override
public PaymentResult process(PaymentRequest request, PaymentContext context) {
return context.getPayPalProcessor().process(request);
}
},
BANK_TRANSFER {
@Override
public PaymentResult process(PaymentRequest request, PaymentContext context) {
return context.getBankTransferProcessor().process(request);
}
};
public abstract PaymentResult process(PaymentRequest request, PaymentContext context);
// Safe lookup with a clear error message
public static PaymentStrategy from(String type) {
try {
return PaymentStrategy.valueOf(type.toUpperCase());
} catch (IllegalArgumentException e) {
throw new UnsupportedPaymentTypeException("Unknown payment type: " + type);
}
}
}
2. PaymentContext
A simple holder class (not a record) so Spring can manage it as a bean:
@Component
public class PaymentContext {
private final CreditCardProcessor creditCardProcessor;
private final PayPalProcessor payPalProcessor;
private final BankTransferProcessor bankTransferProcessor;
public PaymentContext(
CreditCardProcessor creditCardProcessor,
PayPalProcessor payPalProcessor,
BankTransferProcessor bankTransferProcessor) {
this.creditCardProcessor = creditCardProcessor;
this.payPalProcessor = payPalProcessor;
this.bankTransferProcessor = bankTransferProcessor;
}
public CreditCardProcessor getCreditCardProcessor() { return creditCardProcessor; }
public PayPalProcessor getPayPalProcessor() { return payPalProcessor; }
public BankTransferProcessor getBankTransferProcessor() { return bankTransferProcessor; }
}
3. Service
One line to resolve and execute the strategy:
@Service
public class PaymentService {
private final PaymentContext paymentContext;
public PaymentService(PaymentContext paymentContext) {
this.paymentContext = paymentContext;
}
public PaymentResult processPayment(PaymentRequest request) {
PaymentStrategy strategy = PaymentStrategy.from(request.getPaymentType());
return strategy.process(request, paymentContext);
}
}
What changed and why
| Issue | Fix |
|---|---|
record used as Spring bean |
Replaced with a regular @Component class with getters |
Raw valueOf() with no error handling |
Wrapped in PaymentStrategy.from() with a clean message |
@Bean floating without a class |
Moved inside a proper @Configuration class |
| Bullet points for benefits | Expanded into a clear "What changed and why" table |
Why this works
-
Type-safe —
from()gives a clear error on unknown types instead of a raw JVM exception. -
No registry needed —
PaymentStrategy.values()lists all strategies; no extra map to maintain. - Thread-safe by default — enum constants are instantiated exactly once by the JVM.
- Open for extension — adding a new payment type means adding one enum constant; nothing else changes.
2. Builder Pattern
💡Intent
Builder is a Creational Design Patterns that create complex objects step-by-step, keeping the construction readable.
Use
The Builder Pattern is used when you want to construct complex objects step-by-step, while keeping the creation process separate from the object itself.
Traditional Java:
public class User {
private final String firstName;
private final String lastName;
private final int age;
private final String email;
private User(Builder builder) {
this.firstName = builder.firstName;
this.lastName = builder.lastName;
this.age = builder.age;
this.email = builder.email;
}
public static class Builder {
private String firstName;
private String lastName;
private int age;
private String email;
public Builder firstName(String firstName) {
this.firstName = firstName;
return this;
}
public Builder lastName(String lastName) {
this.lastName = lastName;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public User build() {
return new User(this);
}
}
}
//Usage:
User user = new User.Builder()
.firstName("John")
.lastName("Doe")
.age(30)
.email("john.doe@example.com")
.build();
Modern Java (Java 16+ Records + Compact Constructor)
public record User(String firstName, String lastName, int age, String email) {
public static User of(java.util.function.Consumer<Builder> consumer) {
Builder b = new Builder();
consumer.accept(b);
return new User(b.firstName, b.lastName, b.age, b.email);
}
public static class Builder {
String firstName;
String lastName;
int age;
String email;
}
}
///Usage:
User user = User.of(b -> {
b.firstName = "John";
b.lastName = "Doe";
b.age = 30;
b.email = "john@example.com";
});
If you’re using Lombok, you can remove the boilerplate entirely:
@Builder
public record User(String firstName, String lastName, int age, String email) {}
3. Factory Method Pattern
💡Intent
Define an interface for creating an object, but let subclasses decide which class to instantiate. Factory Method lets a class defer instantiation to subclasses.
Use
- You want to create objects without coupling your code to concrete classes.
- You expect new variants in the future (open/closed).
- Object creation needs extension points (plug-ins, feature flags, A/B variants)
Traditional
interface Shape {}
class Circle implements Shape {}
class Rectangle implements Shape {}
class ShapeFactory {
public Shape create(String type) {
if ("circle".equalsIgnoreCase(type)) return new Circle();
if ("rectangle".equalsIgnoreCase(type)) return new Rectangle();
throw new IllegalArgumentException();
}
}
- Uses if-else chains or verbose switch statements.
- No compile-time check if a Shape type is missing in the factory.
Modern (Sealed Classes + Switch Expressions)
- Sealed interfaces for closed hierarchies
- Records for value objects
- Switch expressions for concise creation logic
sealed interface Shape permits Circle, Rectangle {}
record Circle() implements Shape {}
record Rectangle() implements Shape {}
enum ShapeType { CIRCLE, RECTANGLE }
class ShapeFactory {
static Shape create(ShapeType type) {
return switch (type) {
case CIRCLE -> new Circle();
case RECTANGLE -> new Rectangle();
};
}
}
- No string checks, exhaustive compile-time safety, minimal boilerplate.
4. Singleton Pattern
💡Intent
Ensure a class has only one instance in the JVM and provide a global point of access to that instance.
Use
- You want exactly one object to coordinate actions across a system (shared resource).
- That object should be accessible from anywhere without repeatedly creating it.
- You need to control its lifecycle (created once, reused many times).
Traditional Singleton (Java 5+)
class TraditionalSingleton {
private static volatile TraditionalSingleton instance;
private TraditionalSingleton() {}
public static TraditionalSingleton getInstance() {
if (instance == null) {
synchronized (TraditionalSingleton.class) {
if (instance == null) {
instance = new TraditionalSingleton();
}
}
}
return instance;
}
}
enum ModernSingleton {
INSTANCE;
// Example state
private final AtomicInteger counter = new AtomicInteger();
// Example method
public void incrementCounter() {
int newValue = counter.incrementAndGet();
logs.info("Counter incremented to " + newValue);
}
public int getCounter() {
return counter.get();
}
public void doSomething() {
System.out.println("Singleton operation");
}
}
ModernSingleton s = ModernSingleton.INSTANCE;
s.incrementCounter();
s.doSomething();
- In Java 5+, the Enum Singleton is the most robust approach:
- Thread-safe without synchronization.
- Resistant to reflection & serialization issues.
- Cleaner and less error-prone than double-checked locking.
5. Observer Pattern
💡Intent
Define a one-to-many dependency between objects so that when one object (the subject) changes state, all its dependents (observers) are notified automatically.
Use
- To achieve loose coupling: the subject doesn’t need to know who’s observing it or how they’ll react.
- To support event-driven architectures where changes propagate automatically.
Traditional Observer Pattern
//The Subject (Publisher)
interface WeatherObserver {
void update(float temperature);
}
class WeatherStation {
private final List<WeatherObserver> observers = new ArrayList<>();
private float temperature;
public void addObserver(WeatherObserver observer) {
observers.add(observer);
}
public void removeObserver(WeatherObserver observer) {
observers.remove(observer);
}
public void setTemperature(float temperature) {
this.temperature = temperature;
notifyObservers();
}
private void notifyObservers() {
for (WeatherObserver o : observers) {
o.update(temperature);
}
}
}
//The Observers
class PhoneDisplay implements WeatherObserver {
@Override
public void update(float temperature) {
System.out.println("Phone Display: Temp is " + temperature + "°C");
}
}
class WindowDisplay implements WeatherObserver {
@Override
public void update(float temperature) {
System.out.println("Window Display: Temp is " + temperature + "°C");
}
}
Demo
public static void main(String[] args) {
WeatherStation station = new WeatherStation();
WeatherObserver phone = new PhoneDisplay();
WeatherObserver window = new WindowDisplay();
station.addObserver(phone);
station.addObserver(window);
station.setTemperature(25.5f);
station.setTemperature(30.0f);
}
- Subject (WeatherStation) holds state and notifies observers when it changes.
- Observers (PhoneDisplay, WindowDisplay) react without the subject knowing what they do.
- Easy to extend: just add more observers without modifying WeatherStation.
Modern Observer using CompletableFuture and Reactive Streams concept
public class ModernWeatherStation {
private final List<Consumer<Float>> subscribers = new ArrayList<>();
// Subscribe using lambda/method reference
public void subscribe(Consumer<Float> subscriber) {
subscribers.add(subscriber);
}
// Async notification using CompletableFuture
public void setTemperature(float temperature) {
subscribers.forEach(subscriber ->
CompletableFuture.runAsync(() -> subscriber.accept(temperature))
);
}
// Synchronous update using var for type inference
public void setTemperatureSync(float temperature) {
var tempMessage = temperature; // could be enriched before sending
subscribers.forEach(subscriber -> subscriber.accept(tempMessage));
}
}
Demo
public static void main(String[] args) {
ModernWeatherStation station = new ModernWeatherStation();
// Subscribe with lambdas
station.subscribe(temp -> System.out.println("Phone Display: " + temp + "°C"));
station.subscribe(temp -> System.out.println("Window Display: " + temp + "°C"));
// Async notification
station.setTemperature(25.5f);
// Sync notification
station.setTemperatureSync(30.0f);
}
- A single Consumer works for any type — you don’t lock into a single interface name.
- Easy to chain with .andThen(...) for multiple actions in one observer.
- Consumer makes your Observer Pattern lighter, more flexible, and compatible with modern Java’s functional style, while avoiding the overhead of defining custom observer interfaces.
6. Template Method Pattern
💡Intent
Define the skeleton of an algorithm in a method, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the overall structure.
Use
- When you have an invariant process but need variation in certain steps.
- To avoid code duplication when multiple classes share the same workflow.
- To enforce a standardized algorithm across different implementations.
Traditional Template Method
abstract class DataProcessor {
// Template method (final to prevent overriding full structure)
public final void process() {
readData();
processData();
saveData();
}
abstract void readData();
abstract void processData();
// Common step
void saveData() {
System.out.println("Saving processed data...");
}
}
class CSVDataProcessor extends DataProcessor {
void readData() { System.out.println("Reading CSV file"); }
void processData() { System.out.println("Processing CSV data"); }
}
class JSONDataProcessor extends DataProcessor {
void readData() { System.out.println("Reading JSON file"); }
void processData() { System.out.println("Processing JSON data"); }
}
Modern Template Method using Functional Interfaces and Default Methods
sealed interface DataProcessor permits CSVDataProcessor, JSONDataProcessor {
// Template Method (default implementation)
default void process() {
readData();
processData();
saveData();
}
void readData();
void processData();
// Common step shared by all
default void saveData() {
System.out.println("Saving processed data...");
}
}
final class CSVDataProcessor implements DataProcessor {
public void readData() {
System.out.println("Reading CSV file");
}
public void processData() {
System.out.println("Processing CSV data");
}
}
final class JSONDataProcessor implements DataProcessor {
public void readData() {
System.out.println("Reading JSON file");
}
public void processData() {
System.out.println("Processing JSON data");
}
}
Demo
DataProcessor csv = new CSVDataProcessor();
DataProcessor json = new JSONDataProcessor();
csv.process();
json.process();
- Less boilerplate — no need for abstract class if you use default methods in an interface.
- Sealed interface — restricts who can extend/implement the base type, keeping design safe.
- Easy extension — Adding a new processor type is just a new final class. Functional compatibility — You could even make readData and processData accept lambdas if you wanted dynamic behavior.
7. Abstract Factory Pattern
💡Intent
Provide an interface for creating families of related objects without specifying their concrete classes.
Use
UI toolkits for different OS themes, game objects for different levels.
Traditional Java
interface Button { void paint(); }
class WinButton implements Button { public void paint() { System.out.println("Windows Button"); } }
class MacButton implements Button { public void paint() { System.out.println("Mac Button"); } }
interface UIFactory { Button createButton(); }
class WinFactory implements UIFactory { public Button createButton() { return new WinButton(); } }
class MacFactory implements UIFactory { public Button createButton() { return new MacButton(); } }
public class AbstractFactoryTraditional {
public static void main(String[] args) {
UIFactory factory = new WinFactory();
Button button = factory.createButton();
button.paint();
}
}
Modern Java
interface Button { void paint(); }
record WinButton() implements Button { public void paint() { System.out.println("Windows Button"); } }
record MacButton() implements Button { public void paint() { System.out.println("Mac Button"); } }
interface UIFactory {
Button createButton();
static UIFactory of(String type) {
return switch (type) {
case "WIN" -> WinButton::new;
case "MAC" -> MacButton::new;
default -> throw new IllegalArgumentException("Unknown type");
};
}
}
//Demo
public class AbstractFactoryModern {
public static void main(String[] args) {
var factory = UIFactory.of("MAC");
factory.createButton().paint();
}
}
- "MAC" triggers the case "MAC" ->
MacButton::newand it becomes a UIFactory implementation (lambda) wherecreateButton()just does new MacButton() - Since
createButton()returns a Button,MacButton::newis automatically treated as a lambda equivalent to:() -> new MacButton()
References & Credits
AI tools were used to assist in research and writing but final content was reviewed and verified by the author.
Top comments (0)