In this Article
The Singleton pattern allows for one instance and global access, making it useful for logging or configuration. However, relying solely on Singleton limits architectural flexibility. To build robust production systems, you need patterns that handle object creation, state changes, and algorithmic selection efficiently.
Three practical patterns used frequently in frameworks like Spring and real-world production code are Factory Method, Observer, and Strategy.
Factory Method Pattern
The Factory Method pattern solves the problem of hardcoding class instantiations. If your application creates objects based on user input or configuration, using logic like if type equals A return new ClassA() creates a maintenance burden. This logic tends to spread across the application, making updates difficult.
The Factory Method pushes object creation into subclasses. The main code does not need to know which specific class is being instantiated.
In this structure, a Product interface defines object behavior. The Creator class defines a factory method but leaves the implementation to the ConcreteCreator subclass. This allows you to swap creators (e.g., swapping a production creator for a mock creator) without changing the core logic.
Implementation
Here is how to implement the Factory Method in Java to decouple object creation.
// 1. The Product Interface
// Defines what the objects can actually do.
interface Product {
void doSomething();
}
// 2. Concrete Product
// The actual object implementation we want to create.
class ConcreteProduct implements Product {
@Override
public void doSomething() {
System.out.println("ConcreteProduct is working.");
}
}
// 3. Abstract Creator
// Declares the factory method but leaves implementation to subclasses.
abstract class Creator {
// This is the factory method
public abstract Product factoryMethod();
// Business logic that relies on the product
public void triggerOperation() {
Product product = factoryMethod();
product.doSomething();
}
}
// 4. Concrete Creator
// Implements the factory method to return a specific product.
class ConcreteCreator extends Creator {
@Override
public Product factoryMethod() {
return new ConcreteProduct();
}
}
// Usage
public class FactoryDemo {
public static void main(String[] args) {
// We use the abstract reference to trigger behavior.
// The specific type is determined by the ConcreteCreator.
Creator creator = new ConcreteCreator();
creator.triggerOperation();
}
}This approach mirrors how Spring handles bean injection. Spring does not hardcode class names; it allows configuration to determine which implementation to inject, keeping code testable and decoupled.
Observer Pattern
When you have a data source or system component (the Subject) and multiple other parts of the app need to react to its changes, direct calling is inefficient. Hardcoding dependencies creates tight coupling.
The Observer pattern allows a Subject to notify many dependent Observers automatically when state changes. The Subject does not need to know who the Observers are or what they do. It simply broadcasts a notification.
This pattern is fundamental to event-driven systems, UI frameworks, and real-time dashboards like Grafana.
Implementation
The Subject interface handles attaching, detaching, and notifying observers. The Observer interface defines the update method.
import java.util.ArrayList;
import java.util.List;
// 1. Observer Interface
// Defines the method to be called when the subject changes.
interface Observer {
void update(String message);
}
// 2. Concrete Observer
// Reacts to the notification.
private String name;
this.name = name;
}
@Override
public void update(String message) {
System.out.println(name + " received update: " + message);
}
}
// 3. Subject Interface
// Methods to manage observers.
interface Subject {
void attach(Observer o);
void detach(Observer o);
void notifyObservers();
}
// 4. Concrete Subject (Publisher)
class NewPublisher implements Subject {
private List<Observer> observers = new ArrayList<>();
private String mainState;
@Override
public void attach(Observer o) {
observers.add(o);
}
@Override
public void detach(Observer o) {
observers.remove(o);
}
@Override
public void notifyObservers() {
for (Observer o : observers) {
o.update(mainState);
}
}
// Logic to change state and trigger notification
public void publishNewContent(String content) {
this.mainState = content;
notifyObservers();
}
}
// Usage
public class ObserverDemo {
public static void main(String[] args) {
NewPublisher publisher = new NewPublisher();
publisher.attach(sub1);
publisher.attach(sub2);
// Simulating a state change
publisher.publishNewContent("New Video Released");
}
}Strategy Pattern
Applications often require supporting multiple algorithms that vary at runtime, such as sorting products by price versus rating, or choosing a GPS route (fastest vs. shortest).
A common mistake is using massive if-else blocks to handle this logic. This violates the Open-Closed Principle because adding a new algorithm requires modifying existing, tested code. The Strategy pattern encapsulates each algorithm in its own class, making them interchangeable.
The Context class holds a reference to a Strategy interface. When an operation is needed, the Context delegates the logic to the currently active strategy.
Implementation
This example uses a payment processing system where the method of payment can change dynamically.
// 1. Strategy Interface
// Defines the common operation for all algorithms.
interface PaymentStrategy {
void pay(int amount);
}
// 2. Concrete Strategy A
class CreditCardPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("Paid " + amount + " using Credit Card.");
}
}
// 3. Concrete Strategy B
class PayPalPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("Paid " + amount + " using PayPal.");
}
}
// 4. Context
// Maintains a reference to a Strategy object.
class PaymentContext {
private PaymentStrategy strategy;
public PaymentContext(PaymentStrategy strategy) {
this.strategy = strategy;
}
// Allows swapping the strategy at runtime
public void setStrategy(PaymentStrategy strategy) {
this.strategy = strategy;
}
public void executePayment(int amount) {
strategy.pay(amount);
}
}
// Usage
public class StrategyDemo {
public static void main(String[] args) {
// Start with Credit Card strategy
PaymentContext context = new PaymentContext(new CreditCardPayment());
context.executePayment(100);
// Switch to PayPal strategy at runtime
// No logic inside PaymentContext needs to change.
context.setStrategy(new PayPalPayment());
context.executePayment(200);
}
}This works like a mobile phone and SIM cards. The phone is the context, and the SIM card is the strategy. You can swap SIM cards (strategies) to change the carrier behavior without modifying the phone itself.
Summary
These three patterns provide specific architectural solutions:
- Factory Method: Decouples object creation from business logic.
- Observer: Enables reactive systems through one-to-many notifications.
- Strategy: Allows swapping behaviors or algorithms at runtime without altering the context.
Mastering these improves code modularity and maintainability. While other patterns like Decorator, Command, and Adapter are also valuable, these three form the backbone of many scalable Java applications.