Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

strategy pattern

Tung Bui Thanh (TungDaDev)9 mins read

Stategy pattern.webp

Strategy Pattern là một trong những design pattern được dùng nhiều nhất. Có điều ở nhiều dự án bây giờ, người ta áp dụng nó khá máy móc: đẻ ra cả tá class chỉ chứa đúng một method 3 dòng, project nhìn như mê cung class, bảo trì mệt. Từ khi Java 8 có function là "công dân hạng nhất" (First-Class Functions) và Spring Boot lo phần Dependency Injection, Strategy Pattern đã có cách viết khác hẳn: gọn hơn, ít class hơn mà vẫn type-safe.

Từ tính chiết khấu đơn hàng trong E-Commerce, chọn thuật toán nén dữ liệu, tới tự chọn thuật toán mã hoá trong FinTech, Strategy Pattern giúp tách thuật toán ra khỏi luồng điều phối nghiệp vụ.

Dưới đây là ranh giới giữa Strategy, State và Template Method, cách Java Lambdas cắt bớt phần lớn code khuôn mẫu, rồi dựng một Dynamic Pricing Engine (công cụ tính giá khuyến mãi động) đủ dùng cho production.


# Strategy vs state vs template method

Ba behavioral pattern này hay bị nhầm vì nhìn Class Diagram thì na ná nhau:

# bảng so sánh chi tiết

Tiêu chíStrategy PatternState PatternTemplate Method Pattern
Cách cài đặtComposition (uỷ quyền)Composition (uỷ quyền)Inheritance (kế thừa)
Ai quyết định đổi?Client hoặc Service Context chọn strategy phù hợpCác State tự chuyển qua lại với nhauChốt cứng lúc biên dịch (Compile-time)
Dùng để làm gìĐổi qua lại giữa các thuật toán độc lậpQuản lý vòng đời trạng thái của objectDùng lại khung logic chung
Có giữ trạng thái khôngStrategy thường Stateless (không giữ trạng thái)State thường giữ ngữ cảnh để chuyển đổiTuỳ vào cây kế thừa

# từ Java 8 trở đi: lambda thay cho đống class thừa

Trước Java 8, muốn có 3 cách định dạng ngày tháng thì phải tạo 1 Interface và 3 Class riêng:

# viết gọn bằng functional interfaces

Không cần tạo class RoadShipping, SeaShipping nữa, chỉ cần dùng BiFunction<BigDecimal, BigDecimal, BigDecimal> hoặc tự định nghĩa một @FunctionalInterface. Tự định nghĩa thường dễ đọc hơn: tên ShippingCalculator.calculate(weightKg, distanceKm) nói rõ ý nghĩa hơn BiFunction.apply(a, b). Và với tiền thì dùng BigDecimal, đừng dùng Double vì sai số dấu phẩy động (0.1 + 0.2 ra 0.30000000000000004).

@FunctionalInterface
public interface ShippingCalculator {
    BigDecimal calculate(BigDecimal weightKg, BigDecimal distanceKm);
}
 
// Bảng biểu diễn chiến lược dưới dạng Lambda hoặc Static Method References:
public class ShippingStrategies {
    public static final ShippingCalculator ROAD =
        (weight, distance) -> weight.multiply(new BigDecimal("1.5")).add(distance.multiply(new BigDecimal("0.5")));
 
    public static final ShippingCalculator SEA =
        (weight, distance) -> weight.multiply(new BigDecimal("1.0")).add(distance.multiply(new BigDecimal("0.2")));
 
    public static final ShippingCalculator AIR =
        (weight, distance) -> weight.multiply(new BigDecimal("2.5")).add(distance.multiply(new BigDecimal("1.0")));
}

Ở tầng Controller / Service, đổi strategy giờ nhẹ nhàng hẳn:

public class ShippingService {
    public BigDecimal computeCost(BigDecimal weight, BigDecimal dist, ShippingCalculator calculator) {
        return calculator.calculate(weight, dist); // Nhận trực tiếp bất kỳ Lambda nào!
    }
}
 
// Gọi thực thi:
shippingService.computeCost(w, d, ShippingStrategies.AIR);
// Hoặc truyền trực tiếp inline lambda cho chương trình khuyến mãi đặc biệt:
shippingService.computeCost(w, d, (weight, dist) -> BigDecimal.ZERO); // Miễn phí vận chuyển

# ví dụ cỡ enterprise: dynamic promotion & pricing engine

Ở các hệ thống thương mại điện tử lớn, tính giá khuyến mãi (Discount & Pricing) chẳng bao giờ là một phép tính đơn giản. Một đơn hàng có thể dính cùng lúc nhiều strategy:

  1. Giảm giá theo hạng thành viên (VIP Tier Discount).
  2. Voucher, coupon giảm giá.
  3. Flash Sale theo khung giờ vàng.

# viết promotion Strategy với Spring Boot

public interface PromotionStrategy {
    boolean isApplicable(OrderContext context);
    DiscountResult applyDiscount(OrderContext context);
    int getExecutionOrder(); // Thứ tự ưu tiên áp dụng (Order precedence)
}
 
@Component
@Order(1)
public class VipCustomerPromotionStrategy implements PromotionStrategy {
    @Override
    public boolean isApplicable(OrderContext context) {
        return context.customer().isVip();
    }
 
    @Override
    public DiscountResult applyDiscount(OrderContext context) {
        BigDecimal discount = context.currentTotal().multiply(new BigDecimal("0.10"));
        return new DiscountResult("VIP_10_PERCENT", discount);
    }
 
    @Override
    public int getExecutionOrder() { return 1; }
}
 
