Software Design Patterns for LLD Interviews: A Complete Guide
In Software Development Engineer (SDE) interviews—especially for mid-level and senior roles—low-level design (LLD) rounds assess your ability to write clean, reusable, maintainable, and extensible code. The foundation of resolving these architectural challenges lies in the standard Gang of Four (GoF) Design Patterns. Rather than memorizing theoretical definitions, interviewers expect you to apply these patterns to real-world scenarios, identifying the trade-offs of each.
Below is a comprehensive guide to the 12 most frequently asked design patterns in LLD interviews, categorized by their classification (Creational, Structural, and Behavioral). Each pattern contains a concrete, real-world Java implementation and a detailed breakdown of design decisions.
Creational Design Patterns
Creational design patterns deal with object creation mechanisms. They abstract the instantiation process, making a system independent of how its objects are created, composed, and represented.
1. Singleton Pattern
The Problem: Some classes must have exactly one instance across the entire application lifecycle (such as thread pools, loggers, caches, and database connection managers). Multiple instances could lead to resource exhaustion, state corruption, or inconsistent configurations.
The Solution: Restrict object creation by making the constructor private, providing a static global access point, and ensuring thread-safe lazy initialization using double-checked locking.
public class DatabaseConnectionPool {
private static volatile DatabaseConnectionPool instance;
private final List<String> connections;
private DatabaseConnectionPool() {
// Private constructor to prevent direct instantiation
connections = new ArrayList<>();
for (int i = 0; i < 10; i++) {
connections.add("Connection-" + i);
}
}
public static DatabaseConnectionPool getInstance() {
if (instance == null) { // First check (no synchronization overhead)
synchronized (DatabaseConnectionPool.class) {
if (instance == null) { // Second check (guarantees single instance)
instance = new DatabaseConnectionPool();
}
}
}
return instance;
}
public synchronized String borrowConnection() {
if (connections.isEmpty()) {
throw new RuntimeException("No database connections available in the pool!");
}
return connections.remove(0);
}
public synchronized void releaseConnection(String connection) {
connections.add(connection);
}
}
Design Breakdown: We use the volatile keyword to guarantee that changes to the instance reference are immediately visible to all threads, preventing them from accessing a partially constructed object. The double-checked locking inside getInstance() ensures synchronization overhead is only incurred when the instance is created for the first time, keeping read operations highly performant.
2. Factory Method Pattern
The Problem: You need to create objects without specifying the exact class of object that will be created. Hardcoding constructors (like new EmailNotification()) couples your code, making it difficult to introduce new notification channels (like Slack or SMS) without breaking existing client implementations.
The Solution: Define an interface for creating objects, but let subclasses decide which class to instantiate. This decouples object creation from application logic.
public interface Notification {
void send(String message, String recipient);
}
public class EmailNotification implements Notification {
@Override
public void send(String message, String recipient) {
System.out.println("Email sent to " + recipient + ": " + message);
}
}
public class SmsNotification implements Notification {
@Override
public void send(String message, String recipient) {
System.out.println("SMS sent to " + recipient + ": " + message);
}
}
public abstract class NotificationCreator {
public abstract Notification createNotification();
public void deliverMessage(String message, String recipient) {
Notification notification = createNotification();
notification.send(message, recipient);
}
}
public class EmailCreator extends NotificationCreator {
@Override
public Notification createNotification() {
return new EmailNotification();
}
}
public class SmsCreator extends NotificationCreator {
@Override
public Notification createNotification() {
return new SmsNotification();
}
}
Design Breakdown: The creator class (NotificationCreator) relies on subclasses (EmailCreator, SmsCreator) to override the createNotification() method. This aligns with the Open-Closed Principle: if we need to support WhatsApp messages tomorrow, we simply add WhatsAppNotification and WhatsAppCreator classes without altering the base creator workflow.
3. Builder Pattern
The Problem: When a class has dozens of configuration attributes—some mandatory, some optional—the class constructor becomes cluttered (telescoping constructors). This makes the code unreadable, prone to positional parameter errors, and hard to manage.
The Solution: Separate the construction of a complex object from its representation, allowing step-by-step object assembly using a fluent API.
public class HttpRequest {
private final String url;
private final String method; // Mandatory
private final Map<String, String> headers; // Optional
private final String body; // Optional
private HttpRequest(Builder builder) {
this.url = builder.url;
this.method = builder.method;
this.headers = builder.headers;
this.body = builder.body;
}
public static class Builder {
private final String url;
private final String method;
private Map<String, String> headers = new HashMap<>();
private String body;
public Builder(String url, String method) {
this.url = url;
this.method = method; // Mandatory values passed in builder constructor
}
public Builder addHeader(String name, String value) {
this.headers.put(name, value);
return this; // Return builder instance for fluent chaining
}
public Builder setBody(String body) {
this.body = body;
return this;
}
public HttpRequest build() {
return new HttpRequest(this);
}
}
}
Design Breakdown: The outer class (HttpRequest) constructor is private, enforcing that it can only be initialized through the static nested Builder class. All properties on HttpRequest are declared final, which guarantees that once the object is constructed, it is immutable and thread-safe.
Structural Design Patterns
Structural design patterns focus on how classes and objects compose to form larger, more complex systems, ensuring that changes in one part of the system do not require rewriting others.
4. Adapter Pattern
The Problem: You need to integrate an external library or legacy service into your application, but its interface is incompatible with your existing codebase. Rewriting either your code or the third-party library is not possible.
The Solution: Create an adapter class that acts as a translator, wrapping the incompatible interface and exposing a standard interface expected by your client.
// Standard interface used by our application
public interface PaymentGateway {
void processPayment(String orderId, double amount);
}
// Third-party legacy library with incompatible parameters
public class LegacyStripeProcessor {
public void chargeCustomerCard(String stripeToken, long amountInCents) {
System.out.println("Stripe charged token: " + stripeToken + " | Amount: " + amountInCents + " cents.");
}
}
// Adapter implementing client interface
public class StripePaymentAdapter implements PaymentGateway {
private final LegacyStripeProcessor stripeProcessor;
public StripePaymentAdapter(LegacyStripeProcessor stripeProcessor) {
this.stripeProcessor = stripeProcessor;
}
@Override
public void processPayment(String orderId, double amount) {
// Translate parameters (e.g. converting dollars to cents, resolving keys)
String mockToken = "tok_" + orderId.hashCode();
long cents = (long) (amount * 100);
stripeProcessor.chargeCustomerCard(mockToken, cents);
}
}
Design Breakdown: The adapter class (StripePaymentAdapter) uses object composition to wrap the legacy interface. It converts incoming data parameters (such as double dollar amounts to long cents values) before passing the calls to the third-party dependency, protecting the core application logic from external API shifts.
5. Decorator Pattern
The Problem: You need to attach new behaviors to objects dynamically at runtime. Relying on class inheritance leads to class explosion (e.g., creating EmailWithSlackNotification, EmailWithSmsNotification, and EmailWithSlackAndSmsNotification subclasses to support multiple combinations).
The Solution: Wrap the target object with custom decorators that implement the same base interface, allowing you to chain and stack operations dynamically.
public interface MessageService {
String formatMessage(String message);
}
public class BaseMessageService implements MessageService {
@Override
public String formatMessage(String message) {
return message; // Base formatting returns plain text
}
}
public abstract class MessageDecorator implements MessageService {
protected final MessageService wrappedService;
public MessageDecorator(MessageService service) {
this.wrappedService = service;
}
@Override
public String formatMessage(String message) {
return wrappedService.formatMessage(message);
}
}
public class EncryptionDecorator extends MessageDecorator {
public EncryptionDecorator(MessageService service) {
super(service);
}
@Override
public String formatMessage(String message) {
String baseResult = super.formatMessage(message);
return Base64.getEncoder().encodeToString(baseResult.getBytes()); // Add encryption
}
}
public class CompressionDecorator extends MessageDecorator {
public CompressionDecorator(MessageService service) {
super(service);
}
@Override
public String formatMessage(String message) {
String baseResult = super.formatMessage(message);
return "[Compressed] " + baseResult; // Simulate compression
}
}
Design Breakdown: The abstract class MessageDecorator implements the base interface and contains a reference to it. This allows a decorator (such as EncryptionDecorator) to wrap another decorator (such as CompressionDecorator), enabling you to assemble complex processing pipelines (e.g., encrypting and then compressing a payload) dynamically at runtime.
6. Composite Pattern
The Problem: Your application manages a nested tree hierarchy of objects (such as directories containing files and subdirectories, or organograms with managers and employees). Treating single leaf items differently from group containers leads to complex conditional statements.
The Solution: Define a shared interface to treat single elements (leaves) and collections of elements (composites) uniformly.
public interface FileSystemNode {
void printName();
long getSize();
}
public class File implements FileSystemNode {
private final String name;
private final long size;
public File(String name, long size) {
this.name = name;
this.size = size;
}
@Override
public void printName() {
System.out.println("File: " + name);
}
@Override
public long getSize() { return size; }
}
public class Directory implements FileSystemNode {
private final String name;
private final List<FileSystemNode> children = new ArrayList<>();
public Directory(String name) {
this.name = name;
}
public void addNode(FileSystemNode node) {
children.add(node);
}
@Override
public void printName() {
System.out.println("Directory: " + name);
for (FileSystemNode node : children) {
node.printName(); // Recursively call children
}
}
@Override
public long getSize() {
return children.stream().mapToLong(FileSystemNode::getSize).sum();
}
}
Design Breakdown: By implementing the common interface FileSystemNode, the client doesn't need to check whether a node is a single File or a nested Directory. Dynamic method calls resolve operations recursively, making it simple to calculate directory sizes or print paths.
Behavioral Design Patterns
Behavioral design patterns concern algorithm execution and how responsibilities are distributed between interacting objects.
7. Strategy Pattern
The Problem: You need to support multiple variations of an algorithm (such as different payment routing rules, cache eviction logic, or data compression metrics). Implementing these variations within a single class results in large conditional statements (like if-else or switch blocks).
The Solution: Define a family of algorithms, encapsulate each one in a separate class, and make them interchangeable at runtime.
public interface SortingStrategy {
void sort(int[] array);
}
public class QuickSort implements SortingStrategy {
@Override
public void sort(int[] array) {
System.out.println("Array sorted using Quick Sort.");
}
}
public class MergeSort implements SortingStrategy {
@Override
public void sort(int[] array) {
System.out.println("Array sorted using Merge Sort.");
}
}
public class Sorter {
private SortingStrategy strategy;
public void setStrategy(SortingStrategy strategy) {
this.strategy = strategy;
}
public void executeSort(int[] array) {
if (strategy == null) {
throw new IllegalStateException("Sorting strategy is not set!");
}
strategy.sort(array);
}
}
Design Breakdown: The context class (Sorter) delegates execution to the SortingStrategy interface, keeping it decoupled from concrete sorting implementations. Clients can change the strategy dynamically based on inputs (such as swapping to MergeSort for larger datasets or QuickSort for in-memory operations).
8. Observer Pattern
The Problem: Multiple components in your application need to stay in sync with changes in a primary subject (like UI elements updating when backend values change, or order tracking services triggered by a checkout). Direct coupling creates dependency cycles.
The Solution: Implement a publish-subscribe dependency where a subject registers observers and automatically notifies them of state changes.
public interface Observer {
void update(String event);
}
public class EmailAlertListener implements Observer {
@Override
public void update(String event) {
System.out.println("Email: System Alert recorded -> " + event);
}
}
public class WebConsoleLogger implements Observer {
@Override
public void update(String event) {
System.out.println("Web Console: Event recorded -> " + event);
}
}
public class EventPublisher {
private final List<Observer> observers = new ArrayList<>();
public void registerObserver(Observer observer) {
observers.add(observer);
}
public void unregisterObserver(Observer observer) {
observers.remove(observer);
}
public void notifyObservers(String event) {
for (Observer observer : observers) {
observer.update(event);
}
}
}
Design Breakdown: The publisher maintains a collection of Observer interfaces rather than concrete class references. This allows you to add or remove listeners dynamically at runtime without modifying the core event-generation logic.
9. State Pattern
The Problem: An object's behavior depends on its current state, and it must change its behavior dynamically as its state changes. Managing transitions using conditional flags (like isPaid, isDispensing) leads to code that is difficult to maintain as new states are added.
The Solution: Encapsulate state-specific behaviors into individual state classes and delegate operations from the context object to the active state instance.
public interface VendingMachineState {
void insertCoin(VendingContext context);
void selectItem(VendingContext context);
void dispense(VendingContext context);
}
public class NoCoinState implements VendingMachineState {
@Override
public void insertCoin(VendingContext context) {
System.out.println("Coin accepted.");
context.setState(new HasCoinState()); // Transition state
}
@Override
public void selectItem(VendingContext context) {
System.out.println("Error: Insert coin first.");
}
@Override
public void dispense(VendingContext context) {
System.out.println("Error: Payment required.");
}
}
public class HasCoinState implements VendingMachineState {
@Override
public void insertCoin(VendingContext context) {
System.out.println("Error: Coin already inserted.");
}
@Override
public void selectItem(VendingContext context) {
System.out.println("Item selected.");
context.setState(new DispensingState()); // Transition state
}
@Override
public void dispense(VendingContext context) {
System.out.println("Error: Confirm selection first.");
}
}
public class DispensingState implements VendingMachineState {
@Override
public void insertCoin(VendingContext context) {
System.out.println("Error: Currently dispensing.");
}
@Override
public void selectItem(VendingContext context) {
System.out.println("Error: Dispatch in progress.");
}
@Override
public void dispense(VendingContext context) {
System.out.println("Item dispensed successfully.");
context.setState(new NoCoinState()); // Reset state
}
}
public class VendingContext {
private VendingMachineState currentState;
public VendingContext() {
this.currentState = new NoCoinState(); // Initial state
}
public void setState(VendingMachineState state) {
this.currentState = state;
}
public void insertCoin() { currentState.insertCoin(this); }
public void selectItem() { currentState.selectItem(this); }
public void dispense() { currentState.dispense(this); }
}
Design Breakdown: The context (VendingContext) holds a reference to VendingMachineState and delegates commands to it. The state implementations (such as NoCoinState, HasCoinState) handle the logic and trigger state transitions on the context, removing complex conditions from the client class.
10. Chain of Responsibility Pattern
The Problem: You need to pass request payloads through a series of processing steps or checks (such as basic authentication, rate limit validation, log filtering, and header validation). Coupling these checks creates a rigid, complex structure that is difficult to reuse.
The Solution: Chain the processing handlers in a line. Each handler processes the request, then decides whether to pass it along to the next step or halt execution.
public abstract class RequestHandler {
protected RequestHandler nextHandler;
public void setNext(RequestHandler nextHandler) {
this.nextHandler = nextHandler;
}
public abstract boolean handle(String request);
protected boolean checkNext(String request) {
if (nextHandler == null) return true; // Reached end of pipeline successfully
return nextHandler.handle(request);
}
}
public class AuthFilter extends RequestHandler {
@Override
public boolean handle(String request) {
if (!request.contains("token_validated")) {
System.out.println("Auth Filter failed: Request rejected.");
return false; // Halt pipeline
}
System.out.println("Auth Filter passed.");
return checkNext(request);
}
}
public class RateLimiterFilter extends RequestHandler {
@Override
public boolean handle(String request) {
if (request.contains("limit_exceeded")) {
System.out.println("Rate Limiter failed: Request blocked.");
return false; // Halt pipeline
}
System.out.println("Rate Limiter passed.");
return checkNext(request);
}
}
Design Breakdown: The RequestHandler base class holds a reference to the next handler. This allows you to construct processing chains dynamically (e.g., executing authentication check before rate limit verification), making individual processing steps highly reusable.
11. Command Pattern
The Problem: You need to trigger actions on objects without knowing the details of the receiver or the specific method being executed. Hardcoding these calls makes it difficult to support features like command logging, parameter tracking, or undo/redo operations.
The Solution: Encapsulate a request as an object (a command), wrapping all parameters required to execute the action behind a standard interface.
public interface Command {
void execute();
void undo();
}
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 final Light light;
public TurnOnLightCommand(Light light) {
this.light = light;
}
@Override
public void execute() { light.turnOn(); }
@Override
public void undo() { light.turnOff(); }
}
public class RemoteControl {
private Command slot;
private final List<Command> commandHistory = new ArrayList<>();
public void setCommand(Command command) {
this.slot = command;
}
public void pressButton() {
slot.execute();
commandHistory.add(slot); // Record for undo history
}
public void pressUndo() {
if (!commandHistory.isEmpty()) {
Command lastCommand = commandHistory.remove(commandHistory.size() - 1);
lastCommand.undo();
}
}
}
Design Breakdown: The command execution invoker (RemoteControl) does not interact with the concrete receiver (Light) directly. Instead, it relies on the generic Command interface, making it simple to track execution histories and support features like undo stacks.
12. Facade Pattern
The Problem: Your application relies on a complex subsystem with dozens of interacting classes (like a video converter library or a credit card processor integration). Forcing client classes to interact with all these low-level details creates tight coupling.
The Solution: Create a simplified, high-level interface (a facade) that coordinates the complex subsystem calls, providing a clean entry point for client applications.
// Complex subsystem classes
class SoundMixer { public void mix() { System.out.println("Sound mixed."); } }
class VideoCoder { public void decode() { System.out.println("Video decoded."); } }
class BitrateReader { public void read() { System.out.println("Reading bitrate."); } }
// Facade class
public class VideoConversionFacade {
public void convertVideo(String fileName, String format) {
System.out.println("Starting conversion pipeline for " + fileName + " to " + format);
BitrateReader reader = new BitrateReader();
VideoCoder coder = new VideoCoder();
SoundMixer mixer = new SoundMixer();
reader.read();
coder.decode();
mixer.mix();
System.out.println("Conversion pipeline complete.");
}
}
Design Breakdown: The facade (VideoConversionFacade) acts as a high-level wrapper. It manages the creation and execution of the low-level classes in the subsystem, allowing clients to run complex processes with a single method call while still leaving the low-level classes accessible if advanced customization is needed.
Comments
Post a Comment