Design patterns are proven solutions to recurring design problems. Interviewers for SDET roles ask about them because frameworks are built from them: a Singleton or factory for the driver, Facades over page objects, Decorators around WebDriver for logging. Part 1 covers the creational and structural patterns; Part 2 covers behavioural patterns.
Design Patterns
Singleton — Only One Instance
// Thread-safe Singleton (Double-Checked Locking)
public class Singleton {
// volatile: prevents instruction reordering
private static volatile Singleton instance;
private Singleton() {
// prevent reflection instantiation
if (instance != null) {
throw new RuntimeException("Use getInstance()!");
}
}
public static Singleton getInstance() {
if (instance == null) { // Check 1 (no lock)
synchronized (Singleton.class) {
if (instance == null) { // Check 2 (with lock)
instance = new Singleton();
}
}
}
return instance;
}
}
// BEST WAY: Enum Singleton (thread-safe, serialization-safe)
public enum BestSingleton {
INSTANCE;
public void doWork() { System.out.println("Working!"); }
}
BestSingleton.INSTANCE.doWork();
Singleton Pitfalls
// BROKEN BY REFLECTION:
Constructor<?> con = Singleton.class.getDeclaredConstructor();
con.setAccessible(true);
Singleton s2 = (Singleton) con.newInstance(); // new instance!
// Fix: check in constructor (shown above)
// BROKEN BY SERIALIZATION:
// Deserializing creates NEW object
// Fix: add readResolve() method
protected Object readResolve() { return getInstance(); }
// Enum Singleton avoids ALL these problems automatically!
// Factory Method: delegate object creation to subclasses
interface Shape {
void draw();
}
class Circle implements Shape { public void draw() { System.out.println("Circle"); } }
class Rectangle implements Shape { public void draw() { System.out.println("Rectangle"); } }
class Triangle implements Shape { public void draw() { System.out.println("Triangle"); } }
// Factory class
class ShapeFactory {
public static Shape create(String type) {
return switch (type.toLowerCase()) {
case "circle" -> new Circle();
case "rectangle" -> new Rectangle();
case "triangle" -> new Triangle();
default -> throw new IllegalArgumentException("Unknown: " + type);
};
}
}
Shape s = ShapeFactory.create("circle");
s.draw(); // Circle
// Advantage: client doesn't know concrete class
// Adding new shape? Just add new case — open/closed principle
// Builder: construct complex objects step by step
public class Pizza {
private String size;
private String crust;
private boolean cheese;
private boolean pepperoni;
private boolean mushrooms;
private Pizza(Builder builder) {
this.size = builder.size;
this.crust = builder.crust;
this.cheese = builder.cheese;
this.pepperoni = builder.pepperoni;
this.mushrooms = builder.mushrooms;
}
public static class Builder {
private String size;
private String crust = "thin"; // default
private boolean cheese = false;
private boolean pepperoni = false;
private boolean mushrooms = false;
public Builder(String size) { this.size = size; }
public Builder crust(String c) { this.crust = c; return this; }
public Builder cheese() { this.cheese = true; return this; }
public Builder pepperoni() { this.pepperoni = true; return this; }
public Builder mushrooms() { this.mushrooms = true; return this; }
public Pizza build() { return new Pizza(this); }
}
}
// Readable, flexible object construction
Pizza pizza = new Pizza.Builder("Large")
.crust("thick")
.cheese()
.pepperoni()
.build();
// Observer: when one object changes, notify all dependents
import java.util.*;
interface Observer {
void update(String event, Object data);
}
class EventBus {
private Map<String, List<Observer>> listeners = new HashMap<>();
public void subscribe(String event, Observer observer) {
listeners.computeIfAbsent(event, k -> new ArrayList<>()).add(observer);
}
public void publish(String event, Object data) {
List<Observer> obs = listeners.getOrDefault(event, List.of());
obs.forEach(o -> o.update(event, data));
}
}
// Usage
EventBus bus = new EventBus();
bus.subscribe("login", (event, data) ->
System.out.println("Logger: User logged in: " + data));
bus.subscribe("login", (event, data) ->
System.out.println("Email: Sending welcome to " + data));
bus.publish("login", "alice@example.com");
// Logger: User logged in: alice@example.com
// Email: Sending welcome to alice@example.com
Interview Questions
What is the difference between Factory and Abstract Factory?
Factory Method creates one product type; subclasses decide the specific class. Abstract Factory creates FAMILIES of related products — an interface for creating multiple different product types that work together (e.g., UI toolkit: WindowsButton+WindowsDialog vs MacButton+MacDialog).
When would you use Builder pattern?
When constructing an object with many optional parameters (avoids telescoping constructors), when object construction requires multiple steps, or when you want immutable objects with many fields. Lombok's @Builder annotation generates this automatically.
Decorator Pattern
Start with a plain pizza. Add cheese (+₹50). Add pepperoni (+₹80). Each topping wraps the previous pizza, adding behavior. Decorator pattern adds responsibilities to objects dynamically without changing their class.
// Component interface
public interface Coffee {
String getDescription();
double getCost();
}
// Concrete component
public class SimpleCoffee implements Coffee {
public String getDescription() { return "Simple Coffee"; }
public double getCost() { return 50.0; }
}
// Abstract Decorator
public abstract class CoffeeDecorator implements Coffee {
protected Coffee coffee; // wraps a coffee
public CoffeeDecorator(Coffee c) { this.coffee = c; }
}
// Concrete Decorators
public class MilkDecorator extends CoffeeDecorator {
public MilkDecorator(Coffee c) { super(c); }
public String getDescription() { return coffee.getDescription() + ", Milk"; }
public double getCost() { return coffee.getCost() + 15.0; }
}
public class SugarDecorator extends CoffeeDecorator {
public SugarDecorator(Coffee c) { super(c); }
public String getDescription() { return coffee.getDescription() + ", Sugar"; }
public double getCost() { return coffee.getCost() + 5.0; }
}
public class WhipDecorator extends CoffeeDecorator {
public WhipDecorator(Coffee c) { super(c); }
public String getDescription() { return coffee.getDescription() + ", Whip"; }
public double getCost() { return coffee.getCost() + 20.0; }
}
// Usage — stack decorators
Coffee coffee = new SimpleCoffee(); // ₹50
coffee = new MilkDecorator(coffee); // ₹65
coffee = new SugarDecorator(coffee); // ₹70
coffee = new WhipDecorator(coffee); // ₹90
System.out.println(coffee.getDescription());
// Simple Coffee, Milk, Sugar, Whip
System.out.println(coffee.getCost()); // 90.0
// Java uses Decorator: BufferedReader(FileReader(...))
// InputStream → BufferedInputStream → DataInputStream
Proxy Pattern
// Proxy: controls access to an object (lazy load, security, logging, caching)
public interface Image {
void display();
}
// Real object (expensive to create)
public class RealImage implements Image {
private String filename;
public RealImage(String filename) {
this.filename = filename;
loadFromDisk(); // expensive operation!
}
private void loadFromDisk() {
System.out.println("Loading " + filename + " from disk...");
}
public void display() { System.out.println("Displaying: " + filename); }
}
// Proxy — lazy loading
public class ImageProxy implements Image {
private String filename;
private RealImage realImage; // null until first access
public ImageProxy(String filename) {
this.filename = filename; // no loading yet!
}
public void display() {
if (realImage == null) {
realImage = new RealImage(filename); // load on demand
}
realImage.display();
}
}
// Usage
Image img = new ImageProxy("photo.jpg"); // no disk load yet
System.out.println("Image created");
img.display(); // NOW loads from disk (first access)
img.display(); // uses cached RealImage
// Other Proxy types:
// Protection Proxy: check permissions before delegating
// Remote Proxy: represent object in another JVM (RMI)
// Caching Proxy: cache results
// Spring AOP is a Dynamic Proxy!
Facade, Adapter, State Patterns
// ── FACADE: simple interface to complex subsystem ──
public class HomeTheaterFacade {
private TV tv; private SoundSystem sound; private Projector proj;
public HomeTheaterFacade(TV tv, SoundSystem s, Projector p) {
this.tv=tv; this.sound=s; this.proj=p;
}
// One method instead of 10 steps
public void watchMovie(String movie) {
tv.on(); sound.on(); sound.setVolume(50);
proj.on(); proj.setInput("HDMI");
System.out.println("Enjoy: " + movie);
}
public void endMovie() {
tv.off(); sound.off(); proj.off();
}
}
// ── ADAPTER: convert incompatible interface ──
// Old interface
public interface OldPayment { void makePayment(int amount); }
// New interface
public interface NewPayment { void processPayment(double amount, String currency); }
// Adapter wraps old to match new
public class PaymentAdapter implements NewPayment {
private OldPayment oldPayment;
public PaymentAdapter(OldPayment old) { this.oldPayment = old; }
public void processPayment(double amount, String currency) {
int converted = (int)(amount * getRate(currency));
oldPayment.makePayment(converted);
}
}
// ── STATE: object changes behavior when state changes ──
public interface TrafficLightState {
void handle(TrafficLight light);
}
public class RedState implements TrafficLightState {
public void handle(TrafficLight light) {
System.out.println("RED: Stop");
light.setState(new GreenState());
}
}
public class GreenState implements TrafficLightState {
public void handle(TrafficLight light) {
System.out.println("GREEN: Go");
light.setState(new YellowState());
}
}
public class YellowState implements TrafficLightState {
public void handle(TrafficLight light) {
System.out.println("YELLOW: Slow down");
light.setState(new RedState());
}
}
public class TrafficLight {
private TrafficLightState state = new RedState();
public void setState(TrafficLightState s) { this.state = s; }
public void change() { state.handle(this); }
}
TrafficLight light = new TrafficLight();
light.change(); // RED: Stop
light.change(); // GREEN: Go
light.change(); // YELLOW: Slow down
When would you use Facade vs Adapter?
Facade: simplify a complex subsystem (wrap multiple classes into one simple interface). Adapter: make incompatible interfaces work together (convert one interface to another). Facade reduces complexity; Adapter resolves incompatibility.
Flyweight Pattern
A forest has 1 million trees. Instead of storing color+texture for each tree (1M objects), store one shared TreeType object and reference it. Flyweight separates intrinsic state (shared) from extrinsic state (unique per object).
// Flyweight: share common data across many objects
// Intrinsic state: shared (stored in flyweight)
// Extrinsic state: unique per object (passed in)
// Flyweight object (shared)
public class CharacterStyle {
private final String font;
private final int size;
private final String color;
public CharacterStyle(String font, int size, String color) {
this.font = font; this.size = size; this.color = color;
}
public void render(char character, int x, int y) {
System.out.printf("Rendering '%c' at (%d,%d) font=%s size=%d color=%s%n",
character, x, y, font, size, color);
}
}
// Flyweight Factory — returns cached instances
public class CharacterStyleFactory {
private static final Map<String, CharacterStyle> cache = new HashMap<>();
public static CharacterStyle getStyle(String font, int size, String color) {
String key = font + "-" + size + "-" + color;
return cache.computeIfAbsent(key,
k -> new CharacterStyle(font, size, color));
}
public static int getCacheSize() { return cache.size(); }
}
// Client — millions of characters, few shared styles
public class TextEditor {
record Character(char c, int x, int y, CharacterStyle style) {}
List<Character> chars = new ArrayList<>();
public void addChar(char c, int x, int y, String font, int size, String color) {
CharacterStyle style = CharacterStyleFactory.getStyle(font, size, color);
chars.add(new Character(c, x, y, style)); // shares style object!
}
public void render() {
chars.forEach(c -> c.style().render(c.c(), c.x(), c.y()));
}
}
TextEditor editor = new TextEditor();
// 1000 'A' chars all share ONE CharacterStyle object!
for (int i = 0; i < 1000; i++)
editor.addChar('A', i, 0, "Arial", 12, "black");
System.out.println(CharacterStyleFactory.getCacheSize()); // 1 (not 1000!)
Composite Pattern
A file system: a folder can contain files OR other folders. Both files and folders support 'open'. Composite lets you treat individual objects and compositions uniformly.
// Component interface
public interface FileSystemItem {
String getName();
long getSize();
void display(String indent);
}
// Leaf — no children
public class File implements FileSystemItem {
private String name;
private long size;
public File(String name, long size) { this.name=name; this.size=size; }
public String getName() { return name; }
public long getSize() { return size; }
public void display(String indent) {
System.out.println(indent + "📄 " + name + " (" + size + " bytes)");
}
}
// Composite — has children
public class Folder implements FileSystemItem {
private String name;
private List<FileSystemItem> children = new ArrayList<>();
public Folder(String name) { this.name = name; }
public void add(FileSystemItem item) { children.add(item); }
public void remove(FileSystemItem item) { children.remove(item); }
public String getName() { return name; }
public long getSize() {
return children.stream().mapToLong(FileSystemItem::getSize).sum();
}
public void display(String indent) {
System.out.println(indent + "📁 " + name + " (" + getSize() + " bytes)");
children.forEach(c -> c.display(indent + " "));
}
}
// Build tree
Folder root = new Folder("root");
Folder src = new Folder("src");
src.add(new File("Main.java", 1024));
src.add(new File("App.java", 2048));
Folder docs = new Folder("docs");
docs.add(new File("README.md", 512));
root.add(src); root.add(docs);
root.add(new File("pom.xml", 256));
root.display("");
System.out.println("Total: " + root.getSize() + " bytes");