@Component
@Order(2)
public class FirstOrderPromotionStrategy implements PromotionStrategy {
    @Override
    public boolean isApplicable(OrderContext context) {
        return context.customer().totalOrders() == 0;
    }
 
    @Override
    public DiscountResult applyDiscount(OrderContext context) {
        return new DiscountResult("FIRST_ORDER_FIXED", new BigDecimal("50000"));
    }
 
    @Override
    public int getExecutionOrder() { return 2; }
}

# engine tự gom hết strategy (Spring autowiring)

@Service
public class PromotionExecutionEngine {
 
    private final List<PromotionStrategy> strategies;
 
    // Spring tự inject mọi bean PromotionStrategy, và List được inject đã được sắp theo @Order.
    // Ở đây sắp lại theo getExecutionOrder(): trong code thật chỉ nên giữ MỘT nguồn thứ tự,
    // hai nguồn lệch nhau là bug khó thấy.
    public PromotionExecutionEngine(List<PromotionStrategy> strategies) {
        this.strategies = strategies.stream()
            .sorted(Comparator.comparingInt(PromotionStrategy::getExecutionOrder))
            .toList();
    }
 
    public OrderPricingResult calculateFinalPrice(OrderContext context) {
        BigDecimal runningTotal = context.initialTotal();
        List<DiscountResult> appliedDiscounts = new ArrayList<>();
 
        for (PromotionStrategy strategy : strategies) {
            if (strategy.isApplicable(context)) {
                DiscountResult result = strategy.applyDiscount(context);
                runningTotal = runningTotal.subtract(result.amount()).max(BigDecimal.ZERO); // không để giá âm
                appliedDiscounts.add(result);
 
                // Cập nhật giá trị đơn hàng sau khi giảm để chiến lược tiếp theo tính toán
                context = context.withCurrentTotal(runningTotal);
            }
        }
 
        return new OrderPricingResult(runningTotal, appliedDiscounts);
    }
}

Thứ tự ở đây không chỉ là chuyện gọn gàng mà ảnh hưởng thẳng tới số tiền. Đơn 1.000.000đ, giảm 10% trước rồi trừ 50.000đ thì còn 850.000đ; trừ 50.000đ trước rồi giảm 10% thì còn 855.000đ. Vì vậy thứ tự áp dụng phải là quyết định nghiệp vụ được ghi rõ, không phải thứ tự ngẫu nhiên Spring tìm thấy bean. Hệ thống thật thường còn cần thêm luật loại trừ (voucher này không dùng chung với flash sale) và trần giảm giá tối đa cho mỗi đơn.


# cái bẫy thread-safety khi chạy đa luồng

Một trong những lỗi nặng nhất khi viết Strategy trong Spring Boot là để strategy giữ trạng thái (Stateful Strategy):

// NGUY HIỂM: Rò rỉ trạng thái giữa các request và các luồng!
@Component
public class BrokenDiscountStrategy implements PromotionStrategy {
    private BigDecimal accumulatedDiscount = BigDecimal.ZERO; // BIẾN MUTABLE TRÊN SPRING SINGLETON!
 
    @Override
    public boolean isApplicable(OrderContext context) { return true; }
 
    @Override
    public int getExecutionOrder() { return 99; }
 
    @Override
    public DiscountResult applyDiscount(OrderContext context) {
        // Nhiều requests đồng thời cùng sửa đổi accumulatedDiscount -> RACE CONDITION!
        this.accumulatedDiscount = this.accumulatedDiscount.add(new BigDecimal("10000"));
        return new DiscountResult("PROMO", this.accumulatedDiscount);
    }
}

Ngay cả khi chỉ có một thread, đoạn code này vẫn sai: bean là singleton nên accumulatedDiscount sống qua mọi request, đơn sau được giảm 20.000, đơn sau nữa 30.000... Chạy đa luồng thì tệ hơn, hai request cùng đọc rồi cùng ghi đè nhau nên con số còn không đoán trước được.

Nguyên tắc không được phá: mọi Strategy bean trong Spring Boot phải Stateless (không giữ trạng thái). Cần dữ liệu gì thì truyền qua tham số method (OrderContext context), còn kết quả trả về phải là một DTO/Record immutable mới.


# checklist trước khi lên production (architect's checklist)

  • 1. Stateless 100%: soát lại các class Strategy, đảm bảo không có biến instance mutable nào có thể gây race condition khi chạy đa luồng.
  • 2. Tận dụng Functional Interface: strategy chỉ có 1 hàm tính toán đơn giản thì dùng Lambda hoặc Function<T, R>, đừng tạo class riêng.
  • 3. Chốt thứ tự áp dụng (Ordering): khi kết hợp nhiều Strategy (Composite Pipeline), luôn ghi rõ độ ưu tiên bằng @Order hoặc method getOrder().
  • 4. Fallback Strategy: luôn có một strategy mặc định (No-Op Strategy: return input;) cho trường hợp không strategy nào khớp điều kiện.
  • 5. Đo thời gian chạy: gắn metric đo thời gian chạy của từng Strategy, để bắt được thuật toán nào đang kéo P99 Latency của đơn hàng lên.

# lời kết

Strategy Pattern không chỉ là bài tập đổi if-else trong sách giáo khoa. Dùng đúng chỗ, nó cho phép thêm một thuật toán mới mà không phải sửa luồng điều phối cũ.

Strategy chỉ có một hàm đơn giản thì dùng lambda; strategy cần dependency (repository, config) hoặc cần test riêng thì để thành bean Spring. Còn nếu chỉ có hai nhánh và không có dấu hiệu sẽ thêm, một câu if vẫn dễ đọc hơn cả một bộ interface.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.