Strategy Pattern: thay if-else bằng một interface

1. Tổng quan

Chuỗi if-else hoặc switch dài để chọn hành vi theo loại là dấu hiệu code sắp khó bảo trì. Mỗi lần thêm một loại mới, bạn phải sửa lại cùng một hàm, vi phạm Open/Closed Principle. Bài này chỉ cho bạn cách dùng Strategy Pattern để tách từng hành vi thành một class riêng, implement cùng một interface, rồi để Spring tự chọn strategy phù hợp thay vì bạn viết if-else. Đọc xong, bạn sẽ tự tái cấu trúc được một service tính phí/giảm giá đang rối rắm.

2. Vấn đề: if-else phình to theo thời gian

Giả sử bạn có PaymentService tính phí giao dịch theo phương thức thanh toán:

public class PaymentService {

    public double calculateFee(String paymentType, double amount) {
        if (paymentType.equals("CREDIT_CARD")) {
            return amount * 0.03;
        } else if (paymentType.equals("BANK_TRANSFER")) {
            return amount * 0.01;
        } else if (paymentType.equals("E_WALLET")) {
            return amount * 0.015;
        } else {
            throw new IllegalArgumentException("Unsupported type: " + paymentType);
        }
    }
}

Vấn đề:

  • Thêm phương thức thanh toán mới (ví dụ CRYPTO) phải sửa trực tiếp vào method này.
  • Logic tính phí của từng loại trộn lẫn trong một hàm, khó test riêng lẻ.
  • Dùng String để so sánh dễ gây lỗi chính tả, không có compile-time check.

3. Giải pháp: định nghĩa một interface Strategy

Ý tưởng cốt lõi: trích xuất “hành vi thay đổi theo loại” thành một interface, mỗi loại là một implementation riêng.

public interface FeeStrategy {
    double calculateFee(double amount);
}

Mỗi strategy giờ là một class độc lập, dễ đọc, dễ test:

public class CreditCardFeeStrategy implements FeeStrategy {
    @Override
    public double calculateFee(double amount) {
        return amount * 0.03;
    }
}
public class BankTransferFeeStrategy implements FeeStrategy {
    @Override
    public double calculateFee(double amount) {
        return amount * 0.01;
    }
}
public class EWalletFeeStrategy implements FeeStrategy {
    @Override
    public double calculateFee(double amount) {
        return amount * 0.015;
    }
}

PaymentService giờ chỉ cần nhận một FeeStrategy và gọi nó, không còn biết gì về logic bên trong:

public class PaymentService {

    private final FeeStrategy feeStrategy;

    public PaymentService(FeeStrategy feeStrategy) {
        this.feeStrategy = feeStrategy;
    }

    public double calculateFee(double amount) {
        return feeStrategy.calculateFee(amount);
    }
}
  • feeStrategy được inject qua constructor, PaymentService không cần biết là CreditCardFeeStrategy hay loại nào khác.
  • Thêm phương thức thanh toán mới = thêm một class mới implement FeeStrategy, không sửa PaymentService.

4. Chọn strategy đúng cách: tránh if-else ở nơi khác

Vấn đề thường gặp: bạn xoá if-else khỏi PaymentService nhưng lại dựng nó lên ở chỗ gọi service. Để tránh điều này, dùng một Map<String, FeeStrategy> làm registry, đăng ký strategy theo key tại nơi khởi tạo (factory), không rải if-else khắp code.

public class FeeStrategyFactory {

    private final Map<String, FeeStrategy> strategies;

    public FeeStrategyFactory() {
        this.strategies = Map.of(
            "CREDIT_CARD", new CreditCardFeeStrategy(),
            "BANK_TRANSFER", new BankTransferFeeStrategy(),
            "E_WALLET", new EWalletFeeStrategy()
        );
    }

    public FeeStrategy resolve(String paymentType) {
        FeeStrategy strategy = strategies.get(paymentType);
        if (strategy == null) {
            throw new IllegalArgumentException("Unsupported type: " + paymentType);
        }
        return strategy;
    }
}
  • strategies là map không đổi, build một lần khi khởi tạo factory.
  • resolve() là nơi duy nhất còn “nhánh điều kiện” — nhưng nó chỉ tra map, không chứa logic nghiệp vụ. Logic thật nằm trong từng strategy.

Dùng thử:

FeeStrategyFactory factory = new FeeStrategyFactory();
FeeStrategy strategy = factory.resolve("E_WALLET");
PaymentService service = new PaymentService(strategy);

double fee = service.calculateFee(1_000_000);
System.out.println(fee);
15000.0

Lưu ý: nếu danh sách strategy cố định và ít thay đổi (2–3 loại), if-else đơn giản vẫn ổn. Strategy Pattern phát huy giá trị khi số loại tăng dần hoặc logic mỗi loại phức tạp, cần test riêng.

5. Tận dụng Spring: để container tự inject danh sách strategy

Trong Spring Boot, bạn không cần tự viết Map tay. Đánh dấu mỗi strategy là một @Component, Spring sẽ tự thu thập tất cả bean cùng interface vào một List.

public interface FeeStrategy {
    String getPaymentType();
    double calculateFee(double amount);
}

Thêm getPaymentType() để mỗi strategy tự khai báo nó phục vụ loại nào:

@Component
public class CreditCardFeeStrategy implements FeeStrategy {

    @Override
    public String getPaymentType() {
        return "CREDIT_CARD";
    }

    @Override
    public double calculateFee(double amount) {
        return amount * 0.03;
    }
}

