Project: Applying Patterns Together
Individual patterns are tools. Real architecture is knowing which tools to pick and how to wire them together without over-engineering. In this capstone lesson you will build a small but realistic notification dispatch system — a domain that appears in virtually every production codebase — while deliberately composing five patterns: Singleton, Factory Method, Builder, Strategy, and Observer. After each pattern is introduced into the code you will see why it was chosen over alternatives.
Ground rules for this project: Keep every class small and single-purpose. Prefer composition over inheritance. Let the patterns emerge from real requirements — do not reach for a pattern unless a concrete problem motivates it.
The domain: notification dispatch
The system must:
- Support multiple delivery channels (email, SMS, push notification) — new channels must be addable without touching existing code.
- Build complex notification payloads through a fluent API without a telescoping constructor.
- Route notifications to a configurable sending strategy (immediate, batched, or throttled) that can be swapped at runtime.
- Allow interested components (audit logger, metrics counter, UI badge) to react when a notification is dispatched, without coupling them to the dispatcher.
- Keep a single shared dispatcher instance across the application.
Step 1 — Singleton: the NotificationDispatcher
The dispatcher owns the channel registry and the delivery queue. Every part of the application must use the same instance — a classic Singleton use case. Use the enum idiom; it is thread-safe by the JVM specification and immune to reflective attacks.
// Thread-safe Singleton via enum
public enum NotificationDispatcher {
INSTANCE;
private DeliveryStrategy strategy = new ImmediateDeliveryStrategy();
private final List<DispatchObserver> observers = new ArrayList<>();
private final Map<String, NotificationChannel> channels = new HashMap<>();
public void registerChannel(NotificationChannel channel) {
channels.put(channel.channelName(), channel);
}
public void setStrategy(DeliveryStrategy strategy) {
this.strategy = strategy;
}
public void addObserver(DispatchObserver observer) {
observers.add(observer);
}
public void dispatch(Notification notification) {
NotificationChannel channel = channels.get(notification.channel());
if (channel == null) throw new IllegalArgumentException(
"No channel registered: " + notification.channel());
strategy.deliver(notification, channel);
observers.forEach(o -> o.onDispatched(notification));
}
}
Why enum Singleton over double-checked locking? The JVM guarantees that enum constants are initialised exactly once per class-loader, making the pattern inherently thread-safe. Double-checked locking requires a volatile field and careful reasoning about memory visibility — the enum approach is simpler and correct by construction.
Step 2 — Factory Method: notification channels
Each channel (email, SMS, push) has a completely different sending mechanism. A factory method isolates that construction decision behind a stable interface, so the dispatcher never depends on concrete channel classes.
// Product interface
public interface NotificationChannel {
String channelName();
void send(Notification notification);
}
// Concrete products
public class EmailChannel implements NotificationChannel {
@Override public String channelName() { return "email"; }
@Override public void send(Notification n) {
System.out.printf("[EMAIL] To: %s | Subject: %s%n",
n.recipient(), n.subject());
}
}
public class SmsChannel implements NotificationChannel {
@Override public String channelName() { return "sms"; }
@Override public void send(Notification n) {
System.out.printf("[SMS] To: %s | Body: %s%n",
n.recipient(), n.body());
}
}
public class PushChannel implements NotificationChannel {
@Override public String channelName() { return "push"; }
@Override public void send(Notification n) {
System.out.printf("[PUSH] Device: %s | Title: %s%n",
n.recipient(), n.subject());
}
}
// Factory — decouples channel creation from the dispatcher
public class ChannelFactory {
public static NotificationChannel create(String type) {
return switch (type.toLowerCase()) {
case "email" -> new EmailChannel();
case "sms" -> new SmsChannel();
case "push" -> new PushChannel();
default -> throw new IllegalArgumentException("Unknown channel: " + type);
};
}
}
Step 3 — Builder: constructing Notification objects
A Notification has a recipient, channel, subject, body, priority, and optional metadata. A telescoping constructor would have six overloads. The Builder pattern solves this with a fluent, readable API and validates required fields at build() time.
// Immutable value object
public record Notification(
String recipient,
String channel,
String subject,
String body,
int priority,
Map<String, String> metadata
) {
public static Builder builder() { return new Builder(); }
public static class Builder {
private String recipient;
private String channel;
private String subject = "(no subject)";
private String body = "";
private int priority = 5;
private final Map<String, String> metadata = new LinkedHashMap<>();
public Builder to(String recipient) { this.recipient = recipient; return this; }
public Builder via(String channel) { this.channel = channel; return this; }
public Builder subject(String subject) { this.subject = subject; return this; }
public Builder body(String body) { this.body = body; return this; }
public Builder priority(int priority) { this.priority = priority; return this; }
public Builder meta(String key, String val) { metadata.put(key, val); return this; }
public Notification build() {
Objects.requireNonNull(recipient, "recipient is required");
Objects.requireNonNull(channel, "channel is required");
return new Notification(recipient, channel, subject, body,
priority, Map.copyOf(metadata));
}
}
}
Records as Builder targets: Java records (record, Java 16+) generate equals, hashCode, and a canonical constructor automatically. Pairing a record with a nested Builder gives you immutability plus a pleasant construction API without writing any accessor boilerplate.
Step 4 — Strategy: delivery behaviour
The dispatcher must support three delivery modes without hardcoding any of them. The Strategy pattern extracts each mode into its own class and lets the caller swap strategies at runtime — without any if/else in the dispatcher itself.
// Strategy interface
public interface DeliveryStrategy {
void deliver(Notification notification, NotificationChannel channel);
}
// Strategy A: send immediately
public class ImmediateDeliveryStrategy implements DeliveryStrategy {
@Override
public void deliver(Notification n, NotificationChannel ch) {
ch.send(n);
}
}
// Strategy B: batch and flush periodically
public class BatchDeliveryStrategy implements DeliveryStrategy {
private final List<Map.Entry<Notification, NotificationChannel>> batch = new ArrayList<>();
private final int batchSize;
public BatchDeliveryStrategy(int batchSize) { this.batchSize = batchSize; }
@Override
public void deliver(Notification n, NotificationChannel ch) {
batch.add(Map.entry(n, ch));
if (batch.size() >= batchSize) flush();
}
public void flush() {
batch.forEach(e -> e.getValue().send(e.getKey()));
batch.clear();
System.out.println("[BATCH] Flushed " + batchSize + " notifications");
}
}
// Strategy C: throttled — at most N per minute (simplified)
public class ThrottledDeliveryStrategy implements DeliveryStrategy {
private final int maxPerMinute;
private int sentThisMinute = 0;
public ThrottledDeliveryStrategy(int maxPerMinute) {
this.maxPerMinute = maxPerMinute;
}
@Override
public void deliver(Notification n, NotificationChannel ch) {
if (sentThisMinute >= maxPerMinute) {
System.out.println("[THROTTLE] Rate limit reached — dropping: " + n.subject());
return;
}
ch.send(n);
sentThisMinute++;
}
}
Step 5 — Observer: reacting to dispatched notifications
An audit logger, a metrics counter, and a UI badge all need to know when a notification is sent — but none of them should be coupled to the dispatcher's dispatch loop. The Observer pattern lets them subscribe independently.
// Observer interface
public interface DispatchObserver {
void onDispatched(Notification notification);
}
// Concrete observers
public class AuditLogger implements DispatchObserver {
@Override
public void onDispatched(Notification n) {
System.out.printf("[AUDIT] channel=%s recipient=%s subject=%s priority=%d%n",
n.channel(), n.recipient(), n.subject(), n.priority());
}
}
public class MetricsCounter implements DispatchObserver {
private final Map<String, Long> counts = new HashMap<>();
@Override
public void onDispatched(Notification n) {
counts.merge(n.channel(), 1L, Long::sum);
}
public void printReport() {
counts.forEach((ch, cnt) ->
System.out.printf("[METRICS] %s: %d sent%n", ch, cnt));
}
}
Wiring it all together
The main method below is the only place that knows about concrete types. Everything the dispatcher touches is accessed through interfaces.
import java.util.*;
public class Main {
public static void main(String[] args) {
// --- Factory: build channels, register with Singleton dispatcher ---
NotificationDispatcher dispatcher = NotificationDispatcher.INSTANCE;
dispatcher.registerChannel(ChannelFactory.create("email"));
dispatcher.registerChannel(ChannelFactory.create("sms"));
dispatcher.registerChannel(ChannelFactory.create("push"));
// --- Observer: attach listeners ---
AuditLogger audit = new AuditLogger();
MetricsCounter metrics = new MetricsCounter();
dispatcher.addObserver(audit);
dispatcher.addObserver(metrics);
// --- Strategy: start with immediate delivery ---
dispatcher.setStrategy(new ImmediateDeliveryStrategy());
// --- Builder: construct notifications fluently ---
Notification welcome = Notification.builder()
.to("alice@example.com")
.via("email")
.subject("Welcome to the platform")
.body("Hi Alice, your account is ready.")
.priority(3)
.meta("template", "welcome-v2")
.build();
Notification otp = Notification.builder()
.to("+447700900123")
.via("sms")
.subject("OTP")
.body("Your code is 482910")
.priority(1)
.build();
Notification alert = Notification.builder()
.to("device-token-xyz")
.via("push")
.subject("New message")
.body("You have an unread message.")
.priority(5)
.build();
// Dispatch — each call triggers channel.send() + all observers
dispatcher.dispatch(welcome);
dispatcher.dispatch(otp);
dispatcher.dispatch(alert);
// --- Strategy swap: switch to batch mid-run ---
BatchDeliveryStrategy batch = new BatchDeliveryStrategy(2);
dispatcher.setStrategy(batch);
dispatcher.dispatch(Notification.builder().to("bob@example.com")
.via("email").subject("Newsletter").body("Issue #42").build());
dispatcher.dispatch(Notification.builder().to("bob@example.com")
.via("email").subject("Invoice ready").body("See attachment.").build());
// batchSize=2 is reached — auto-flush fires here
metrics.printReport();
}
}
Reading the call trace: When dispatcher.dispatch(welcome) runs, control flows through (1) the enum Singleton to get the registered EmailChannel; (2) the current ImmediateDeliveryStrategy which delegates to channel.send(); (3) every DispatchObserver in turn. No class that implements any of these interfaces is aware of the others — all coordination is in the wiring code inside main.
Trade-offs and when to stop
Combining patterns can veer into over-engineering. Ask these questions before adding each one:
- Singleton: Is there a genuine application-wide uniqueness constraint, or would a plain constructor and careful wiring suffice? If tests become hard to isolate, consider injecting the dispatcher instead.
- Factory: Would a simple switch in the caller be clearer? Use a Factory when the number of products will grow or when construction is complex.
- Builder: Justified once a class has four or more parameters or any optional ones. For one or two mandatory fields, a constructor is simpler.
- Strategy: Worth the extra interface when two or more algorithms exist and runtime switching is genuinely needed. If only one algorithm will ever exist, the pattern is premature.
- Observer: Essential when the number of interested parties is open-ended or unknown at compile time. Avoid it if you have exactly one listener that never changes.
Pattern sprawl: A codebase with five patterns applied to a 50-line problem is not well-designed — it is over-architected. The goal is to minimise accidental complexity. Always validate that a pattern removes more coupling than the abstraction layers it introduces.
Summary
In this project you saw five patterns working in concert: the Singleton enum held the shared dispatcher; the Factory Method encapsulated channel creation; the Builder produced clean, validated notification objects; the Strategy made delivery mode a runtime decision; and the Observer let unrelated listeners react to dispatches without coupling. The key professional insight is that patterns are communication tools as much as design tools — when a teammate reads ChannelFactory.create() or sees a class named DeliveryStrategy, they immediately understand the design intent. Use that shared vocabulary deliberately, and resist adding patterns until the problem they solve is concretely present.