Software Engineer · Java · Spring Boot · Microservices
Java Design Patterns in Production: Strategy, Factory, and Builder for Scalable Systems
A payment platform at a large e-commerce company had a PaymentService with 800 lines of nested if/else branching on payment provider type. Adding a new provider required modifying this class, re-running all integration tests, and deploying the entire service. Design patterns aren't theoretical constructs from a Gang of Four textbook — they are production survival tools. This post shows how Strategy, Factory Method, and Builder patterns eliminate exactly this class of maintainability nightmare.
Table of Contents
- Why Design Patterns Still Matter in the Microservices Era
- Strategy Pattern: Pluggable Payment Gateways Without Conditionals
- Factory Method: Dynamic Bean Creation in Spring Boot
- Builder Pattern: Complex Request Object Construction
- Combining Patterns: Strategy + Factory for Multi-Provider Architecture
- Production Failure: The Conditional Hell Anti-Pattern
- When NOT to Use Design Patterns
- Performance Implications of Pattern Abstraction
- Key Takeaways
1. Why Design Patterns Still Matter in the Microservices Era
Some engineers dismiss design patterns as legacy OOP thinking replaced by functional programming or microservice decomposition. This is a misunderstanding. Patterns describe solutions to recurring structural problems in code. Whether you're writing a monolith or a microservice, you'll still face the problem of "how do I add a new payment provider without changing existing code?" That's the Open/Closed Principle problem — and the Strategy pattern is the answer, regardless of architecture style.
The three patterns covered here — Strategy, Factory Method, and Builder — are the workhorses of enterprise Java development because they solve the three most common structural problems: varying behavior, flexible object creation, and safe complex initialization.
2. Strategy Pattern: Pluggable Payment Gateways Without Conditionals
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. The key mechanism: define an interface representing the behavior that varies, implement each variant as a separate class, and inject the correct variant at runtime. The caller doesn't need to know which implementation it has — it just calls the interface method.
Before Strategy — the conditional hell pattern:
// The anti-pattern — every new provider requires modifying this class
public PaymentResult processPayment(PaymentRequest req) {
if ("STRIPE".equals(req.getProvider())) {
// 40 lines of Stripe-specific logic
} else if ("PAYPAL".equals(req.getProvider())) {
// 35 lines of PayPal-specific logic
} else if ("BRAINTREE".equals(req.getProvider())) {
// 45 lines of Braintree-specific logic
} else {
throw new UnsupportedProviderException(req.getProvider());
}
}
After Strategy — the extensible pattern:
// Strategy interface
public interface PaymentGateway {
PaymentResult charge(Money amount, PaymentMethod method, String idempotencyKey);
boolean supports(String providerCode);
}
// Concrete strategies — each in its own class, testable independently
@Component
public class StripeGateway implements PaymentGateway {
@Override
public boolean supports(String providerCode) { return "STRIPE".equals(providerCode); }
@Override
public PaymentResult charge(Money amount, PaymentMethod method, String idempotencyKey) {
// Stripe-specific implementation — isolated and independently deployable
StripeChargeParams params = StripeChargeParams.builder()
.amount(amount.toStripeMinorUnits())
.currency(amount.currency().getCode().toLowerCase())
.source(method.getToken())
.idempotencyKey(idempotencyKey)
.build();
Charge charge = stripeClient.charges().create(params);
return PaymentResult.success(charge.getId(), amount);
}
}
@Component
public class PayPalGateway implements PaymentGateway {
@Override
public boolean supports(String providerCode) { return "PAYPAL".equals(providerCode); }
// PayPal-specific implementation
}
// Context — knows nothing about concrete strategies
@Service
public class PaymentService {
private final List<PaymentGateway> gateways;
public PaymentService(List<PaymentGateway> gateways) {
this.gateways = gateways; // Spring injects ALL PaymentGateway beans
}
public PaymentResult process(PaymentRequest request) {
PaymentGateway gateway = gateways.stream()
.filter(g -> g.supports(request.getProvider()))
.findFirst()
.orElseThrow(() -> new UnsupportedProviderException(request.getProvider()));
return gateway.charge(request.getAmount(), request.getMethod(), request.getIdempotencyKey());
}
}
@Component that implements PaymentGateway. Zero changes to PaymentService. Zero risk to existing payment flows. The new component is independently testable with a mock client.
3. Factory Method: Dynamic Bean Creation in Spring Boot
The Factory Method pattern delegates the responsibility of creating an object to a subclass or a separate factory component, decoupling the creation logic from the consumer. In Spring Boot, this is particularly useful when you need to create objects that depend on runtime configuration, tenant context, or environment variables not known at compile time.
A common real-world case: a notification service that creates different notification senders (email, SMS, push) based on user preference stored in the database:
// Factory interface
public interface NotificationSenderFactory {
NotificationSender create(NotificationChannel channel);
}
// Factory implementation — knows how to wire concrete senders
@Component
public class DefaultNotificationSenderFactory implements NotificationSenderFactory {
private final EmailSender emailSender;
private final SmsSender smsSender;
private final PushSender pushSender;
// Constructor injection via Spring
public DefaultNotificationSenderFactory(
EmailSender emailSender, SmsSender smsSender, PushSender pushSender) {
this.emailSender = emailSender;
this.smsSender = smsSender;
this.pushSender = pushSender;
}
@Override
public NotificationSender create(NotificationChannel channel) {
return switch (channel) {
case EMAIL -> emailSender;
case SMS -> smsSender;
case PUSH -> pushSender;
};
}
}
// Consumer — never knows which concrete sender it's using
@Service
public class NotificationService {
private final NotificationSenderFactory senderFactory;
private final UserPreferenceRepository prefRepo;
public void notify(UserId userId, Notification notification) {
NotificationChannel channel = prefRepo.getPreferredChannel(userId);
NotificationSender sender = senderFactory.create(channel);
sender.send(notification);
}
}
Using Java's switch expression in the factory — rather than a chained if/else — eliminates missing-case bugs at compile time (exhaustiveness is checked for sealed classes and enums).
4. Builder Pattern: Complex Request Object Construction
The Builder pattern is the antidote to the telescoping constructor anti-pattern — constructors with 8 parameters where half are nullable, and callers pass null, null, null, "ACTIVE", null, true with no idea what those positions mean. Builders give you named parameters in Java.
In modern Spring Boot applications, Lombok's @Builder handles most cases, but there are important production caveats:
// Using Lombok @Builder with validation
@Builder(toBuilder = true)
@Getter
public class OrderSearchRequest {
@NonNull private final String customerId;
private final OrderStatus status;
private final Instant from;
private final Instant to;
@Builder.Default private final int pageSize = 20;
@Builder.Default private final int page = 0;
// Lombok doesn't provide validation — add it via a static factory wrapper
public static OrderSearchRequest of(String customerId) {
if (customerId == null || customerId.isBlank())
throw new IllegalArgumentException("customerId required");
return OrderSearchRequest.builder().customerId(customerId).build();
}
}
// Usage — readable and self-documenting
OrderSearchRequest req = OrderSearchRequest.builder()
.customerId("cust-123")
.status(OrderStatus.PENDING)
.from(Instant.now().minus(Duration.ofDays(30)))
.to(Instant.now())
.pageSize(50)
.build();
@Builder does not call your constructor — it constructs objects by directly setting fields. If you have a private constructor with validation, Lombok bypasses it. Always add a static factory method that performs validation, or use Lombok's @Builder(builderMethodName = "") with a custom build() override.
5. Combining Patterns: Strategy + Factory for Multi-Provider Architecture
The real power emerges when you combine patterns. A Strategy defines interchangeable algorithms; a Factory creates the right Strategy at runtime; a Builder constructs the complex input objects those Strategies need. This trio forms the backbone of enterprise multi-provider integrations:
// Builder constructs the request
ShippingQuoteRequest request = ShippingQuoteRequest.builder()
.origin(warehouseAddress)
.destination(customerAddress)
.packageWeight(Weight.kg(2.5))
.dimensions(Dimensions.cm(30, 20, 15))
.serviceLevel(ServiceLevel.STANDARD)
.build();
// Factory resolves the Strategy based on carrier config
ShippingCarrier carrier = carrierFactory.selectBest(request, customerTier);
// Strategy executes — caller agnostic of carrier-specific implementation
ShippingQuote quote = carrier.getQuote(request);
Order order = orderBuilder.withShipping(quote).build();
You can explore how similar patterns apply to microservice API design in the post on SOLID Principles in Java Spring Boot, which covers the Open/Closed Principle using the same Strategy-based extension mechanism.
6. Production Failure: The Conditional Hell Anti-Pattern
A logistics startup had a ShipmentRouter class with 1,400 lines of conditional logic routing shipments across 7 carriers based on region, weight, service level, and customer tier. Every time a new carrier was added or routing rules changed, a developer had to navigate the entire method, find the right else if block, and insert new conditions. Merge conflicts on this one method caused 3 production incidents in a single quarter as concurrent branches modified the same lines.
After refactoring to Strategy + Factory, each carrier's routing logic lived in its own class. Adding a new carrier meant creating a new class — no existing code touched. The 1,400-line method shrank to 12 lines. Merge conflicts on routing logic dropped to zero in the following quarter.
7. When NOT to Use Design Patterns
Patterns are solutions to recurring problems. If you don't have the problem, the pattern is overhead. Warning signs that you're over-engineering:
- Single implementation Strategy: If you only ever have one payment gateway and no plans to add others, a Strategy interface is premature abstraction. Add it when you need the second implementation.
- Factory for simple
new: If an object has no complex creation logic and no dependencies, a factory adds a layer with no benefit. - Builder for 3-field objects: A constructor with 3 parameters is readable. The Builder pattern is valuable when you have 6+ parameters with optional combinations.
The rule: write the simplest code that works, and refactor to a pattern when the problem that pattern solves actually appears. YAGNI (You Aren't Gonna Need It) applies to patterns too.
8. Performance Implications of Pattern Abstraction
Interface dispatch in Java is a virtual method call. The JVM's JIT compiler uses inline caches to optimize virtual dispatch — if a call site always dispatches to the same implementation (monomorphic), the JIT inlines it with near-zero overhead. If it dispatches to 2 implementations (bimorphic), overhead is still negligible. Only megamorphic dispatch (3+ implementations at the same call site) can cause measurable slowdowns in extremely hot paths.
In practice, pattern-introduced interface overhead is not measurable in service-layer code (database I/O and network latency dominate by 4–5 orders of magnitude). The only context where it matters is tight inner loops processing millions of items per second — like custom serializers or math-heavy batch processing. In that context, consider sealed interface with switch pattern matching instead of virtual dispatch.
9. Key Takeaways
- Strategy pattern eliminates conditional branching on type — each variant lives in its own isolated, testable class
- Spring's
@Component+ constructor injection withList<Interface>is the idiomatic way to implement Strategy in Spring Boot - Factory Method decouples object creation from usage — the consumer never needs to know the concrete type
- Builder pattern is essential for objects with 6+ parameters — especially when some are optional and some combinations are invalid
- Combining Strategy + Factory creates an extension point where new behavior can be added without modifying existing code
- Don't apply patterns preemptively — apply them when the recurring problem they solve actually manifests
- Virtual dispatch overhead from patterns is negligible in typical service-layer code; optimize only proven hot paths
Related Posts
SOLID Principles in Java
Real-world refactoring with all five SOLID principles in Spring Boot microservices.
Clean Architecture
Structuring Spring Boot applications for long-term maintainability and testability.
Domain-Driven Design
DDD aggregates, bounded contexts, and ubiquitous language for complex domains.
Last updated: March 2026 — Written by Md Sanwar Hossain