SSourav Saha
HomeExperienceSoftware DesignSystem DesignLearningBooksToolsContact
SSourav Saha

Building scalable backend systems, distributed infrastructure, and cloud-native applications.

Navigation

  • Home
  • Experience
  • Software Design
  • System Design
  • Learning

More

  • Books
  • Tools
  • Contact

Connect

  • LinkedIn
  • Email

© 2026 Sourav Saha. All rights reserved.

Built with using Next.js

Software DesignPractical Design Patterns for Real-World Applications
Software DesignDesign Patterns

Practical Design Patterns for Real-World Applications

Learn when to use design patterns and when NOT to. Covering Strategy, Factory, and Builder patterns with progressive Java/Spring Boot examples.

May 19, 20246 min read
design-patternsjavaspring-bootarchitecture

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-else is 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

  1. 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.

  2. 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.

  3. 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 AbstractNotificationChannelFactoryFactory sounds 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

PatternProblem It SolvesWhen to Use
StrategyRuntime behavior selection3+ interchangeable behaviors
FactoryObject creation complexityCentralizing creation logic
BuilderComplex object construction4+ constructor parameters

Related Articles

  • The SOLID Principles Explained
  • Core OOP Principles
  • Java & Spring Boot Best Practices
PreviousCore OOP Principles for Scalable SystemsNextJava & Spring Boot Best Practices