Factory giờ nhận List<FeeStrategy> do Spring inject, tự build map bằng @PostConstruct:

@Component
public class FeeStrategyFactory {

    private final Map<String, FeeStrategy> strategies = new HashMap<>();

    public FeeStrategyFactory(List<FeeStrategy> strategyList) {
        for (FeeStrategy strategy : strategyList) {
            strategies.put(strategy.getPaymentType(), strategy);
        }
    }

    public FeeStrategy resolve(String paymentType) {
        FeeStrategy strategy = strategies.get(paymentType);
        if (strategy == null) {
            throw new IllegalArgumentException("Unsupported type: " + paymentType);
        }
        return strategy;
    }
}
  • Spring quét toàn bộ bean implement FeeStrategy, đưa vào strategyList theo đúng thứ tự khởi tạo.
  • Constructor build map một lần duy nhất khi FeeStrategyFactory được tạo — không có if-else nào trong toàn bộ luồng chọn strategy.
  • Thêm CryptoFeeStrategy mới: chỉ cần tạo class, gắn @Component, Spring tự nhận diện. FeeStrategyFactory không cần build lại.

PaymentService dùng factory này qua @Service:

@Service
public class PaymentService {

    private final FeeStrategyFactory feeStrategyFactory;

    public PaymentService(FeeStrategyFactory feeStrategyFactory) {
        this.feeStrategyFactory = feeStrategyFactory;
    }

    public double calculateFee(String paymentType, double amount) {
        FeeStrategy strategy = feeStrategyFactory.resolve(paymentType);
        return strategy.calculateFee(amount);
    }
}

6. Viết test cho từng strategy và cho factory

Vì mỗi strategy là một class độc lập, bạn test riêng từng cái mà không cần mock gì cả:

class CreditCardFeeStrategyTest {

    private final CreditCardFeeStrategy strategy = new CreditCardFeeStrategy();

    @Test
    void shouldCalculate3PercentFee() {
        double fee = strategy.calculateFee(1_000_000);

        assertThat(fee).isEqualTo(30_000.0);
    }
}

Test cho factory đảm bảo việc “chọn strategy” hoạt động đúng, không rơi vào if-else sai:

class FeeStrategyFactoryTest {

    private final FeeStrategyFactory factory = new FeeStrategyFactory(
        List.of(new CreditCardFeeStrategy(), new EWalletFeeStrategy())
    );

    @Test
    void shouldResolveCorrectStrategy() {
        FeeStrategy strategy = factory.resolve("E_WALLET");

        assertThat(strategy).isInstanceOf(EWalletFeeStrategy.class);
    }

    @Test
    void shouldThrowWhenTypeNotSupported() {
        assertThatThrownBy(() -> factory.resolve("CRYPTO"))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("CRYPTO");
    }
}
  • shouldResolveCorrectStrategy xác nhận factory trả đúng loại strategy theo key.
  • shouldThrowWhenTypeNotSupported đảm bảo trường hợp không tìm thấy strategy vẫn báo lỗi rõ ràng, giống hành vi else cũ — nhưng giờ tập trung ở một nơi duy nhất.

7. Dùng Enum thay cho String key (tùy chọn an toàn hơn)

Nếu lo ngại lỗi chính tả với String key, bạn có thể dùng enum làm key cho map, giúp compiler bắt lỗi sớm:

public enum PaymentType {
    CREDIT_CARD, BANK_TRANSFER, E_WALLET
}
public interface FeeStrategy {
    PaymentType getPaymentType();
    double calculateFee(double amount);
}

Cách này giữ nguyên toàn bộ kiến trúc ở trên, chỉ đổi kiểu key trong Map<PaymentType, FeeStrategy>. IDE sẽ gợi ý đầy đủ giá trị enum, tránh gõ sai chuỗi.

8. Khi nào KHÔNG nên dùng Strategy Pattern

  • Chỉ có 2 nhánh logic, khả năng mở rộng gần như bằng không — một if-else đơn giản dễ đọc hơn việc tạo thêm 2–3 class.
  • Logic các nhánh giống nhau 90%, chỉ khác vài tham số — dùng strategy sẽ tạo nhiều class gần như trùng lặp. Trường hợp này, một enum với field cấu hình (ví dụ tỷ lệ phí) thường gọn hơn.
  • Hành vi được chọn không theo “loại” cố định mà phụ thuộc nhiều điều kiện runtime phức tạp (nhiều tiêu chí kết hợp) — khi đó Strategy Pattern đơn giản không đủ, bạn cần cân nhắc Chain of Responsibility hoặc rule engine.

Lưu ý: đừng áp dụng pattern vì nó “nhìn chuyên nghiệp hơn”. Chỉ dùng khi bạn thấy rõ số lượng nhánh sẽ tăng theo thời gian hoặc logic mỗi nhánh đủ phức tạp để cần test độc lập.

9. Kết luận

Strategy Pattern giải quyết đúng vấn đề của chuỗi if-else dài: tách hành vi thay đổi theo loại thành các class riêng, cùng implement một interface. Trong Spring Boot, bạn tận dụng được cơ chế inject List<Bean> để tự động thu thập strategy, không cần tự quản lý registry. Kết quả là thêm loại mới không đụng vào code cũ, mỗi strategy test độc lập dễ dàng, và nơi “chọn strategy” chỉ còn một chỗ duy nhất — một lookup trong map, không còn rải if-else khắp codebase.

design-patternjavaspring-bootclean-code