design

Factory + Strategy Design Patterns: Payment Processing in Java

Learn how to combine Factory and Strategy patterns to build flexible, maintainable payment processing systems. Real-world example with complete Java implementation.

Reading Time: 12 min readAuthor: DeepTechHub
#design-patterns#factory-pattern#strategy-pattern#java#architecture
Factory + Strategy Design Patterns: Payment Processing in Java

Factory + Strategy Design Patterns: Building Flexible Payment Systems

Design patterns are reusable solutions to common problems in software design. When used together strategically, they create flexible, maintainable systems that scale with changing requirements.

This guide explores how combining Factory Pattern and Strategy Pattern creates robust payment processing systems that can easily support new payment methods without changing existing code.


Why Combine Factory and Strategy?

Imagine building a payment processing system that supports multiple payment methods: card, cash, digital wallet, and tomorrow—cryptocurrency.

Without patterns:

  • You'd have massive if-else chains that grow with each new payment method.
  • processPayment() method knows about all payment types, violating the Open/Closed Principle.
// ❌ BAD: Tight coupling and hard to extend
public void processPayment(String method, double amount) {
    if ("CARD".equals(method)) {
        // card logic
    } else if ("CASH".equals(method)) {
        // cash logic
    } else if ("DIGITAL_WALLET".equals(method)) {
        // wallet logic
    }
    // Each new method requires modifying this code
}

With patterns: New payment methods integrate seamlessly without touching existing code.


Understanding the Patterns

Strategy Pattern

What: Encapsulates a family of algorithms and makes them interchangeable.

When to use: When you have multiple ways to perform a task and want to select at runtime.

Benefit: Isolates the algorithm from the client, allowing you to switch strategies easily.

Factory Pattern

What: Creates objects without specifying their exact classes.

When to use: When object creation logic is complex or should be centralized.

Benefit: Decouples object creation from usage, centralizing instantiation logic.

Why Together?

  • Strategy defines what algorithms you have
  • Factory defines how to create them
  • Together they create a system that's both flexible and maintainable

When to choose this over Enum + Lambda

Use Factory + Strategy classes when strategy behavior is more than small formula-style logic.

  • Each strategy needs its own collaborators (gateway clients, repositories, metrics, retry handlers)
  • You want stronger unit-test isolation per strategy class
  • Strategy behavior is likely to grow independently over time
  • Strategy selection may become config-driven or environment-specific

Use Enum + Lambda when strategies are small, fixed, and mostly stateless.

In short: start with Enum + Lambda for lightweight rules; move to Factory + Strategy classes when complexity grows.

Architecture Diagram

The flow below shows how OrderService asks the factory for a processor, and how each concrete processor implements the same PaymentProcessor strategy contract.


Step-by-Step Implementation

Let's build a payment processing system from the ground up.

Step 1: Define Payment Methods

public enum PaymentMethod {
    CARD, CASH, DIGITAL_WALLET
}

Use an enum to represent all available payment methods. This provides type safety and makes it easy to add new methods.

Step 2: Define Payment Status

public enum PaymentStatus {
    PENDING, COMPLETED, FAILED
}

Track the status of each payment transaction.

Step 3: Create the Strategy Interface

This is the core of our system—the common contract all payment processors must follow:

public interface PaymentProcessor {
    /**
     * Process a payment of the given amount.
     *
     * @param amount The payment amount to process
     * @return true if successful, false otherwise
     */
    boolean processPayment(double amount);
}

Key Point: Every payment method implements this single interface, ensuring consistent behavior.

Step 4: Implement Concrete Strategies

Now create specific implementations for each payment method:

