Hành vi Cơ bản 7 phút đọc

Strategy Pattern trong C#: Func, DI, hay Interface?

Strategy pattern trong C# / .NET 10: khi Func delegate là đủ, khi interface inject xứng đáng, và Strategy khác State và Command thế nào.

Mục lục
  1. Strategy pattern giải quyết bài toán gì trong C#?
  2. Strategy giáo khoa trong .NET 10 trông thế nào?
  3. Khi nào Func delegate là đủ?
  4. Khi nào interface inject xứng đáng?
  5. Strategy kết hợp Factory Method để pick lúc runtime thế nào?
  6. Khi nào Strategy pattern bắn trật?
  7. So sánh Strategy với State và Command thế nào?
  8. Một ví dụ thật trong .NET 10 trông thế nào?
  9. Đọc tiếp gì trong series?

Shop có ba thuật toán discount hôm nay: PercentOff, BuyOneGetOne, FreeShipping. Mai sẽ có cái thứ tư. Phiên bản đầu là switch qua một string trong OrderTotalsCalculator. Sáu tháng sau calculator phụ thuộc helper ngày, segment customer, và bốn service khác — tất cả import chỉ vì một biến thể discount nào đó dùng.

Strategy pattern là câu trả lời. Mỗi thuật toán thành object riêng sau interface nhỏ. Caller chọn cái nào dùng; calculator giữ sạch. C# hiện đại cho hai hình dạng dễ chịu: Func<> một dòng cho strategy tầm thường, và interface inject cho cái phong phú. Bài này nói về chọn giữa hai cái — và nhận ra bạn đã dùng Strategy khoảnh khắc inject IComparer<T> vào sort.

Đây là bài chúng ta tham chiếu chéo từ Singleton, Factory Method, Bridge, Command, State, và một số khác.

Strategy pattern giải quyết bài toán gì trong C#?

Pattern xứng chỗ khi logic của caller biến đổi đúng ở một bước được định nghĩa rõ, và bước đó có nhiều implementation thay thế được. Ba hình dạng cụ thể:

  1. Rule discount / pricing. Percent-off, BOGO, free shipping, tiered. Calculator cart chạy bất cứ rule nào áp.
  2. Sort / so sánh. IComparer<T>, IEqualityComparer<T>. Caller chọn so sánh; sort không bao giờ biết chi tiết.
  3. Format serialise. Cùng data, JSON hay Protobuf hay CSV. Serialiser là Strategy.

Cái không phải bài toán Strategy: "hành vi đổi khi state transition" — đó là State. "Tôi muốn ghi action cho replay" — đó là Command. Strategy đặc biệt nói về một thuật toán, swappable bởi caller.

Strategy giáo khoa trong .NET 10 trông thế nào?

Một interface nhỏ và một class mỗi thuật toán:

public sealed record CartContext(string[] Skus, decimal Subtotal, string Region);

public interface IDiscountStrategy
{
    decimal Apply(CartContext ctx);
}

public sealed class PercentOff(decimal Percent) : IDiscountStrategy
{
    public decimal Apply(CartContext ctx) => ctx.Subtotal * (1m - Percent);
}

public sealed class FreeShipping : IDiscountStrategy
{
    public decimal Apply(CartContext ctx) => ctx.Subtotal;   // shipping được thêm sau
}

public sealed class BuyOneGetOne(string Sku, decimal UnitPrice) : IDiscountStrategy
{
    public decimal Apply(CartContext ctx)
    {
        var count = ctx.Skus.Count(s => s == Sku);
        return ctx.Subtotal - (count / 2) * UnitPrice;
    }
}

Caller nhận strategy như dependency và chạy nó:

public sealed class CartCalculator
{
    private readonly IDiscountStrategy _discount;
    public CartCalculator(IDiscountStrategy discount) => _discount = discount;
    public decimal GrandTotal(CartContext ctx) => _discount.Apply(ctx);
}

Bức tranh cấu trúc:

