Strategy pattern: thay đổi quy tắc mà không nhồi thêm nhánh vào checkout Cơ bản
Thời gian đọc ước tính: 10 phút
Vấn đề
Phần tiêu đề “Vấn đề”Checkout ban đầu chỉ lấy tổng tiền. Sau đó thêm giảm giá thành viên, khuyến mãi theo kênh, giá đối tác, chương trình theo mùa. Hàm checkout() mọc dần các nhánh if và còn trộn cả gọi payment. Sửa một loại giá có nguy cơ làm hỏng loại khác; test một phép tính nhỏ phải dựng cả checkout.
Vấn đề không phải if luôn xấu, mà là một hành vi thay đổi theo nhiều biến thể đang nằm trong use case có trách nhiệm khác. Nếu “cách tính giá” có contract rõ, ta tách được thành các policy thay thế nhau. Đó là Strategy.
Before và after
Phần tiêu đề “Before và after”Before: if-chain trong checkout
function checkout(cart: Cart): number { let amount = cart.subtotal; if (cart.customerTier === 'member') amount = Math.max(0, amount - 10); else if (cart.customerTier === 'partner') amount = amount * 0.8; // ... thêm mỗi loại khách là thêm một nhánh // ... và payment, ghi đơn cũng nằm trong cùng hàm return amount;}After: policy sau một contract (đây là file demo)
type PricePolicy = (cart: Cart) => number;
const regularPrice: PricePolicy = (cart) => cart.subtotal;const memberPrice: PricePolicy = (cart) => Math.max(0, cart.subtotal - 10);
function checkout(cart: Cart, policy: PricePolicy): number { const amount = policy(cart); if (!Number.isFinite(amount) || amount < 0) throw new Error('invalid amount'); return amount;}
function selectPolicy(tier: Cart['customerTier']): PricePolicy { return tier === 'member' ? memberPrice : regularPrice;}| If-chain | Strategy | |
|---|---|---|
| Thêm biến thể | Sửa hàm lõi | Thêm một policy + một dòng trong selector |
| Test công thức | Dựng cả checkout | Gọi policy với input, so output |
| Invariant chung | Dễ bị quên ở nhánh mới | Checkout kiểm tra một lần cho mọi policy |
| Nơi quyết định “ai dùng gì” | Rải trong hàm | Một selector |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Ba vai:
| Thành phần | Câu hỏi nó trả lời | Không nên làm |
|---|---|---|
| Selector | Khách này dùng policy nào? | Tính lại giá ở đây |
| Strategy | Với input này, số tiền là bao nhiêu? | Gọi payment gateway hoặc ghi đơn |
| Context (checkout) | Dùng số tiền đã tính để tiếp tục workflow thế nào? | Chứa công thức từng loại |
Đang tải diagram…
Strategy không làm điều kiện biến mất: selector vẫn có nhánh, nhưng nhánh chọn nằm một chỗ còn mỗi policy giữ quy tắc thực thi. Strategy cũng không đảm bảo Open/Closed hoàn hảo: thêm policy vẫn phải sửa selector hoặc cấu hình. Lợi ích thật là giới hạn phạm vi sửa và test độc lập.
Trong TypeScript, function là first-class nên Strategy không bắt buộc là class. Chỉ dùng class khi policy cần nhiều dependency hoặc lifecycle riêng.
Policy nên thuần: chỉ nhận input và trả kết quả, không đọc đồng hồ ngầm hay gọi database. Nếu cần giờ bắt đầu chương trình, truyền now vào input. Nhờ vậy cùng input luôn cho cùng output, và sau này tái hiện được giá đã áp dụng.
Strategy với NestJS DI
Phần tiêu đề “Strategy với NestJS DI”Khi policy cần dependency (price table, feature flag), để DI container ghép. Mẫu phổ biến: đăng ký các strategy, gom vào một registry theo key, inject registry vào checkout. Minh họa (không nằm trong file demo, chưa chạy):
export const PRICE_POLICIES = Symbol('PRICE_POLICIES');
export interface PricePolicy { readonly tier: 'regular' | 'member'; price(cart: Cart): number;}
@Injectable()export class RegularPricePolicy implements PricePolicy { readonly tier = 'regular' as const; price(cart: Cart) { return cart.subtotal; }}
@Injectable()export class MemberPricePolicy implements PricePolicy { readonly tier = 'member' as const; constructor(private readonly discounts: DiscountTable) {} price(cart: Cart) { return Math.max(0, cart.subtotal - this.discounts.member()); }}
@Injectable()export class CheckoutService { constructor(@Inject(PRICE_POLICIES) private readonly policies: Map<string, PricePolicy>) {}
total(cart: Cart): number { const policy = this.policies.get(cart.customerTier); if (!policy) throw new Error('no policy for tier ' + cart.customerTier); const amount = policy.price(cart); if (!Number.isFinite(amount) || amount < 0) throw new Error('invalid amount'); return amount; }}
@Module({ providers: [ RegularPricePolicy, MemberPricePolicy, DiscountTable, { provide: PRICE_POLICIES, useFactory: (...list: PricePolicy[]) => new Map(list.map((p) => [p.tier, p])), inject: [RegularPricePolicy, MemberPricePolicy], }, CheckoutService, ],})export class PricingModule {}Lưu ý:
useFactoryvới mảnginjectlà cơ chế custom provider của NestJS (xem Custom providers và DI container).- Thêm policy mới vẫn phải sửa module (thêm provider và dòng trong
inject). Đây chính là chỗ “Strategy không phải Open/Closed hoàn hảo”. - Checkout thiếu policy cho một tier thì fail rõ ràng thay vì mặc định âm thầm.
Strategy, State và Template Method
Phần tiêu đề “Strategy, State và Template Method”Ba pattern này dễ nhầm vì đều “đổi hành vi sau một interface”. Theo Refactoring.Guru:
| Strategy | State | Template Method | |
|---|---|---|---|
| Cơ chế | Composition: truyền object/function vào | Composition: context giữ object trạng thái | Kế thừa: subclass ghi đè bước |
| Ai chọn biến thể | Client/selector từ bên ngoài | Chính các state tự chuyển state của context | Chọn lúc định nghĩa class (tĩnh) |
| Các biến thể biết nhau không | Không, độc lập hoàn toàn | Có thể (state biết state kế tiếp) | Không áp dụng |
| Đổi lúc runtime | Có | Có (theo vòng đời) | Không |
| Ví dụ | Chọn công thức giá theo tier | Đơn hàng: pending, paid, shipped | Pipeline xử lý file có bước đọc/ghi khác nhau |
Cách chọn nhanh: biến thể do bên ngoài chọn và thay nhau được thì Strategy; hành vi đổi theo vòng đời nội tại của object thì State; khung thuật toán cố định và chỉ vài bước khác thì Template Method.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Chạy demo
Phần tiêu đề “Chạy demo”npx --prefix examples ts-node examples/src/strategy-pattern/pricing-demo.tsOutput thật:
regular 100member 90Thử truyền policy trả NaN hoặc -1: checkout throw invalid amount, nghĩa là context giữ một invariant chung độc lập với policy.
Demo dùng number để tập trung vào pattern. Với tiền thật, dùng minor units (integer an toàn hoặc bigint) hoặc decimal library có quy tắc làm tròn rõ, và test ở biên: giỏ 0, nhiều discount, refund một phần, đổi currency, promotion hết hạn đúng lúc checkout.
Khi nào dùng / không dùng
Phần tiêu đề “Khi nào dùng / không dùng”- Dùng khi có từ ba biến thể trở lên (hoặc chắc chắn sắp có), mỗi biến thể cần test riêng, hoặc chọn theo runtime.
- Không dùng cho một nhánh hiếm, không có khả năng mở rộng:
ifngắn rõ hơn. Selector phình thành hàng trăm điều kiện chồng chéo thì cần xem lại model nghiệp vụ hoặc dùng rules engine, không chỉ chuyểnifsang file khác.
Trade-offs
Phần tiêu đề “Trade-offs”Khi Strategy có ích
- Biến thể test riêng với input/output cụ thể.
- Lõi workflow không chứa công thức từng kênh, giảm rủi ro sửa chéo.
- Chọn policy lúc runtime khi điều kiện áp dụng thật sự khác nhau.
Chi phí thêm vào
- Thêm contract, selector và nhiều file; với hai nhánh ngắn
ifcó thể rõ hơn. - Chọn sai policy vẫn là lỗi nghiệp vụ dù từng strategy pass test.
- Contract quá hẹp phải đổi khi cần breakdown, currency hoặc async I/O.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Checklist review một Strategy
Phần tiêu đề “Checklist review một Strategy”- Có ít nhất ba biến thể (hoặc chắc chắn sắp có) cần test riêng; nếu không, một
ifngắn rõ hơn. - Policy thuần: chỉ nhận input, trả kết quả; giá trị như
nowđược truyền vào, không đọc ngầm. - Policy không gọi database hay payment; phần tính tách khỏi phần ghi.
- Nhánh chọn policy nằm ở một selector (hoặc registry) và có test riêng; thiếu policy cho một key thì fail rõ ràng.
- Context kiểm tra invariant chung (ví dụ
amounthữu hạn, không âm) cho mọi policy. - Dùng function khi policy không có state hay dependency; chỉ dùng class khi cần DI hoặc lifecycle riêng.
Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra:
- Thêm loại khách mới thì chỗ nào phải sửa, chỗ nào nên giữ nguyên?
- Vì sao
checkoutvẫn kiểm traamountdù policy đã được test? - Trạng thái đơn pending, paid, shipped nên mô hình bằng Strategy hay State, vì sao?