public class CardPaymentProcessor implements PaymentProcessor {
    @Override
    public boolean processPayment(double amount) {
        System.out.println("Processing CARD payment of amount $" + amount);
 
        // In real world: Call payment gateway (Stripe, Square, etc.)
        try {
            // Simulate gateway processing time
            Thread.sleep(1000);
            // Simulate validation
            if (amount > 0) {
                System.out.println("Card payment SUCCESS");
                return true;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return false;
    }
}
public class CashPaymentProcessor implements PaymentProcessor {
    @Override
    public boolean processPayment(double amount) {
        System.out.println("Processing CASH payment of amount $" + amount);
 
        // In real world: Verify cash register, update inventory
        try {
            Thread.sleep(500); // Cash is faster
            if (amount > 0) {
                System.out.println("Cash payment SUCCESS");
                return true;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return false;
    }
}
public class DigitalWalletPaymentProcessor implements PaymentProcessor {
    @Override
    public boolean processPayment(double amount) {
        System.out.println("Processing Digital Wallet payment of amount $" + amount);
 
        // In real world: Call PayPal, Apple Pay, Google Pay API
        try {
            Thread.sleep(2000); // Wallet can be slower due to API calls
            if (amount > 0 && amount < 100000) { // Example validation
                System.out.println("Wallet payment SUCCESS");
                return true;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return false;
    }
}

Observation: Each strategy is independent and handles its own logic. Adding a new payment method only requires creating a new class that implements PaymentProcessor.

Step 5: Create the Factory

The factory centralizes the creation of payment processors:

public class PaymentProcessorFactory {
    /**
     * Factory method to get the appropriate payment processor
     *
     * @param method The payment method to use
     * @return An instance of the appropriate PaymentProcessor
     * @throws IllegalArgumentException if method is not supported
     */
    public static PaymentProcessor getProcessor(PaymentMethod method) {
        return switch(method) {
            case CASH -> new CashPaymentProcessor();
            case CARD -> new CardPaymentProcessor();
            case DIGITAL_WALLET -> new DigitalWalletPaymentProcessor();
        };
    }
}

Java 17+ Tip: The switch expression (not statement) is cleaner and mandatory for exhaustiveness checking.


Using the System

Now clients use the system without worrying about which payment processor to use:

public class OrderService {
    public void checkout(double amount, PaymentMethod paymentMethod) {
        try {
            // Factory creates the right processor
            PaymentProcessor processor = PaymentProcessorFactory.getProcessor(paymentMethod);
 
            // Strategy executes the payment
            boolean success = processor.processPayment(amount);
 
            if (success) {
                System.out.println("Order completed successfully");
            } else {
                System.out.println("Payment failed");
            }
        } catch (IllegalArgumentException e) {
            System.out.println("Unsupported payment method: " + e.getMessage());
        }
    }
}

Usage Example

public class Main {
    public static void main(String[] args) {
        OrderService service = new OrderService();
 
        // Same code, different behavior based on PaymentMethod
        service.checkout(99.99, PaymentMethod.CARD);           // Calls CardPaymentProcessor
        service.checkout(50.00, PaymentMethod.CASH);           // Calls CashPaymentProcessor
        service.checkout(199.99, PaymentMethod.DIGITAL_WALLET); // Calls DigitalWalletPaymentProcessor
    }
}

Adding a New Payment Method

This is where the pattern shines. Want to add cryptocurrency support?

Only add two lines to existing code:

public enum PaymentMethod {
    CARD, CASH, DIGITAL_WALLET, CRYPTOCURRENCY  // ✅ Add here
}
 
public class PaymentProcessorFactory {
    public static PaymentProcessor getProcessor(PaymentMethod method) {
        return switch(method) {
            case CASH -> new CashPaymentProcessor();
            case CARD -> new CardPaymentProcessor();
            case DIGITAL_WALLET -> new DigitalWalletPaymentProcessor();
            case CRYPTOCURRENCY -> new CryptoPaymentProcessor();  // ✅ Add here
        };
    }
}

Then create the new strategy:

public class CryptoPaymentProcessor implements PaymentProcessor {
    @Override
    public boolean processPayment(double amount) {
        System.out.println("Processing CRYPTO payment of amount $" + amount);
        // Crypto-specific logic
        return true;
    }
}

Zero changes to OrderService, checkout(), or client code!


Best Practices

1. Immutability

Keep payment processors stateless:

✅ GOOD: Stateless processors
public class CardPaymentProcessor implements PaymentProcessor {
    @Override
    public boolean processPayment(double amount) { /* ... */ }
}
 
❌ BAD: Storing state
public class CardPaymentProcessor implements PaymentProcessor {
    private String transactionId; // Mutable state
    // Can cause issues in concurrent environments
}

2. Factory Caching

For expensive processor creation, cache instances:

public class PaymentProcessorFactory {
    private static final Map<PaymentMethod, PaymentProcessor> cache =
        new ConcurrentHashMap<>();
 
    public static PaymentProcessor getProcessor(PaymentMethod method) {
        return cache.computeIfAbsent(method, key -> switch(key) {
            case CARD -> new CardPaymentProcessor();
            case CASH -> new CashPaymentProcessor();
            case DIGITAL_WALLET -> new DigitalWalletPaymentProcessor();
        });
    }
}

3. Dependency Injection

In real applications, inject dependencies:

@Component
public class PaymentProcessorFactory {
    @Autowired
    private CardPaymentService cardService;
 
    @Autowired
    private CashRegisterService cashService;
 
    public PaymentProcessor getProcessor(PaymentMethod method) {
        return switch(method) {
            case CARD -> new CardPaymentProcessor(cardService);
            case CASH -> new CashPaymentProcessor(cashService);
            case DIGITAL_WALLET -> new DigitalWalletPaymentProcessor();
        };
    }
}

4. Configuration-Driven

Make payment methods configurable:

@Configuration
public class PaymentConfig {
    @Value("${app.payment.methods:CARD,CASH,DIGITAL_WALLET}")
    private List<String> enabledMethods;
}

Common Mistakes to Avoid

❌ Mistake 1: Adding Logic to Factory

// DON'T do this
public class PaymentProcessorFactory {
    public static PaymentProcessor getProcessor(PaymentMethod method) {
        PaymentProcessor processor = switch(method) {
            case CARD -> new CardPaymentProcessor();
            // ...
        };
        // Factory should NOT contain business logic
        processor.processPayment(100); // ❌ Wrong place
        return processor;
    }
}

❌ Mistake 2: Exposing Implementation

// DON'T do this
public class OrderService {
    public void checkout(double amount) {
        CardPaymentProcessor card = new CardPaymentProcessor(); // ❌ Tight coupling
        card.processPayment(amount);
    }
}

❌ Mistake 3: God Factory

// DON'T do this - Factory doing too much
public class PaymentProcessorFactory {
    public PaymentProcessor getProcessor(String method,
                                        double amount,
                                        User user,
                                        Order order) { // ❌ Too many params
        // Complex logic here
    }
}

Testing Your Design

Unit Testing Strategies

@Test
public void testCardPaymentProcessing() {
    // Create a test double if needed
    PaymentProcessor processor = new CardPaymentProcessor();
    assertTrue(processor.processPayment(50.0));
    assertFalse(processor.processPayment(-10.0));
}
 
@Test
public void testFactoryCreatesCorrectProcessor() {
    PaymentProcessor card = PaymentProcessorFactory.getProcessor(PaymentMethod.CARD);
    assertInstanceOf(CardPaymentProcessor.class, card);
 
    PaymentProcessor cash = PaymentProcessorFactory.getProcessor(PaymentMethod.CASH);
    assertInstanceOf(CashPaymentProcessor.class, cash);
}
 
@Test
public void testPaymentPolymorphism() {
    // Test that all strategies work consistently
    PaymentMethod[] methods = PaymentMethod.values();
    for (PaymentMethod method : methods) {
        PaymentProcessor processor = PaymentProcessorFactory.getProcessor(method);
        // All should handle valid amounts
        assertTrue(processor.processPayment(25.0));
    }
}

When NOT to Use This Pattern

  • Simple systems with only one way to do something (use Strategy when 2+ variants exist)
  • Performance-critical loops creating many processor instances (add caching/pooling)
  • Overkill for trivial logic (balance simplicity vs. extensibility)

Summary

The combination of Factory + Strategy patterns provides:

AspectBenefit
FlexibilitySwitch between implementations at runtime
MaintainabilityEach algorithm in its own class
TestabilityEasy to mock and test strategies independently
ScalabilityAdd new methods without modifying existing code
SOLID PrinciplesFollows Open/Closed, Single Responsibility, Dependency Inversion

This pattern is ideal for payment processing, logging strategies, authentication methods, compression algorithms, and any system with multiple interchangeable implementations.

Enjoyed this article?

Check out more articles or share this with your network.