Introduction
Design patterns are proven solutions to recurring software design problems. But they are tools, not goals. The biggest mistake developers make is reaching for a pattern before understanding the problem.
This article covers three essential patterns — Strategy, Factory, and Builder — with a focus on when to use them and when not to.
When NOT to Use Design Patterns
Before diving into patterns, let's talk about when to avoid them:
- Overengineering: A class with 2 payment methods does not need a full Strategy + Factory + DI setup. A simple
if-elseis fine. - Premature abstraction: Don't create an interface until you have at least two implementations. YAGNI (You Ain't Gonna Need It).
- Resume-driven development: Using patterns to impress, not to solve.
"The best code is no code at all. The second best is the simplest code that works." — Jeff Atwood
1. Strategy Pattern — The Evolution
Let's see how payment processing evolves from simple to scalable.
Stage 1: If-Else (Simple, and that's okay for 2 types)
public double processPayment(String type, double amount) {
if ("UPI".equals(type)) {
return amount; // No fee
} else if ("CARD".equals(type)) {
return amount + (amount * 0.02); // 2% fee
}
throw new IllegalArgumentException("Unknown type");
}
Stage 2: Enum (Better for 3-5 types)
public enum PaymentType {
UPI(0.0),
CARD(0.02),
NET_BANKING(0.01);
private final double feeRate;
PaymentType(double feeRate) { this.feeRate = feeRate; }
public double calculateTotal(double amount) {
return amount + (amount * feeRate);
}
}
Stage 3: Strategy Interface (Scalable for 5+ types with complex logic)
public interface PaymentStrategy {
double calculateFee(double amount);
void processPayment(double amount);
boolean supports(String currency);
}
public class UpiPayment implements PaymentStrategy {
public double calculateFee(double amount) { return 0; }
public void processPayment(double amount) { /* UPI API call */ }
public boolean supports(String currency) { return "INR".equals(currency); }
}
public class CardPayment implements PaymentStrategy {
public double calculateFee(double amount) { return amount * 0.02; }
public void processPayment(double amount) { /* Stripe API call */ }
public boolean supports(String currency) { return true; }
}
Stage 4: Spring DI (Production-grade)
@Component
public class UpiPayment implements PaymentStrategy { ... }
@Component
public class CardPayment implements PaymentStrategy { ... }
@Service
public class PaymentService {
private final List<PaymentStrategy> strategies;
@Autowired
public PaymentService(List<PaymentStrategy> strategies) {
this.strategies = strategies;
}
public void processPayment(String type, double amount) {
PaymentStrategy strategy = strategies.stream()
.filter(s -> s.getType().equals(type))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unsupported: " + type));
strategy.processPayment(amount);
}
}
Spring auto-discovers all PaymentStrategy implementations. Adding a new payment method is just creating a new @Component class.
2. Factory Pattern — Without Overengineering
Simple Factory (Start here)
public class NotificationFactory {
public static Notification create(String channel) {
return switch (channel) {
case "EMAIL" -> new EmailNotification();
case "SMS" -> new SmsNotification();
case "PUSH" -> new PushNotification();
default -> throw new IllegalArgumentException("Unknown: " + channel);
};
}
}
// Usage
Notification notification = NotificationFactory.create("EMAIL");
notification.send("Hello!");
Scalable Factory (When it grows)
@Component
public class NotificationFactory {
private final Map<String, Notification> notificationMap;
@Autowired
public NotificationFactory(List<Notification> notifications) {
this.notificationMap = notifications.stream()
.collect(Collectors.toMap(Notification::getType, Function.identity()));
}
public Notification create(String channel) {
Notification notification = notificationMap.get(channel);
if (notification == null) throw new IllegalArgumentException("Unknown: " + channel);
return notification;
}
}
The simple version is fine for 3 types. Move to the scalable version when the switch statement starts growing.
3. Builder Pattern for Complex Objects
The Problem
// 12 constructor parameters — which one is which?
new Order("ORD-123", "CUST-456", "2024-01-15", "PENDING",
"INR", 1500.0, 270.0, 1770.0, "Mumbai",
"400001", true, "Express");
The Solution
public class Order {
private final String orderId;
private final String customerId;
private final String date;
private final String status;
private final double subtotal;
private final double tax;
private final String shippingCity;
private final boolean isPriority;
private Order(Builder builder) {
this.orderId = builder.orderId;
this.customerId = builder.customerId;
this.date = builder.date;
this.status = builder.status;
this.subtotal = builder.subtotal;
this.tax = builder.tax;
this.shippingCity = builder.shippingCity;
this.isPriority = builder.isPriority;
}
public static class Builder {
private final String orderId; // Required
private final String customerId; // Required
private String date;
private String status = "PENDING";
private double subtotal;
private double tax;
private String shippingCity;
private boolean isPriority = false;
public Builder(String orderId, String customerId) {
this.orderId = orderId;
this.customerId = customerId;
}
public Builder date(String date) { this.date = date; return this; }
public Builder subtotal(double subtotal) { this.subtotal = subtotal; return this; }
public Builder tax(double tax) { this.tax = tax; return this; }
public Builder shippingCity(String city) { this.shippingCity = city; return this; }
public Builder priority(boolean priority) { this.isPriority = priority; return this; }
public Order build() {
return new Order(this);
}
}
}
// Usage — readable and self-documenting
Order order = new Order.Builder("ORD-123", "CUST-456")
.date("2024-01-15")
.subtotal(1500.0)
.tax(270.0)
.shippingCity("Mumbai")
.priority(true)
.build();
Modern Alternative: Lombok
@Builder
@Value
public class Order {
String orderId;
String customerId;
@Builder.Default String status = "PENDING";
double subtotal;
double tax;
String shippingCity;
@Builder.Default boolean isPriority = false;
}
Interview Questions
-
What is the difference between Strategy and Factory? Strategy selects behavior at runtime. Factory selects object creation at runtime. They often work together — a Factory creates the right Strategy.
-
When would you use Builder over a constructor? When you have more than 4 parameters, especially when many are optional. Builder makes the code self-documenting and prevents parameter order bugs.
-
How do you decide which pattern to use? Start without patterns. When you feel pain (growing switch statements, constructor chaos, tight coupling), the correct pattern will become obvious.
Common Mistakes
- Pattern per class: Not every class needs a pattern. Most classes are just data holders or simple services.
- Abstract Factory overuse: Creating factories for factories. If your
AbstractNotificationChannelFactoryFactorysounds ridiculous, it probably is. - Ignoring the evolution: Start with the simplest solution. Let the pattern emerge from the code, don't force it upfront.
Summary
| Pattern | Problem It Solves | When to Use |
|---|---|---|
| Strategy | Runtime behavior selection | 3+ interchangeable behaviors |
| Factory | Object creation complexity | Centralizing creation logic |
| Builder | Complex object construction | 4+ constructor parameters |