Complex conditional logic often plagues codebases, making them difficult to read, maintain, and extend. This article explores four design patterns that effectively replace tangled if/else structures with cleaner, more maintainable alternatives.
1. Strategy Pattern
Overview
The Strategy Pattern is a behavioral design pattern that defines a family of algorithms, encapsulates each one, and makes them interchangeable. This pattern allows the algorithm to vary independently from clients that use it.
Consider scenarios where multiple approaches exist for completing the same task, with the selection depending on runtime conditions. Traditional implementations often use straightforward if/else blocks, which work adequately for beginners but become problematic as logic grows.
Such implementations typically violate two fundamental OOP principles:
- Single Responsibility Principle: Each class should have only one reason to change
- Open/Closed Principle: Software entities should be open for extension but closed for modification
Violating these principles leads to code that becomes increasingly fragile and difficult to maintain as conditional branches multiply.
The Strategy Pattern addresses this by:
Encapsulating each algorithm in its own dedicated class, treating these classes as interchangeable strategies with a common interface.
This pattern involves three key roles:
- Context: The class that maintains a reference to a strategy and delegates work to it
- Strategy: An abstract interface defining the contract for all concrete strategies
- ConcreteStrategy: Specific implementations of the strategy interface
Implementation
Step 1: Define the strategy interface
public interface Calculator {
int compute(int first, int second);
}
The interface establishes a contract that all concrete implementations must follow.
Step 2: Create concrete implementations
public class AdditionOperation implements Calculator {
@Override
public int compute(int first, int second) {
return first + second;
}
}
public class SubtractionOperation implements Calculator {
@Override
public int compute(int first, int second) {
return first - second;
}
}
public class MultiplicationOperation implements Calculator {
@Override
public int compute(int first, int second) {
return first * second;
}
}
Step 3: Implement the context class
public class CalculationContext {
private Calculator calculator;
public void setCalculator(Calculator calculator) {
this.calculator = calculator;
}
public int execute(int first, int second) {
return calculator.compute(first, second);
}
}
Step 4: Client usage demonstration
public static void main(String[] args) {
CalculationContext ctx = new CalculationContext();
ctx.setCalculator(new AdditionOperation());
System.out.println("20 + 10 = " + ctx.execute(20, 10));
ctx.setCalculator(new SubtractionOperation());
System.out.println("20 - 10 = " + ctx.execute(20, 10));
ctx.setCalculator(new MultiplicationOperation());
System.out.println("20 * 10 = " + ctx.execute(20, 10));
}
Advantages:
- Provides perfect support for the Open/Closed Principle, enabling algorithm selection without modifying existing code
- Offers a clean way to manage families of related algorithms
- Eliminates complex conditional statements entirely
Disadvantages:
- Clients must understand all available strategy implementations to make appropriate selections
- May introduce numerous strategy classes, though the Flyweight Pattern can mitigate this issue
2. SPI Mechanism
Overview
SPI stands for Service Provider Interface, a service discovery mechanism that enables runtime configuration of interface implementations.
The core principle involves specifying implementation class names in configuration files, which a service loader reads at runtime to dynamically load implementations. This enables programs to extend functionality without code modifications.
Java SPI: JDBC Driver Example
Prior to JDBC 4.0, developers manually loaded database drivers:
Class.forName("com.mysql.jdbc.Driver");
Connection conn = DriverManager.getConnection(url, username, password);
Modern JDBC (4.0+) leverages Java SPI, eliminating the need for explicit driver loading.
The DriverManager class serves as the entry point for driver initialization:
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
The loadInitialDrivers method performs four key steps:
- Retrieves driver definitions from system properties
- Uses SPI to discover driver implementations
- Instantiates discovered implementations
- Registers drivers with the DriverManager
The SPI discovery mechanism:
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
This creates an iterator without immediately loading implementations. During iteration:
Iterator<Driver> driversIterator = loadedDrivers.iterator();
while(driversIterator.hasNext()) {
driversIterator.next();
}
The hasNext() method searches for META-INF/services/java.sql.Driver files across the classpath, while next() instantiates the discovered implementations.
When multiple drivers exist, the JDBC URL determines which one handles the connection—drivers return null for URLs they cannot process, allowing the manager to find the appropriate one.
On-Demand Loading: Dubbo SPI
Java SPI loads all implementations simultaneously, which may be inefficient. Dubbo implements a more sophisticated SPI mechanism supporting lazy loading.
Dubbo SPI configurations reside in META-INF/dubbo with key-value pairs:
optimusPrime = org.apache.spi.OptimusPrime
bumblebee = org.apache.spi.Bumblebee
The @SPI annotation marks extensible interfaces:
public class DubboSPITest {
@Test
public void sayHello() throws Exception {
ExtensionLoader<Robot> extensionLoader =
ExtensionLoader.getExtensionLoader(Robot.class);
Robot optimusPrime = extensionLoader.getExtension("optimusPrime");
optimusPrime.sayHello();
Robot bumblebee = extensionLoader.getExtension("bumblebee");
bumblebee.sayHello();
}
}
Dubbo SPI additionally provides IOC and AOP capabilities beyond Java SPI.
SPI Benefits:
- Complete decoupling between interfaces and implementations
- New implementations require no changes to existing code
- Runtime discovery and loading of services
3. Chain of Responsibility Pattern
Overview
The Chain of Responsibility Pattern is a behavioral pattern that passes requests along a chain of handlers. Each handler decides either to process the request or to pass it to the next handler in the chain.
In simpler terms, the chain is a linked structure where nodes share a common interface, each with its own responsibility and implementation. When input arrives, the first node attempts to handle it; if unsuccessful, the input passes to the next node, continuing until the chain ends.
Ideal Use Cases:
- Multiple objects can handle the same request, with the specific handler determined at runtime
- Handlers may need to process different subsets of requests with potential dependencies between them
- When the number of potential handlers is unknown
Example: Payment Processing System
Consider a ride-hailing payment system with multiple payment methods: account balance, coupons, and corporate accounts. Each method may handle part of a transaction, with handlers evaluated in sequence.
Step 1: Define the handler interface
public interface PaymentProcessor {
boolean supports(String userId);
PaymentOutcome settlePayment(String userId, double amount);
}
class PaymentOutcome {
private final boolean completed;
private final double settledAmount;
private final String description;
public PaymentOutcome(boolean completed, double settledAmount, String description) {
this.completed = completed;
this.settledAmount = settledAmount;
this.description = description;
}
public boolean isCompleted() { return completed; }
public double getSettledAmount() { return settledAmount; }
public String getDescription() { return description; }
}
Step 2: Implement concrete handlers
public class CorporateAccountProcessor implements PaymentProcessor {
private static final double CORPORATE_LIMIT = 5000.0;
@Override
public boolean supports(String userId) {
return userId.startsWith("corp_");
}
@Override
public PaymentOutcome settlePayment(String userId, double amount) {
System.out.printf("Attempting corporate account: user[%s], amount[%.2f]%n",
userId, amount);
if (amount <= CORPORATE_LIMIT) {
return new PaymentOutcome(true, amount, "Corporate payment successful");
}
return new PaymentOutcome(true, CORPORATE_LIMIT,
String.format("Partial corporate payment (%.2f), remaining: %.2f",
CORPORATE_LIMIT, amount - CORPORATE_LIMIT));
}
}
public class WalletProcessor implements PaymentProcessor {
private static final double WALLET_LIMIT = 1000.0;
@Override
public boolean supports(String userId) {
return true;
}
@Override
public PaymentOutcome settlePayment(String userId, double amount) {
System.out.printf("Attempting wallet payment: user[%s], amount[%.2f]%n",
userId, amount);
if (amount <= WALLET_LIMIT) {
return new PaymentOutcome(true, amount, "Wallet payment successful");
}
return new PaymentOutcome(true, WALLET_LIMIT,
String.format("Partial wallet payment (%.2f), remaining: %.2f",
WALLET_LIMIT, amount - WALLET_LIMIT));
}
}
Step 3: Implement the payment coordinator
public class PaymentCoordinator {
private final List<PaymentProcessor> processors = new ArrayList<>();
public void register(PaymentProcessor processor) {
processors.add(processor);
}
public Map<String, PaymentOutcome> processPayment(String userId, double totalAmount) {
Map<String, PaymentOutcome> results = new LinkedHashMap<>();
double outstanding = totalAmount;
for (PaymentProcessor processor : processors) {
if (processor.supports(userId) && outstanding > 0) {
PaymentOutcome outcome = processor.settlePayment(userId, outstanding);
results.put(processor.getClass().getSimpleName(), outcome);
if (outcome.isCompleted()) {
outstanding -= outcome.getSettledAmount();
if (outstanding <= 0) break;
}
}
}
if (outstanding > 0) {
results.put("Outstanding", new PaymentOutcome(false, outstanding,
String.format("Payment incomplete. Outstanding: %.2f", outstanding)));
}
return results;
}
}
Step 4: Usage demonstration
public class PaymentDemo {
public static void main(String[] args) {
PaymentCoordinator coordinator = new PaymentCoordinator();
coordinator.register(new CorporateAccountProcessor());
coordinator.register(new WalletProcessor());
// Scenario 1: Corporate user, single payment method
System.out.println("\n=== Scenario 1 ===");
System.out.println("User: corp_789, Amount: 3000.00");
coordinator.processPayment("corp_789", 3000).forEach((name, outcome) ->
System.out.printf("[%s] %s (settled: %.2f)%n",
name, outcome.getDescription(), outcome.getSettledAmount()));
// Scenario 2: Corporate user, combined payment
System.out.println("\n=== Scenario 2 ===");
System.out.println("User: corp_789, Amount: 6000.00");
coordinator.processPayment("corp_789", 6000).forEach((name, outcome) ->
System.out.printf("[%s] %s (settled: %.2f)%n",
name, outcome.getDescription(), outcome.getSettledAmount()));
// Scenario 3: Regular user, wallet only
System.out.println("\n=== Scenario 3 ===");
System.out.println("User: user456, Amount: 1500.00");
coordinator.processPayment("user456", 1500).forEach((name, outcome) ->
System.out.printf("[%s] %s (settled: %.2f)%n",
name, outcome.getDescription(), outcome.getSettledAmount()));
}
}
4. Rule Engine
Overview
E-commerce platforms frequently implement promotional activities with complex discount logic, such as "spend 1000, get 200 off" or "spend 500, get 100 off."
Simple if/else implementations work initially, but become problematic when rules change frequently, requiring repeated code modifications and deployment cycles.
Rule engines solve this by separating business rules from application code, offering:
- Reduced development overhead
- Business users can configure rules independently
- Improved rule visibility and auditability
- Faster iteration on promotional campaigns
The approach stores rule definitions as text in databases or configuration centers, executing them at runtime with the necessary parameters.
Implementation with AviatorScript
Step 1: Store rule definitions externally
String discountRule = "if (orderAmount >= 1000) {\n" +
" return 200;\n" +
"} elsif (orderAmount >= 500) {\n" +
" return 100;\n" +
"} else {\n" +
" return 0;\n" +
"}";
Step 2: Define rule execution method
public static BigDecimal calculateDiscount(BigDecimal orderAmount, String ruleDefinition) {
Map<String, Object> context = new HashMap<>();
context.put("orderAmount", orderAmount);
Expression compiled = AviatorEvaluator.compile(
DigestUtils.md5Hex(ruleDefinition.getBytes()),
ruleDefinition,
true
);
Object outcome = compiled.execute(context);
return outcome != null ? new BigDecimal(String.valueOf(outcome)) : BigDecimal.ZERO;
}
Step 3: Execute rules at runtime
// Retrieve rule from database or configuration center
String rule = "if (orderAmount >= 1000) {\n" +
" return 200;\n" +
"} elsif (orderAmount >= 500) {\n" +
" return 100;\n" +
"} else {\n" +
" return 0;\n" +
"}";
BigDecimal discount = calculateDiscount(new BigDecimal("600"), rule);
System.out.println("Calculated discount: " + discount);
// Output: 100
Appropriate Scenarios for Rule Engines:
- Business rules undergo frequent modifications
- Non-technical stakeholders need to modify rules
- Rules exhibit high complexity or dynamic nature
Benefits:
- Business users manage rules independently
- Dramatically reduces development and testing cycles
- Rules remain transparent and auditable
Pattern Selection Guidelines
When addressing conditional complexity, consider these principles:
| Principle | Description |
|---|---|
| Single Responsibility | Each component handles one concern |
| Open/Closed | Extend behavior without modifying existing code |
| High Cohesion, Low Coupling | Related elements stay together; dependencies remain minimal |
| KISS | Prefer simplicity over clever solutions |
No universal solution exists. Evaluate your specific context—system complexity, change frequency, and team familiarity—to select the most appropriate approach.