This content originally appeared on DEV Community and was authored by jungledev
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/hashCode
for 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 Java (Java 8+ with Lambdas):
- Functional interfaces (@FunctionalInterface)
- Lambdas or method references
- Enum-based strategy registry
@FunctionalInterface
public interface PaymentStrategy {
PaymentResult process(PaymentRequest request, PaymentContext context);
}
public enum PaymentType {
CREDIT_CARD,
PAYPAL,
BANK_TRANSFER
}
public record PaymentContext(
CreditCardProcessor creditCardProcessor,
PayPalProcessor payPalProcessor,
BankTransferProcessor bankTransferProcessor
) {}
public class PaymentStrategyRegistry {
private final Map<PaymentType, PaymentStrategy> strategies = Map.of(
PaymentType.CREDIT_CARD, (req, ctx) -> ctx.creditCardProcessor().process(req),
PaymentType.PAYPAL, (req, ctx) -> ctx.payPalProcessor().process(req),
PaymentType.BANK_TRANSFER, (req, ctx) -> ctx.bankTransferProcessor().process(req)
);
public PaymentStrategy getStrategy(PaymentType type) {
PaymentStrategy strategy = strategies.get(type);
if (strategy == null) {
throw new IllegalArgumentException("No strategy found for type: " + type);
}
return strategy;
}
}
public class PaymentService {
private final PaymentStrategyRegistry registry;
public PaymentResult pay(PaymentType type, PaymentRequest request, PaymentContext context) {
return registry.getStrategy(type).process(request, context);
}
}
- Marked @FunctionalInterface so we can use lambdas or method references.
- Single abstract method = lambda-compatible.
- Uses Map.of() for immutability.
- No extra boilerplate — each strategy is just a lambda.
- Enum keys ensure only valid payment types are allowed.
Enum-Based Strategy
@Service
public class PaymentServiceOld {
public PaymentResult processPayment(PaymentRequest request) {
if ("CREDIT_CARD".equals(request.getPaymentType())) {
// Credit card logic
return processCreditCard(request);
} else if ("PAYPAL".equals(request.getPaymentType())) {
// PayPal logic
return processPayPal(request);
} else if ("BANK_TRANSFER".equals(request.getPaymentType())) {
// Bank transfer logic
return processBankTransfer(request);
}
throw new UnsupportedPaymentTypeException(request.getPaymentType());
}
}
Clean strategy pattern with Java 17 features
public enum PaymentStrategy {
CREDIT_CARD {
@Override
public PaymentResult process(PaymentRequest request, PaymentContext context) {
return context.creditCardProcessor().process(request);
}
},
PAYPAL {
@Override
public PaymentResult process(PaymentRequest request, PaymentContext context) {
return context.payPalProcessor().process(request);
}
},
BANK_TRANSFER {
@Override
public PaymentResult process(PaymentRequest request, PaymentContext context) {
return context.bankTransferProcessor().process(request);
}
};
public abstract PaymentResult process(PaymentRequest request, PaymentContext context);
}
public record PaymentContext(
CreditCardProcessor creditCardProcessor,
PayPalProcessor payPalProcessor,
BankTransferProcessor bankTransferProcessor
) {}
@Service
public class PaymentService {
private final PaymentContext paymentContext;
public PaymentService(PaymentContext paymentContext) {
this.paymentContext = paymentContext;
}
public PaymentResult processPayment(PaymentRequest request) {
var strategy = PaymentStrategy.valueOf(request.getPaymentType());
return strategy.process(request, paymentContext);
}
}
@Bean
@ConditionalOnProperty(name = "payment.enabled", havingValue = "true", matchIfMissing = true)
public PaymentContext paymentContext(
CreditCardProcessor creditCardProcessor,
PayPalProcessor payPalProcessor,
BankTransferProcessor bankTransferProcessor) {
return new PaymentContext(
creditCardProcessor,
payPalProcessor,
bankTransferProcessor
);
}
- Type-safe list of strategies.
- Easy to iterate over available options.
- Each enum constant is instantiated exactly once (thread-safe, serialization-safe by default).
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::new
and it becomes a UIFactory implementation (lambda) wherecreateButton()
just does new MacButton() - Since
createButton()
returns a Button,MacButton::new
is 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.
This content originally appeared on DEV Community and was authored by jungledev