Design Patterns
Last edited: January 6, 2025 - 14 minutes read
Description
Content
Overview of the simplest and most common design patterns according to the book “Design Patterns Elements of Reusable Object-Oriented Software”:
- Factory Method
- Abstract Factory
- Adapter
- Composite
- Decorator
- Observer
- Strategy
- Template Method
with additional patterns which I personally consider common as well:
- Singleton
- Builder
- Proxy
- Command
- Chain of Responsibility
- Mediator
- Visitor
Creational Patterns
Factory Method:
Use when a class can’t anticipate the exact type of objects it must create or when the creation logic should be delegated to subclasses.
Signs you might need it:
If the code is cluttered with hardcoded object creation, making it difficult to modify or extend without altering the client code.
public interface Button {
void render();
}
public class WindowsButton implements Button {
public void render() {
System.out.println("Rendering Windows Button");
}
}
public class MacButton implements Button {
public void render() {
System.out.println("Rendering Mac Button");
}
}
public interface ButtonFactory {
Button createButton();
}
public class WindowsButtonFactory implements ButtonFactory {
@Override
public Button createButton() {
return new WindowsButton();
}
}
public class MacButtonFactory implements ButtonFactory {
@Override
public Button createButton() {
return new MacButton();
}
}
public class Client {
public static void main(String[] args) {
String platform = "Mac";
ButtonFactory factory;
if ("Windows".equalsIgnoreCase(platform)) {
factory = new WindowsButtonFactory();
} else {
factory = new MacButtonFactory();
}
Button button = factory.createButton();
button.render();
}
}
Abstract Factory:
Use when your system needs to create families of related or dependent objects without specifying their concrete classes.
Signs you might need it:
If adding new families of related objects requires significant changes to client code or tight coupling to concrete classes makes the system rigid and hard to extend.
public interface Button {
void render();
}
public class WindowsButton implements Button {
public void render() {
System.out.println("Rendering Windows Button");
}
}
public class MacButton implements Button {
public void render() {
System.out.println("Rendering Mac Button");
}
}
public interface Checkbox {
void render();
}
public class WindowsCheckbox implements Checkbox {
public void render() {
System.out.println("Rendering Windows Checkbox");
}
}
public class MacCheckbox implements Checkbox {
public void render() {
System.out.println("Rendering Mac Checkbox");
}
}
public interface AbstractFactory {
Button createButton();
Checkbox createCheckbox();
}
public class WindowsFactory implements AbstractFactory {
public Button createButton() {
return new WindowsButton();
}
public Checkbox createCheckbox() {
return new WindowsCheckbox();
}
}
public class MacFactory implements AbstractFactory {
public Button createButton() {
return new MacButton();
}
public Checkbox createCheckbox() {
return new MacCheckbox();
}
}
public class Application {
private final Button button;
private final Checkbox checkbox;
public Application(AbstractFactory factory) {
this.button = factory.createButton();
this.checkbox = factory.createCheckbox();
}
public void render() {
button.render();
checkbox.render();
}
}
public class Client {
public static void main(String[] args) {
String platform = "Mac";
AbstractFactory factory;
if ("Windows".equalsIgnoreCase(platform)) {
factory = new WindowsFactory();
} else {
factory = new MacFactory();
}
Application app = new Application(factory);
app.render();
}
}
Singleton
Use when you need to ensure only one instance of a class exists and provide a global access point to it.
Signs you might need it:
If multiple instances of a class lead to inconsistent state or resource conflicts across the application.
public class Singleton {
private static Singleton instance;
private Singleton() {
// Private constructor to prevent instantiation
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
public class Client {
public static void main(String[] args) {
Singleton instance = Singleton.getInstance();
Singleton sameInstance = Singleton.getInstance();
}
}
Builder
Use when constructing a complex object with many optional parameters or configurations that should be constructed step-by-step.
Signs you might need it:
If constructors become unwieldy due to numerous optional parameters or configurations, leading to unclear and error-prone code.
public class House {
private int rooms;
private boolean hasGarage;
private boolean hasGarden;
private House(Builder builder) {
this.rooms = builder.rooms;
this.hasGarage = builder.hasGarage;
this.hasGarden = builder.hasGarden;
}
public static class Builder {
private int rooms;
private boolean hasGarage;
private boolean hasGarden;
public Builder setRooms(int rooms) {
this.rooms = rooms;
return this;
}
public Builder setGarage(boolean hasGarage) {
this.hasGarage = hasGarage;
return this;
}
public Builder setGarden(boolean hasGarden) {
this.hasGarden = hasGarden;
return this;
}
public House build() {
return new House(this);
}
}
}
public class Client {
public static void main(String[] args) {
House house = new House.Builder()
.setRooms(3)
.setGarage(true)
.setGarden(false)
.build();
}
}
Structural Patterns
Adapter:
Use when you need to make two incompatible interfaces work together without altering their source code.
Signs you might need it:
If incompatible interfaces prevent the integration of existing components or systems, forcing duplication or rewriting of code.
public interface Celsius {
double getCelsiusTemperature();
}
public class Fahrenheit {
private double temperature;
public Fahrenheit(double temperature) {
this.temperature = temperature;
}
public double getFahrenheitTemperature() {
return temperature;
}
}
public class FahrenheitToCelsiusAdapter implements Celsius {
private Fahrenheit device;
public FahrenheitToCelsiusAdapter(Fahrenheit device) {
this.device = device;
}
@Override
public double getCelsiusTemperature() {
double fahrenheit = device.getFahrenheitTemperature();
return (fahrenheit - 32) * 5 / 9;
}
}
public class Client {
public static void main(String[] args) {
Fahrenheit fahrenheitDevice = new Fahrenheit(98.6);
Celsius temperatureAdapter = new FahrenheitToCelsiusAdapter(fahrenheitDevice);
System.out.println("Temperature in Celsius: " + temperatureAdapter.getCelsiusTemperature());
}
}
Composite:
Use when you need to represent a part-whole hierarchy and treat individual objects and groups of objects uniformly.
Signs you might need it:
If managing hierarchies of objects results in duplicated logic or complicated client code that treats individual and composite objects differently.
public interface JSONElement {
String toJSONString();
}
public class JSONValue implements JSONElement {
private Object value;
public JSONValue(Object value) {
this.value = value;
}
@Override
public String toJSONString() {
return (value instanceof String) ? "\"" + value + "\"" : value.toString();
}
}
public class JSONArray implements JSONElement {
private List<JSONElement> elements = new ArrayList<>();
public void add(JSONElement element) {
elements.add(element);
}
@Override
public String toJSONString() {
return elements.stream()
.map(JSONElement::toJSONString)
.collect(Collectors.joining(", ", "[", "]"));
}
}
public class JSONObject implements JSONElement {
private Map<String, JSONElement> elements = new HashMap<>();
public void add(String key, JSONElement element) {
elements.put(key, element);
}
@Override
public String toJSONString() {
return elements.entrySet().stream()
.map(e -> "\"" + e.getKey() + "\": " + e.getValue().toJSONString())
.collect(Collectors.joining(", ", "{", "}"));
}
}
public class Client {
public static void main(String[] args) {
JSONObject root = new JSONObject();
root.add("title", new JSONValue("Design Patterns"));
JSONArray authors = new JSONArray();
authors.add(new JSONValue("Gamma"));
authors.add(new JSONValue("Helm"));
authors.add(new JSONValue("Johnson"));
authors.add(new JSONValue("Vlissides"));
root.add("authors", authors);
System.out.println(root.toJSONString());
}
}
Decorator:
Use when you need to dynamically add or modify behavior to an object without affecting others.
Signs you might need it:
If extending the functionality of a class leads to an explosion of subclasses for every possible variation.
public interface DataSource {
void writeData(String data);
String readData();
}
public class FileDataSource implements DataSource {
private String filename;
public FileDataSource(String filename) {
this.filename = filename;
}
@Override
public void writeData(String data) {
System.out.println("Writing data to " + filename);
}
@Override
public String readData() {
return "Reading data from " + filename;
}
}
public class CompressionDecorator implements DataSource {
private DataSource source;
public CompressionDecorator(DataSource source) {
this.source = source;
}
@Override
public void writeData(String data) {
source.writeData(compress(data));
}
@Override
public String readData() {
return decompress(source.readData());
}
private String compress(String data) {
return "Compressed[" + data + "]";
}
private String decompress(String data) {
return data.replace("Compressed[", "").replace("]", "");
}
}
public class EncryptionDecorator implements DataSource {
private DataSource source;
public EncryptionDecorator(DataSource source) {
this.source = source;
}
@Override
public void writeData(String data) {
source.writeData(encrypt(data));
}
@Override
public String readData() {
return decrypt(source.readData());
}
private String encrypt(String data) {
return "Encrypted[" + data + "]";
}
private String decrypt(String data) {
return data.replace("Encrypted[", "").replace("]", "");
}
}
public class Client {
public static void main(String[] args) {
DataSource dataSource = new FileDataSource("data.txt");
DataSource wrapper = new EncryptionDecorator(new CompressionDecorator(dataSource));
wrapper.writeData("Important Data");
System.out.println(wrapper.readData());
}
}
Proxy:
Use when you need to control access to an object, for purposes like lazy initialization, logging, or access control.
Signs you might need it:
If direct access to resource-intensive or sensitive objects causes performance issues or security risks.
public interface Database {
void query(String sql);
}
public class RealDatabase implements Database {
public RealDatabase() {
connectToDatabase();
}
private void connectToDatabase() {
System.out.println("Connecting to the database...");
}
@Override
public void query(String sql) {
System.out.println("Executing query: " + sql);
}
}
public class DatabaseProxy implements Database {
private RealDatabase realDatabase;
@Override
public void query(String sql) {
if (realDatabase == null) {
realDatabase = new RealDatabase();
}
realDatabase.query(sql);
}
}
public class Client {
public static void main(String[] args) {
Database db = new DatabaseProxy();
db.query("SELECT * FROM Users");
}
}
Behavioral Patterns
Observer:
Use when an object needs to notify multiple dependent objects of state changes, maintaining a one-to-many relationship.
Signs you might need it:
If manually updating multiple dependent objects becomes error-prone and leads to tight coupling between the subject and its observers.
public interface Observer {
void update(String message);
}
public class Subscriber implements Observer {
private String name;
public Subscriber(String name) {
this.name = name;
}
@Override
public void update(String message) {
System.out.println(name + " received update: " + message);
}
}
public class Publisher {
private List<Observer> observers = new ArrayList<>();
public void subscribe(Observer observer) {
observers.add(observer);
}
public void unsubscribe(Observer observer) {
observers.remove(observer);
}
public void notifyObservers(String message) {
for (Observer observer : observers) {
observer.update(message);
}
}
}
public class Client {
public static void main(String[] args) {
Publisher newsPublisher = new Publisher();
Observer alice = new Subscriber("Alice");
Observer bob = new Subscriber("Bob");
newsPublisher.subscribe(alice);
newsPublisher.subscribe(bob);
newsPublisher.notifyObservers("Breaking News: Design Patterns Book is Awesome!");
}
}
Strategy:
Use when you want to define a family of algorithms, encapsulate them, and make them interchangeable at runtime.
Signs you might need it:
If hardcoded algorithms or behaviors make the system inflexible and difficult to extend or modify.
public interface PaymentStrategy {
void pay(int amount);
}
public class CreditCardPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("Paid " + amount + " using credit card.");
}
}
public class BankTransferPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("Paid " + amount + " using bank transfer.");
}
}
public class Client {
public static void main(String[] args) {
PaymentStrategy paymentStrategy;
int totalAmount = 1200;
if (totalAmount > 1000) {
paymentStrategy = new BankTransferPayment();
} else {
paymentStrategy = new CreditCardPayment();
}
paymentStrategy.pay(totalAmount);
}
}
Template Method:
Use when you need to define the skeleton of an algorithm but allow subclasses to override specific steps.
Signs you might need it:
If duplicated logic for similar algorithms creates maintenance challenges and increases the likelihood of inconsistencies.
public interface GameSteps {
void initialize();
void startPlay();
void endPlay();
}
public class Game {
private final GameSteps steps;
public Game(GameSteps steps) {
this.steps = steps;
}
public void play() {
steps.initialize();
steps.startPlay();
steps.endPlay();
}
}
public class Chess implements GameSteps {
@Override
public void initialize() {
System.out.println("Chess Game Initialized!");
}
@Override
public void startPlay() {
System.out.println("Chess Game Started!");
}
@Override
public void endPlay() {
System.out.println("Chess Game Finished!");
}
}
public class Client {
public static void main(String[] args) {
Game chess = new Game(new Chess());
chess.play();
}
}
Command:
Use when you need to encapsulate requests as objects, enabling parameterization, queuing, or undo operations.
Signs you might need it:
If request handling logic is tightly coupled to specific actions, making it hard to implement features like queuing, logging, or undo.
public interface Command {
void execute();
}
public class Light {
public void turnOn() {
System.out.println("Light is ON");
}
public void turnOff() {
System.out.println("Light is OFF");
}
}
public class TurnOnLightCommand implements Command {
private Light light;
public TurnOnLightCommand(Light light) {
this.light = light;
}
@Override
public void execute() {
light.turnOn();
}
}
public class TurnOffLightCommand implements Command {
private Light light;
public TurnOffLightCommand(Light light) {
this.light = light;
}
@Override
public void execute() {
light.turnOff();
}
}
public class Client {
public static void main(String[] args) {
Light light = new Light();
Command[] commands = new Command[] {
new TurnOnLightCommand(light),
new TurnOffLightCommand(light)
};
for (Command command : commands) {
command.execute();
}
}
}
Chain of Responsibility:
Use when multiple objects might handle a request, but the specific handler is determined at runtime.
Signs you might need it:
If request handling becomes rigid and changes to handlers require altering the request-sender code.
public abstract class Handler {
private Handler next;
public void setNext(Handler next) {
this.next = next;
}
public void handleRequest(String request) {
process(request);
if (next != null) {
next.handleRequest(request);
}
}
protected abstract void process(String request);
}
public class LoggingHandler extends Handler {
@Override
protected void process(String request) {
System.out.println("Logging request: " + request);
}
}
public class AuthenticationHandler extends Handler {
@Override
protected void process(String request) {
System.out.println("Authentication successful.");
}
}
public class AuthorizationHandler extends Handler {
@Override
protected void process(String request) {
System.out.println("Authorization successful.");
}
}
public class Client {
public static void main(String[] args) {
List<Handler> middleware = new ArrayList<>();
middleware.add(new LoggingHandler());
middleware.add(new AuthenticationHandler());
middleware.add(new AuthorizationHandler());
for (int i = 0; i < middleware.size() - 1; i++) {
middleware.get(i).setNext(middleware.get(i + 1));
}
middleware.get(0).handleRequest("Request data");
}
}
Mediator:
Use when you need to reduce the direct communication dependencies between objects by centralizing interactions.
Signs you might need it:
If direct communication between objects leads to overly complex and tightly coupled dependencies.
public interface Mediator {
void addConsumer(Consumer consumer);
void addProducer(Producer producer);
void sendMessage(Producer producer, String message);
String readMessage(Consumer consumer);
}
public class MessageQueue implements Mediator {
private final List<Consumer> consumers = new ArrayList<>();
private final List<Producer> producers = new ArrayList<>();
private final Queue<String> messageQueue = new ArrayDeque<>();
@Override
public void addConsumer(Consumer consumer) {
consumers.add(consumer);
}
@Override
public void addProducer(Producer producer) {
producers.add(producer);
}
@Override
public void sendMessage(Producer producer, String message) {
if (!producers.contains(producer)) {
System.out.println("Producer not registered with the messaging queue.");
return;
}
System.out.println("MessageQueue received message: " + message);
messageQueue.offer(message);
}
@Override
public String readMessage(Consumer consumer) {
if (!consumers.contains(consumer)) {
System.out.println("Consumer not registered with the messaging queue.");
return null;
}
System.out.println("MessageQueue sent message: " + message);
return messageQueue.poll();
}
}
public class Producer {
private final Mediator messageQueue;
private final String name;
public Producer(Mediator messageQueue, String name) {
this.messageQueue = messageQueue;
this.name = name;
}
public void produceMessage(String message) {
System.out.println(name + " sending: " + message);
messageQueue.sendMessage(this, message);
}
}
public class Consumer {
private final Mediator messageQueue;
private final String name;
public Consumer(Mediator messageQueue, String name) {
this.messageQueue = messageQueue;
this.name = name;
}
public void readMessage() {
String message = messageQueue.readMessage(this);
if (message) {
System.out.println(name + " read: " + message);
} else {
System.out.println(name + " found no messages to read.");
}
}
}
public class Client {
public static void main(String[] args) {
MessageQueue queue = new MessageQueue();
Producer producer = new Producer(queue, "Producer");
queue.addProducer(producer);
Consumer consumer1 = new Consumer(queue, "Consumer1");
Consumer consumer2 = new Consumer(queue, "Consumer2");
queue.addConsumer(consumer1);
queue.addConsumer(consumer2);
producer.produceMessage("Message 1");
producer.produceMessage("Message 2");
consumer1.readMessage();
consumer2.readMessage();
}
}
Visitor:
Use when you need to perform new operations on elements of a complex object structure without modifying the elements.
Signs you might need it:
If adding new operations to a structure of objects requires modifying the objects themselves, breaking encapsulation and increasing rigidity.
public interface Visitor {
void visit(Book book);
void visit(Game game);
}
public interface ShoppingItem {
void accept(Visitor visitor);
}
public class Book implements ShoppingItem {
private String title;
private double price;
public Book(String title, double price) {
this.title = title;
this.price = price;
}
public String getTitle() {
return title;
}
public double getPrice() {
return price;
}
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
public class Game implements ShoppingItem {
private String name;
private double price;
public Game(String name, double price) {
this.name = name;
this.price = price;
}
public String getName() {
return name;
}
public double getPrice() {
return price;
}
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
public class CheckoutVisitor implements Visitor {
private double totalCost = 0;
@Override
public void visit(Book book) {
totalCost += book.getPrice();
System.out.println("Book added to cart: " + book.getTitle() + " (Price: " + book.getPrice() + ")");
}
@Override
public void visit(Game game) {
totalCost += game.getPrice();
System.out.println("Game added to cart: " + game.getName() + " (Price: " + game.getPrice() + ")");
}
public double getTotalCost() {
return totalCost;
}
}
public class DiscountVisitor implements Visitor {
private double totalDiscount = 0;
@Override
public void visit(Book book) {
double discount = calculateBookDiscount(book);
totalDiscount += discount;
System.out.println("Book: " + book.getTitle() + " - Discount applied: " + discount);
}
@Override
public void visit(Game game) {
double discount = calculateGameDiscount(game);
totalDiscount += discount;
System.out.println("Game: " + game.getName() + " - Discount applied: " + discount);
}
private double calculateBookDiscount(Book book) {
if (book.getPrice() > 30) {
return book.getPrice() * 0.10;
}
return 0;
}
private double calculateGameDiscount(Game game) {
if (game.getPrice() > 50) {
return game.getPrice() * 0.15;
}
return 0;
}
public double getTotalDiscount() {
return totalDiscount;
}
}
public class Client {
public static void main(String[] args) {
ShoppingItem[] shoppingCart = new ShoppingItem[]{
new Book("Design Patterns", 45.99),
new Game("Chess", 19.99)
};
CheckoutVisitor checkoutVisitor = new CheckoutVisitor();
for (ShoppingItem item : shoppingCart) {
item.accept(checkoutVisitor);
}
System.out.println("Total cost: " + checkoutVisitor.getTotalCost());
DiscountVisitor discountVisitor = new DiscountVisitor();
for (ShoppingItem item : shoppingCart) {
item.accept(discountVisitor);
}
System.out.println("Total discount: " + discountVisitor.getTotalDiscount());
}
}