Design Patterns
What Are Design Patterns?
Design patterns are reusable solutions to commonly occurring problems in software design. They are not code you copy — they are blueprints you adapt.
The 23 classic patterns come from a 1994 book, Design Patterns, written by four authors — nicknamed the "Gang of Four" (often shortened to GoF). So whenever you see "GoF patterns", it just means "the 23 patterns from that book".
| Category | Patterns in this file | Purpose |
|---|---|---|
| Creational | Factory, Singleton, Builder | How objects are created |
| Behavioral | Observer, Strategy | How objects communicate & behave |
1. Factory Design Pattern
Category: Creational
What is Factory Pattern?
The Factory Pattern creates objects without exposing the instantiation logic to the caller. The caller asks for an object; the factory decides which class to instantiate.
Think of a vehicle factory. You say "give me a vehicle for city roads." The factory decides whether to give you a scooter, bicycle, or car. You don't know — or care — how it was built.
The Core Idea
Without Factory: caller does → new EmailNotification()
new SMSNotification()
new PushNotification()
(caller knows all classes, tightly coupled)
With Factory: caller does → NotificationFactory.create("EMAIL")
(caller knows nothing about concrete classes)
❌ Without Factory Pattern
class OrderService {
public void notifyUser(String type, String userId, String message) {
if (type.equals("EMAIL")) {
EmailNotification n = new EmailNotification(); // tightly coupled
n.send(userId, message);
} else if (type.equals("SMS")) {
SMSNotification n = new SMSNotification(); // tightly coupled
n.send(userId, message);
} else if (type.equals("PUSH")) {
PushNotification n = new PushNotification(); // tightly coupled
n.send(userId, message);
}
// Add WhatsApp? Modify OrderService — OCP violated
}
}
Problems:
OrderServiceknows about every concrete notification class- Adding new type = modify
OrderService— OCP violated - Hard to test — can't swap implementations
✅ With Factory Pattern
// Step 1: Define the interface (product)
interface Notification {
void send(String userId, String message);
}
// Step 2: Concrete implementations (products)
class EmailNotification implements Notification {
public void send(String userId, String message) {
System.out.println("Sending EMAIL to " + userId + ": " + message);
}
}
class SMSNotification implements Notification {
public void send(String userId, String message) {
System.out.println("Sending SMS to " + userId + ": " + message);
}
}
class PushNotification implements Notification {
public void send(String userId, String message) {
System.out.println("Sending PUSH to " + userId + ": " + message);
}
}
// Step 3: The Factory — creation logic lives here only
class NotificationFactory {
public static Notification create(String type) {
switch (type) {
case "EMAIL": return new EmailNotification();
case "SMS": return new SMSNotification();
case "PUSH": return new PushNotification();
default: throw new IllegalArgumentException("Unknown type: " + type);
}
}
}
// Step 4: Caller — clean, knows nothing about concrete classes
class OrderService {
public void notifyUser(String type, String userId, String message) {
Notification notification = NotificationFactory.create(type);
notification.send(userId, message);
// OrderService is unchanged when you add WhatsApp later
}
}
Real-World System Design Examples
1. Database Connection Factory
interface DBConnection { void query(String sql); }
class MySQLConnection implements DBConnection { ... }
class PostgresConnection implements DBConnection { ... }
class MongoConnection implements DBConnection { ... }
class DBConnectionFactory {
public static DBConnection create(String dbType) {
switch (dbType) {
case "MYSQL": return new MySQLConnection();
case "POSTGRES": return new PostgresConnection();
case "MONGO": return new MongoConnection();
}
}
}
// Read dbType from config — swap DB by changing config, not code
2. Payment Gateway Factory
PaymentGatewayFactory.create("RAZORPAY") → RazorpayGateway
PaymentGatewayFactory.create("STRIPE") → StripeGateway
PaymentGatewayFactory.create("PAYTM") → PaytmGateway
3. Logger Factory
LoggerFactory.create("FILE") → FileLogger
LoggerFactory.create("CONSOLE") → ConsoleLogger
LoggerFactory.create("CLOUD") → CloudWatchLogger
Interview Talking Point
"I use Factory when object creation logic is complex or the type of object isn't known until runtime. For a notification system, instead of if/else chains in
OrderService, aNotificationFactory.create(type)centralises all creation logic. Add a new channel = add a class + one line in the factory.OrderServicenever changes. It also respects OCP — the factory is the only place that changes when you add new types."
Quick Revision
- Factory creates objects without the caller knowing the concrete class
- Creation logic lives in ONE place — the factory
- Caller depends on interface, not concrete classes
- Adding new type = new class + one line in factory (OCP respected)
- Used for: notification channels, DB connections, payment gateways, loggers
2. Singleton Design Pattern
Category: Creational
What is Singleton Pattern?
Singleton ensures only one instance of a class exists throughout the application's lifetime, and provides a global access point to that instance.
Think of a country's president. There is only one at a time. Everyone who asks "who is the president?" gets the same person.
The Core Idea
Normal class: new DatabasePool() → new instance every time
Singleton: DatabasePool.getInstance() → always the SAME instance
Use Singleton when having multiple instances would cause problems:
- Multiple DB connection pools wasting resources
- Multiple config loaders causing inconsistency
- Multiple loggers writing to the same file simultaneously
Basic Implementation (Not Thread-Safe — Don't Use in Production)
class DatabaseConnectionPool {
private static DatabaseConnectionPool instance; // single instance stored here
private DatabaseConnectionPool() {
// private constructor — prevents new DatabaseConnectionPool() from outside
System.out.println("Connection pool initialized");
}
public static DatabaseConnectionPool getInstance() {
if (instance == null) {
instance = new DatabaseConnectionPool(); // created only once
}
return instance;
}
public void executeQuery(String sql) {
System.out.println("Executing: " + sql);
}
}
// Usage
DatabaseConnectionPool pool1 = DatabaseConnectionPool.getInstance();
DatabaseConnectionPool pool2 = DatabaseConnectionPool.getInstance();
System.out.println(pool1 == pool2); // true — same instance
✅ Thread-Safe Singleton (Use This in Production)
The basic version breaks in multi-threaded environments — two threads can both pass the if (instance == null) check and create two instances.
Option 1: Double-Checked Locking (Most Common in Interviews)
class DatabaseConnectionPool {
// volatile ensures visibility across threads
private static volatile DatabaseConnectionPool instance;
private DatabaseConnectionPool() { }
public static DatabaseConnectionPool getInstance() {
if (instance == null) { // First check (no lock)
synchronized (DatabaseConnectionPool.class) {
if (instance == null) { // Second check (with lock)
instance = new DatabaseConnectionPool();
}
}
}
return instance;
}
}
Why double-check? First check avoids locking every time (performance). Second check inside the lock prevents duplicate creation by racing threads.
Option 2: Bill Pugh Singleton (Cleanest — Preferred)
class ConfigManager {
private ConfigManager() { }
// Inner static class — loaded only when getInstance() is called
private static class SingletonHolder {
private static final ConfigManager INSTANCE = new ConfigManager();
}
public static ConfigManager getInstance() {
return SingletonHolder.INSTANCE;
}
}
Thread-safe by JVM class-loading guarantee. No synchronized needed. Lazy-loaded.
Option 3: Enum Singleton (Safest — Prevents Reflection Attacks)
enum AppConfig {
INSTANCE;
private String dbUrl = "jdbc:mysql://localhost/mydb";
public String getDbUrl() { return dbUrl; }
}
// Usage
AppConfig.INSTANCE.getDbUrl();
Enum guarantees one instance even under serialization and reflection.
Real-World System Design Examples
Where Singleton Is Used
1. Database Connection Pool → one pool shared across all requests
2. Configuration Manager → one source of truth for app config
3. Logger → one logger instance writing to one file
4. Cache Manager (in-process) → one shared in-memory cache
5. Thread Pool → one pool of worker threads
6. Service Registry → one registry for service discovery
Spring Framework — Everything is Singleton by Default
@Service // Spring creates ONE instance, shared across the entire application
class PaymentService {
@Autowired
private PaymentRepository repo; // also a singleton
}
In Spring, all @Service, @Repository, @Controller beans are singletons by default.
Problems With Singleton (Know These for Interviews)
| Problem | Why it matters |
|---|---|
| Global state | Any part of the app can modify it — hard to track bugs |
| Hard to test | Can't easily mock/replace in unit tests |
| Hidden dependency | Classes silently depend on it — not visible in constructor |
| Concurrency issues | Mutable state in singleton needs careful synchronisation |
| Violates SRP sometimes | Singletons often accumulate too many responsibilities |
In interviews: say "I'd use Singleton for stateless or read-only shared resources like config or logger. For stateful resources, I'd use dependency injection with a singleton scope managed by the framework (like Spring), not a hand-rolled Singleton."
Interview Talking Point
"Singleton ensures one instance for the lifetime of the app. Classic use cases: DB connection pool, config manager, logger. In production Java, I prefer the Bill Pugh pattern — thread-safe, lazy-loaded, no synchronisation overhead. Or I let Spring manage singleton scope via
@Service, which also makes it injectable and testable. The risk with Singleton is global mutable state — I keep singletons stateless or read-only wherever possible."
Quick Revision
- One instance, global access point, created once
- Private constructor + static
getInstance()method - Thread-safety: use Double-Checked Locking or Bill Pugh pattern
- Spring beans are singletons by default — framework manages lifecycle
- Risk: global state, hard to test — keep singletons stateless or read-only
3. Builder Design Pattern
Category: Creational
What is Builder Pattern?
Builder Pattern constructs complex objects step by step, letting you produce different types and representations of an object using the same construction process.
Think of ordering a custom burger. You say: add bun, add patty, add cheese, add lettuce, no onions. The cashier (builder) assembles it step by step. You get exactly what you specified — nothing unwanted.
The Core Idea
Problem: A class with 10 fields. Constructor becomes monstrous.
new User("Alice", "alice@email.com", null, null, 25, null, "ADMIN", true, null, "IN")
Solution: Build it step by step, set only what you need.
new User.Builder()
.name("Alice")
.email("alice@email.com")
.age(25)
.role("ADMIN")
.country("IN")
.build();
❌ Without Builder — Telescoping Constructor Problem
class HttpRequest {
private String url;
private String method;
private String body;
private Map<String, String> headers;
private int timeoutMs;
private boolean followRedirects;
private String authToken;
private int retryCount;
// You need multiple constructors for different combinations — telescoping
public HttpRequest(String url, String method) { ... }
public HttpRequest(String url, String method, String body) { ... }
public HttpRequest(String url, String method, String body, int timeoutMs) { ... }
public HttpRequest(String url, String method, String body, int timeoutMs, String authToken) { ... }
// ... grows forever, becomes unreadable
}
// Caller has no idea what each argument means
HttpRequest req = new HttpRequest("https://api.example.com", "POST", "{}", 5000, null, true, "token123", 3);
// ↑ timeout? retries? what is this?
✅ With Builder Pattern
class HttpRequest {
// All fields private, set only via builder
private final String url;
private final String method;
private final String body;
private final Map<String, String> headers;
private final int timeoutMs;
private final boolean followRedirects;
private final String authToken;
private final int retryCount;
// Private constructor — only Builder can call this
private HttpRequest(Builder builder) {
this.url = builder.url;
this.method = builder.method;
this.body = builder.body;
this.headers = builder.headers;
this.timeoutMs = builder.timeoutMs;
this.followRedirects = builder.followRedirects;
this.authToken = builder.authToken;
this.retryCount = builder.retryCount;
}
// Static inner Builder class
public static class Builder {
private String url; // required
private String method = "GET"; // optional with default
private String body;
private Map<String, String> headers = new HashMap<>();
private int timeoutMs = 3000; // default
private boolean followRedirects = true;
private String authToken;
private int retryCount = 0;
public Builder(String url) { // required field in constructor
this.url = url;
}
public Builder method(String method) { this.method = method; return this; }
public Builder body(String body) { this.body = body; return this; }
public Builder header(String key, String value) { headers.put(key, value); return this; }
public Builder timeoutMs(int ms) { this.timeoutMs = ms; return this; }
public Builder followRedirects(boolean follow) { this.followRedirects = follow; return this; }
public Builder authToken(String token) { this.authToken = token; return this; }
public Builder retryCount(int count) { this.retryCount = count; return this; }
public HttpRequest build() {
// Validation before building
if (url == null || url.isEmpty()) throw new IllegalStateException("URL is required");
return new HttpRequest(this);
}
}
}
// Usage — readable, self-documenting, only set what you need
HttpRequest request = new HttpRequest.Builder("https://api.example.com/orders")
.method("POST")
.body("{\"item\": \"shoes\"}")
.authToken("Bearer abc123")
.timeoutMs(5000)
.retryCount(3)
.build();
Real-World System Design Examples
1. SQL Query Builder
String query = new QueryBuilder()
.select("id", "name", "email")
.from("users")
.where("age > 18")
.and("country = 'IN'")
.orderBy("name", "ASC")
.limit(50)
.build();
// "SELECT id, name, email FROM users WHERE age > 18 AND country = 'IN' ORDER BY name ASC LIMIT 50"
2. Kafka Message Builder
KafkaMessage message = new KafkaMessage.Builder("order-events")
.key(orderId)
.payload(orderJson)
.header("source", "order-service")
.header("version", "v2")
.retentionMs(86400000)
.build();
3. API Response Builder
ApiResponse response = new ApiResponse.Builder()
.status(200)
.message("Order placed successfully")
.data(order)
.meta(new PageMeta(1, 10, 100))
.build();
4. Used in Java Standard Library
StringBuilder → append().append().toString()
Stream.Builder → add().add().build()
URI.Builder → scheme().host().path().build()
Interview Talking Point
"I use Builder when an object has many optional parameters or a complex construction process. The telescoping constructor anti-pattern — having 8 overloaded constructors for different combinations — is the sign that Builder is needed. With Builder, the caller sets only what's relevant, it's readable (method names are self-documenting), and I can add validation in
build()before the object is created. I've used it for HTTP request clients, API response wrappers, and query builders."
Quick Revision
- Builder = construct complex objects step by step, set only what you need
- Solves telescoping constructor problem — too many constructor overloads
return thisin each setter method enables method chaining (fluent API)- Validation goes in
build()— object is valid before it's created - Used in: HTTP clients, query builders, Kafka producers, API responses
4. Observer Design Pattern
Category: Behavioral
What is Observer Pattern?
Observer defines a one-to-many dependency between objects. When one object (Subject/Publisher) changes state, all its dependents (Observers/Subscribers) are automatically notified and updated.
Think of a YouTube channel. You subscribe (register as observer). When the creator uploads a video (state change), all subscribers get notified. Creator doesn't know who the subscribers are — it just notifies all of them.
The Core Idea
Subject (Publisher) Observers (Subscribers)
────────────────── ──────────────────────
Maintains a list of → EmailNotifier
observers SMSNotifier
PushNotifier
AnalyticsTracker
When state changes:
subject.notifyAll() → every observer's update() is called
✅ Implementation
// Observer interface — all observers implement this
interface Observer {
void update(String event, Object data);
}
// Subject interface — the publisher
interface Subject {
void subscribe(Observer observer);
void unsubscribe(Observer observer);
void notifyObservers(String event, Object data);
}
// Concrete Subject — Order in this case
class OrderService implements Subject {
private List<Observer> observers = new ArrayList<>();
private Order currentOrder;
public void subscribe(Observer observer) {
observers.add(observer);
}
public void unsubscribe(Observer observer) {
observers.remove(observer);
}
public void notifyObservers(String event, Object data) {
for (Observer observer : observers) {
observer.update(event, data); // notify all registered observers
}
}
public void placeOrder(Order order) {
this.currentOrder = order;
// Save order...
notifyObservers("ORDER_PLACED", order); // triggers all observers
}
public void cancelOrder(String orderId) {
// Cancel logic...
notifyObservers("ORDER_CANCELLED", orderId);
}
}
// Concrete Observers — each reacts differently to the same event
class EmailNotifier implements Observer {
public void update(String event, Object data) {
if (event.equals("ORDER_PLACED")) {
Order order = (Order) data;
System.out.println("EMAIL: Sending order confirmation for " + order.getId());
}
}
}
class InventoryManager implements Observer {
public void update(String event, Object data) {
if (event.equals("ORDER_PLACED")) {
Order order = (Order) data;
System.out.println("INVENTORY: Reducing stock for order " + order.getId());
}
}
}
class AnalyticsTracker implements Observer {
public void update(String event, Object data) {
System.out.println("ANALYTICS: Tracking event: " + event);
}
}
// Wiring — register observers
OrderService orderService = new OrderService();
orderService.subscribe(new EmailNotifier());
orderService.subscribe(new InventoryManager());
orderService.subscribe(new AnalyticsTracker());
// Trigger — all three are notified automatically
orderService.placeOrder(new Order("ORD001", "user123", 1500.00));
Real-World System Design Examples
1. Event-Driven Microservices (Observer at Scale)
OrderService places order
↓
Publishes event to Kafka topic: "order.placed"
↓
┌─────────────────────────────┐
│ EmailService subscribes │ → sends confirmation email
│ InventoryService subscribes│ → reduces stock
│ LoyaltyService subscribes │ → adds reward points
│ AnalyticsService subscribes│ → logs conversion
└─────────────────────────────┘
Each service is an Observer. Kafka is the Subject/broker. OrderService has no idea who is listening — completely decoupled.
2. Stock Price Alert System
StockPriceService (Subject)
→ PriceAlertObserver (notifies users when threshold crossed)
→ PortfolioObserver (recalculates portfolio value)
→ TradingBotObserver (triggers buy/sell automatically)
3. UI Event Systems (React/Angular)
Button click (Subject)
→ FormValidator (Observer)
→ SubmitHandler (Observer)
→ AnalyticsLogger (Observer)
Observer vs Pub/Sub — Know the Difference
| Aspect | Observer | Pub/Sub (Kafka, RabbitMQ) |
|---|---|---|
| Coupling | Subject knows observers (indirect) | Publisher doesn't know subscribers |
| Communication | Synchronous (direct call) | Asynchronous (via broker/queue) |
| Scale | In-process, same application | Across services/systems |
| Example | Java EventListener, React state | Kafka, RabbitMQ, AWS SNS |
Interview Talking Point
"Observer defines a one-to-many relationship — when an order is placed, the
OrderServicenotifies all registered observers: email service, inventory manager, analytics tracker. None of them are hardcoded intoOrderService— they register themselves. Adding a loyalty points service is just registering a new observer. At the system level, this is how Kafka works — order service publishes an event, multiple downstream services subscribe independently. The key benefit is decoupling:OrderServicedoesn't know or care who's listening."
Quick Revision
- Observer = one publisher (Subject), many subscribers (Observers)
- Subject maintains observer list; notifies all on state change
- Observers subscribe/unsubscribe at runtime — dynamic
- Decouples publisher from all consumers — publisher doesn't know who listens
- At system scale: Kafka topics, webhooks, event-driven microservices
5. Strategy Design Pattern
Category: Behavioral
What is Strategy Pattern?
Strategy defines a family of algorithms, encapsulates each one, and makes them interchangeable. The algorithm can be selected at runtime without changing the client that uses it.
Think of a GPS app. You choose your route strategy: fastest, shortest, avoid tolls, avoid highways. The app doesn't change — you just swap the routing strategy.
The Core Idea
Without Strategy:
if (type == "FASTEST") { ... routing algorithm A ... }
if (type == "SHORTEST") { ... routing algorithm B ... }
if (type == "AVOID_TOLLS") { ... routing algorithm C ... }
(OCP violated — new algorithm = modify existing code)
With Strategy:
interface RouteStrategy { Route calculate(Point from, Point to); }
FastestRoute implements RouteStrategy
ShortestRoute implements RouteStrategy
AvoidTollsRoute implements RouteStrategy
(Add new algorithm = new class only)
✅ Implementation
// Strategy interface — defines the algorithm contract
interface SortStrategy {
void sort(int[] array);
}
// Concrete strategies — different algorithms
class BubbleSortStrategy implements SortStrategy {
public void sort(int[] array) {
System.out.println("Sorting with Bubble Sort");
// bubble sort logic
}
}
class QuickSortStrategy implements SortStrategy {
public void sort(int[] array) {
System.out.println("Sorting with Quick Sort");
// quicksort logic
}
}
class MergeSortStrategy implements SortStrategy {
public void sort(int[] array) {
System.out.println("Sorting with Merge Sort");
// merge sort logic
}
}
// Context — uses a strategy, doesn't know the algorithm details
class DataSorter {
private SortStrategy strategy;
public DataSorter(SortStrategy strategy) {
this.strategy = strategy;
}
// Strategy can be swapped at runtime
public void setStrategy(SortStrategy strategy) {
this.strategy = strategy;
}
public void sort(int[] array) {
strategy.sort(array); // delegates to whichever strategy is set
}
}
// Usage — swap strategy without changing DataSorter
DataSorter sorter = new DataSorter(new QuickSortStrategy());
sorter.sort(new int[]{5, 3, 1, 4, 2}); // uses QuickSort
sorter.setStrategy(new MergeSortStrategy());
sorter.sort(new int[]{5, 3, 1, 4, 2}); // now uses MergeSort — same sorter, new algorithm
Practical Example — Payment Strategy
// Strategy interface
interface PaymentStrategy {
boolean pay(int amount);
}
// Concrete strategies
class CreditCardPayment implements PaymentStrategy {
private String cardNumber, cvv;
public CreditCardPayment(String cardNumber, String cvv) { ... }
public boolean pay(int amount) {
System.out.println("Paid ₹" + amount + " via Credit Card ending " + cardNumber.substring(12));
return true;
}
}
class UPIPayment implements PaymentStrategy {
private String upiId;
public UPIPayment(String upiId) { this.upiId = upiId; }
public boolean pay(int amount) {
System.out.println("Paid ₹" + amount + " via UPI: " + upiId);
return true;
}
}
class WalletPayment implements PaymentStrategy {
private double walletBalance;
public boolean pay(int amount) {
if (walletBalance < amount) return false;
walletBalance -= amount;
System.out.println("Paid ₹" + amount + " via Wallet");
return true;
}
}
// Context — Checkout
class CheckoutService {
private PaymentStrategy paymentStrategy;
public void setPaymentStrategy(PaymentStrategy strategy) {
this.paymentStrategy = strategy;
}
public void checkout(Cart cart) {
boolean success = paymentStrategy.pay(cart.getTotal());
if (success) System.out.println("Order confirmed!");
else System.out.println("Payment failed!");
}
}
// Usage — user picks payment method at runtime
CheckoutService checkout = new CheckoutService();
// User selects UPI
checkout.setPaymentStrategy(new UPIPayment("alice@okicici"));
checkout.checkout(cart);
// User switches to credit card
checkout.setPaymentStrategy(new CreditCardPayment("1234567890123456", "123"));
checkout.checkout(cart);
Strategy vs Factory — Know the Difference
| Aspect | Factory Pattern | Strategy Pattern |
|---|---|---|
| Purpose | Decides which object to create | Decides which algorithm to run |
| Focus | Object creation | Behaviour / algorithm selection |
| When decided | Usually at creation time | Can be changed at runtime |
| They work together | Factory can create the Strategy object to inject |
Real-World System Design Examples
1. Discount Calculation Strategy (E-commerce)
interface DiscountStrategy { double apply(double price); }
FlatDiscountStrategy → flat ₹100 off
PercentageStrategy → 15% off
LoyaltyStrategy → based on user tier
FlashSaleStrategy → time-based discount
CouponStrategy → coupon code discount
2. Log Routing Strategy
interface LogStrategy { void write(String log); }
ConsoleLogStrategy → development
FileLogStrategy → staging
CloudWatchStrategy → production AWS
DatadogStrategy → production monitoring
3. Load Balancing Strategy
interface LoadBalancingStrategy { Server select(List<Server> servers); }
RoundRobinStrategy → rotate through servers
LeastConnectionsStrategy → pick server with fewest active connections
WeightedStrategy → route more to powerful servers
RandomStrategy → random selection
4. Authentication Strategy (Passport.js style)
interface AuthStrategy { User authenticate(Request req); }
JWTStrategy → validate JWT token
OAuthStrategy → Google / GitHub OAuth
BasicAuthStrategy → username + password
APIKeyStrategy → API key validation
Interview Talking Point
"Strategy encapsulates algorithms behind an interface and makes them swappable at runtime. For payments, I define a
PaymentStrategyinterface —CreditCardPayment,UPIPayment,WalletPaymenteach implement it. The checkout service holds a reference to the interface and delegates to whichever strategy the user selected. No if/else in checkout logic. Adding a new payment method = one new class, zero changes elsewhere. At the system level, load balancers use this — Round Robin, Least Connections, Weighted — all are swappable routing strategies."
Quick Revision
- Strategy = family of algorithms, each in its own class, swappable at runtime
- Client (Context) holds a reference to the Strategy interface — not a concrete class
- Eliminates if/else chains that check "type" to pick an algorithm
- Strategy can be changed at runtime via setter (unlike Factory which decides at creation)
- Used for: payment methods, discount rules, sorting, routing, load balancing, auth
How This Connects
- Polymorphism / Abstraction — every pattern here is "define an interface, vary the implementation". Patterns are named recipes for applying those two.
- SOLID — Strategy & Factory are how you get Open/Closed; Repository & Gateway (Factory + Dependency Injection) are how you get Dependency Inversion.
- Dependency Injection — the "wiring" step of Factory/Strategy: something outside picks the implementation and hands it in. Spring's container is a Factory + Singleton manager.
- Composition vs Inheritance — Strategy, Observer, Builder are all composition; they let you vary behaviour without inheritance chains.
- Module 3 — HLD / Module 12 — Async — Observer scales up to Kafka topics and webhooks; Strategy scales up to swappable load-balancing / routing / auth; Factory scales up to "pick the implementation from config".
Design Patterns — Complete Summary Table
| Pattern | Type | Problem it solves | Key mechanism |
|---|---|---|---|
| Factory | Creational | Caller shouldn't know which class to instantiate | Factory method returns interface type |
| Singleton | Creational | Need exactly one shared instance | Private constructor + static instance |
| Builder | Creational | Complex object construction with many options | Fluent API with step-by-step setters |
| Observer | Behavioral | Notify multiple objects when state changes | Subscribe/notify with observer list |
| Strategy | Behavioral | Multiple algorithms for same task, swappable | Interface + swappable implementation |
Combined Interview Answer (If Asked "Name Design Patterns You Use")
"The patterns I reach for most in system design:
Factory when I need to create objects based on runtime conditions — like a
NotificationFactorythat returns Email, SMS, or Push implementations.Singleton for shared resources — DB connection pool, config manager. In Spring I rely on the framework's singleton scope instead of hand-rolling it.
Builder when constructing complex objects — HTTP request clients, API response wrappers, Kafka message producers with many optional fields.
Observer for event-driven flows — when an order is placed, I want inventory, email, and analytics to react without
OrderServiceknowing about any of them. This scales into Kafka topics at the system level.Strategy to eliminate if/else logic for swappable behaviour — payment methods, discount rules, load balancing algorithms. Each algorithm gets its own class; the context just holds the interface."
Optional. Nice to know, and it sometimes comes up as a follow-up: "a private constructor guarantees one instance, right?" — not quite. Here are the three ways a hand-written singleton can be broken into two objects, with a fix for each, and why the enum version can't be broken at all.
The victim — a normal singleton
public class Config {
public static final Config INSTANCE = new Config(); // the ONE instance
private Config() {
System.out.println("constructor ran");
}
}
The promise is that Config.INSTANCE is the only Config that ever exists.
Break 1 — Reflection
Reflection is a built-in Java feature that lets code inspect and call things at runtime, even members marked private. The call setAccessible(true) means "ignore the private keyword, let me use this anyway."
import java.lang.reflect.Constructor;
Config a = Config.INSTANCE; // the real singleton
Constructor<Config> ctor = Config.class.getDeclaredConstructor(); // grab the private constructor
ctor.setAccessible(true); // "let me call it anyway"
Config b = ctor.newInstance(); // a SECOND Config is created
System.out.println(a == b); // false -> two objects. broken.
Fix: make the constructor refuse a second call.
private Config() {
if (INSTANCE != null)
throw new IllegalStateException("Use Config.INSTANCE");
}
Break 2 — Serialization
Serialization turns an object into bytes (to save to a file or send over a network). Deserialization rebuilds it from those bytes — and it builds a brand-new object: it does not run your constructor and does not return your existing INSTANCE.
public class Config implements java.io.Serializable { // now serializable
public static final Config INSTANCE = new Config();
private Config() {}
}
Config a = Config.INSTANCE;
try (var out = new ObjectOutputStream(new FileOutputStream("config.ser"))) {
out.writeObject(a); // write to a file
}
Config b;
try (var in = new ObjectInputStream(new FileInputStream("config.ser"))) {
b = (Config) in.readObject(); // read it back
}
System.out.println(a == b); // false -> deserialize made a new object. broken.
Fix: add a method named exactly readResolve(). After Java rebuilds the object, if this method exists Java calls it and uses whatever it returns, discarding the fresh copy.
private Object readResolve() {
return INSTANCE; // "when deserialized, hand back the real one"
}
Break 3 — Cloning
clone() (inherited from Object) makes a copy of an object. It only works if the class opts in by implementing Cloneable — a "marker" interface with no methods, just a flag. If a singleton mistakenly implements it, the instance can be copied.
public class Config implements Cloneable {
public static final Config INSTANCE = new Config();
private Config() {}
@Override
public Config clone() throws CloneNotSupportedException {
return (Config) super.clone(); // makes a NEW object
}
}
Config a = Config.INSTANCE;
Config b = a.clone();
System.out.println(a == b); // false -> clone made a second object. broken.
Fix: the simplest is to not implement Cloneable — then clone() throws CloneNotSupportedException. Or override it to return the singleton: public Config clone() { return INSTANCE; }.
The enum singleton beats all three, with no defensive code
public enum Config {
INSTANCE;
public void doSomething() { /* ... */ }
}
Used as Config.INSTANCE.
- Reflection fails.
Constructor.newInstance()has a built-in check: if the class is anenum, it throwsIllegalArgumentException: Cannot reflectively create enum objects. - Serialization is safe. Java treats enums specially — it only writes the constant's name (
"INSTANCE"), and on the way back it callsConfig.valueOf("INSTANCE"), which returns the existing constant. No new object, noreadResolve()needed. - Cloning is impossible.
Enum(the hidden base class of every enum) has afinal clone()that always throws. You can't override it, so no enum can be cloned.
The cost: an enum can't extend another class, and "an enum with one value plus methods" reads oddly to some people. But for a guaranteed single instance with zero boilerplate, it's the strongest choice — which is why Effective Java recommends it as the default way to write a singleton.
| Attack | What it does | Normal singleton | Enum singleton |
|---|---|---|---|
| Reflection | calls the private constructor anyway | broken unless the constructor guards | can't — JVM blocks reflective enum creation |
| Serialization | rebuilding from bytes makes a new object | broken unless you add readResolve() | safe — returns the existing constant |
| Cloning | clone() copies the object | broken if it implements Cloneable | can't — Enum.clone() is final and always throws |