classDiagram
    class IDiscountStrategy {
        <<interface>>
        +Apply(ctx) decimal
    }
    class PercentOff
    class FreeShipping
    class BuyOneGetOne
    IDiscountStrategy <|.. PercentOff
    IDiscountStrategy <|.. FreeShipping
    IDiscountStrategy <|.. BuyOneGetOne

    class CartCalculator
    CartCalculator o-- IDiscountStrategy : dùng

Thêm strategy mới là một class mới cộng một dòng DI.

Khi nào Func delegate là đủ?

Cho strategy một-method tầm thường, interface là overhead. Func<TInput, TOutput> là cùng pattern với một file ít hơn:

public sealed class CartCalculator
{
    private readonly Func<CartContext, decimal> _discount;
    public CartCalculator(Func<CartContext, decimal> discount) => _discount = discount;
    public decimal GrandTotal(CartContext ctx) => _discount(ctx);
}

// Composition root
builder.Services.AddSingleton<Func<CartContext, decimal>>(ctx => ctx.Subtotal * 0.9m);

Đánh đổi thật: Func<> không có tên, không phát hiện được qua reflection, không inject service vào tự nó được, và không unit test được mà không khởi tạo closure. Nâng lên interface khoảnh khắc bạn muốn bất kỳ cái nào trong số đó.

Khi nào interface inject xứng đáng?

Với interface khi ít nhất một trong các điều này đúng:

Strategy kết hợp Factory Method để pick lúc runtime thế nào?

Các strategy phía trên đều sống dạng đăng ký DI; caller chọn một theo key:

public enum DiscountKind { PercentOff, BuyOneGetOne, FreeShipping }

builder.Services.AddKeyedSingleton<IDiscountStrategy, PercentOff>(DiscountKind.PercentOff,
    (_, _) => new PercentOff(0.10m));
builder.Services.AddKeyedSingleton<IDiscountStrategy, FreeShipping>(DiscountKind.FreeShipping,
    (_, _) => new FreeShipping());
builder.Services.AddKeyedSingleton<IDiscountStrategy, BuyOneGetOne>(DiscountKind.BuyOneGetOne,
    (_, _) => new BuyOneGetOne("BOOK", 9.99m));

// Caller
public sealed class Checkout
{
    private readonly IKeyedServiceProvider _sp;
    public Checkout(IKeyedServiceProvider sp) => _sp = sp;

    public decimal ComputeTotal(DiscountKind kind, CartContext ctx)
    {
        var strategy = _sp.GetRequiredKeyedService<IDiscountStrategy>(kind);
        return strategy.Apply(ctx);
    }
}

Đây đúng là Factory Method pattern — keyed service chọn Strategy đúng lúc runtime. Hai pattern compose tự nhiên; nhận ra cả hai cho phép bạn đọc call AddKeyedSingleton<> đúng.

Khi nào Strategy pattern bắn trật?

Ba bẫy:

So sánh Strategy với State và Command thế nào?

Pattern Đại diện cho Ai chọn
Strategy Một thuật toán giữa các peer Caller / DI
State Cả hành vi ở giai đoạn lifecycle State machine của object
Command Một hành động với input Caller, gửi tới handler

Tách rõ: Strategy là thế nào (thuật toán); State là là gì (lifecycle); Command là làm gì một lần (hành động với input). Strategy nhẹ nhất ba cái; nâng lên cái khác khi hình dạng thôi vừa.

Một ví dụ thật trong .NET 10 trông thế nào?

Hình dạng đầy đủ — strategy keyed cho discount, dùng bởi calculator checkout, cộng Func<> cho discount shipping tầm thường không cần class riêng:

public sealed record CartContext(string[] Skus, decimal Subtotal, string Region);

public interface IDiscountStrategy
{
    decimal Apply(CartContext ctx);
}

public sealed class PercentOff : IDiscountStrategy
{
    private readonly decimal _percent;
    public PercentOff(decimal percent) => _percent = percent;
    public decimal Apply(CartContext ctx) => ctx.Subtotal * (1m - _percent);
}

public sealed class TieredDiscount : IDiscountStrategy
{
    private readonly IClock _clock;            // dependency: delegate không inject sạch được
    public TieredDiscount(IClock clock) => _clock = clock;
    public decimal Apply(CartContext ctx)
    {
        var dayBoost = _clock.UtcNow.DayOfWeek == DayOfWeek.Monday ? 0.05m : 0m;
        var baseRate = ctx.Subtotal switch
        {
            < 50m  => 0.00m,
            < 200m => 0.05m,
            _      => 0.10m,
        };
        return ctx.Subtotal * (1m - baseRate - dayBoost);
    }
}

