Bỏ qua để đến nội dung
Knowledge Hub

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

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: 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

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.

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 ý:

  • useFactory với mảng inject là 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.

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.

Terminal window
npx --prefix examples ts-node examples/src/strategy-pattern/pricing-demo.ts

Output thật:

regular 100
member 90

Thử 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.

  • 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: if ngắ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ển if sang file khác.

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 if có 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.
  1. 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 if ngắn rõ hơn.
  2. 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.
  3. Policy không gọi database hay payment; phần tính tách khỏi phần ghi.
  4. 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.
  5. Context kiểm tra invariant chung (ví dụ amount hữu hạn, không âm) cho mọi policy.
  6. 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ự kiểm tra:

  1. Thêm loại khách mới thì chỗ nào phải sửa, chỗ nào nên giữ nguyên?
  2. Vì sao checkout vẫn kiểm tra amount dù policy đã được test?
  3. Trạng thái đơn pending, paid, shipped nên mô hình bằng Strategy hay State, vì sao?