// Composition root
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddKeyedSingleton<IDiscountStrategy, PercentOff>("flat10",
    (_, _) => new PercentOff(0.10m));
builder.Services.AddKeyedSingleton<IDiscountStrategy, TieredDiscount>("tiered");

// Calculator shipping tầm thường - Func là đủ
builder.Services.AddSingleton<Func<decimal, decimal>>(subtotal =>
    subtotal > 100m ? 0m : 7.99m);

// Caller
public sealed class Checkout
{
    private readonly IKeyedServiceProvider _sp;
    private readonly Func<decimal, decimal> _shipping;
    public Checkout(IKeyedServiceProvider sp, Func<decimal, decimal> shipping)
        => (_sp, _shipping) = (sp, shipping);

    public decimal Total(string discountKey, CartContext ctx)
    {
        var discounted = _sp.GetRequiredKeyedService<IDiscountStrategy>(discountKey).Apply(ctx);
        return discounted + _shipping(discounted);
    }
}

// Test
[Fact]
public void Tiered_discount_applies_5pct_in_50_to_200_range()
{
    var clock = new Mock<IClock>();
    clock.SetupGet(c => c.UtcNow).Returns(new DateTime(2026, 5, 5, 0, 0, 0, DateTimeKind.Utc)); // thứ Ba
    var sut = new TieredDiscount(clock.Object);
    var result = sut.Apply(new CartContext(Array.Empty<string>(), 150m, "US"));
    Assert.Equal(142.5m, result);   // 150 * (1 - 0.05)
}

Cái bạn đọc: mỗi strategy nhỏ, có dependency riêng hoặc không, và test được riêng. Caller không bao giờ import PercentOff. Thêm strategy mới là một class mới cộng một dòng DI.

Đọc tiếp gì trong series?

Một quan sát thực dụng: Strategy có lẽ là pattern behavioral dùng nhiều nhất trong .NET hiện đại, và gần như không ai gọi nó bằng tên. Mọi IComparer<T> bạn truyền vào OrderBy, mọi Func<> bạn inject, mọi keyed service đều là pattern. Một khi thấy nó khắp nơi, câu hỏi thành "cái này nên là Func<> hay interface?" — và đó là quyết định thiết kế duy nhất còn lại.

Câu hỏi thường gặp

Khi nào Func delegate là đủ so với interface IStrategy đầy đủ?
Func<> đủ khi strategy là một method, không có dependency, và không xứng unit test riêng. Interface xứng đáng khi strategy có nhiều method, giữ state, phụ thuộc service inject, hoặc cần phát hiện được khắp codebase. Với Func<> trước; nâng lên interface khi muốn inject ILogger vào delegate.
Strategy khác State thế nào?
Cùng hình, ý đồ khác. State đổi cả hành vi object dựa trên giai đoạn lifecycle; transition là một phần abstraction. Strategy đổi một thuật toán trong các peer thay thế được; caller giữ nguyên. State là state machine; Strategy là slot cắm.
Nên cài Strategy hay dùng switch expression?
Dùng switch expression khi thuật toán nhỏ, đóng (bạn kiểm soát mọi biến thể), và không cần dependency riêng. Nâng lên Strategy khi bất kỳ cái nào vỡ: thuật toán bên thứ ba tham gia, biến thể cần IDateTimeProvider inject, hoặc bạn muốn mỗi biến thể test riêng. switch ổn; Strategy mua testability.
Strategy kết hợp Factory Method thế nào?
Factory Method chọn Strategy nào khởi tạo lúc runtime; Strategy sau đó chạy thuật toán đã chọn. Hai cái compose sạch: caller xin factory IDiscountStrategy keyed theo region, rồi chạy .Apply(cart) trên instance trả về. Trong .NET 8+ đây đúng là IKeyedServiceProvider.GetKeyedService<IDiscountStrategy>(region).