Cấu trúc Trung bình 8 phút đọc

Decorator Pattern trong C#: Wrapper Log, Retry, Cache

Decorator pattern trong C# / .NET 10: bọc service với log, retry, cache mà không sửa nó, và cách Scrutor Decorate tự động hóa registration.

Mục lục
  1. Decorator pattern giải quyết bài toán gì trong C#?
  2. Decorator class cơ bản trong .NET 10 trông thế nào?
  3. Compose Decorator với DI trong .NET hiện đại thế nào?
  4. Khi nào Decorator viết tay xứng đáng?
  5. So sánh Decorator với Adapter và Proxy thế nào?
  6. Một ví dụ thật trong .NET 10 trông thế nào?
  7. Đọc tiếp gì trong series?

IPaymentProcessor từ chương Factory Method tính tiền thẻ. Sáu tháng sau, team cần bốn cross-cutting concern quanh một call:

Bản năng sai là thêm cả bốn vào StripeProcessor. Đột nhiên class đang gọi SDK cũng là class đang log, retry, idempotency, và phát hiện fraud. Class provider kế (PaypalProcessor) bị y chang, copy paste. Sáu tháng sau, fix bug retry là ghé mọi implementation.

Decorator pattern là câu trả lời. Mỗi cross-cutting concern sống trong class nhỏ riêng bọc IPaymentProcessor và bản thân implement IPaymentProcessor. Concern xếp chồng tại registration time. Mỗi cái test độc lập. Gỡ hoặc đổi thứ tự concern là một dòng trong Program.cs.

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

Pattern xứng chỗ khi bạn muốn thêm hành vi cross-cutting cho interface mà không sửa implementation concrete nào. Ba hình dạng cụ thể:

  1. Bọc kiểu pipeline. Log, retry, idempotency, metric, cache, validate. Mỗi cái một concern; cùng nhau tạo chuỗi.
  2. Tính năng optional. Cache nên bật ở production nhưng tắt ở test; log verbose toggle theo môi trường.
  3. Concern không phụ thuộc provider. Retry nên hoạt động giống nhau cho StripeProcessorPaypalProcessor. Viết một lần dạng decorator tránh hai bản copy.

Cái không phải bài toán Decorator: "Tôi muốn bọc class có interface sai" — đó là Adapter. "Tôi muốn fan out sang nhiều object" — đó là Composite. Decorator đặc biệt nói về bọc một-một thêm hành vi.

Decorator class cơ bản trong .NET 10 trông thế nào?

Logging decorator là ví dụ đơn giản nhất — cùng interface, gọi xuyên qua, thêm việc trước/sau:

public sealed class LoggingPaymentProcessor : IPaymentProcessor
{
    private readonly IPaymentProcessor _inner;
    private readonly ILogger<LoggingPaymentProcessor> _log;

    public LoggingPaymentProcessor(IPaymentProcessor inner,
        ILogger<LoggingPaymentProcessor> log)
        => (_inner, _log) = (inner, log);

    public async Task<PaymentResult> PayAsync(decimal amount, string customerId,
        string idemKey, CancellationToken ct)
    {
        _log.LogInformation("Charging {Amount} for {Customer} (key {Key})",
            amount, customerId, idemKey);
        var sw = System.Diagnostics.Stopwatch.StartNew();
        try
        {
            var result = await _inner.PayAsync(amount, customerId, idemKey, ct);
            _log.LogInformation("Charge {Status} in {Ms}ms (tx {Tx})",
                result.Status, sw.ElapsedMilliseconds, result.TransactionId);
            return result;
        }
        catch (Exception ex)
        {
            _log.LogError(ex, "Charge failed for {Customer}", customerId);
            throw;
        }
    }
}

Các decorator khác theo cùng hình dạng. Retry decorator:

public sealed class RetryingPaymentProcessor : IPaymentProcessor
{
    private readonly IPaymentProcessor _inner;
    private readonly int _maxAttempts;
    public RetryingPaymentProcessor(IPaymentProcessor inner, int maxAttempts = 3)
        => (_inner, _maxAttempts) = (inner, maxAttempts);

    public async Task<PaymentResult> PayAsync(decimal amount, string customerId,
        string idemKey, CancellationToken ct)
    {
        for (var attempt = 1; ; attempt++)
        {
            try { return await _inner.PayAsync(amount, customerId, idemKey, ct); }
            catch (TransientException) when (attempt < _maxAttempts)
            {
                await Task.Delay(TimeSpan.FromMilliseconds(200 * Math.Pow(2, attempt - 1)), ct);
            }
        }
    }
}

Mỗi decorator cũng là IPaymentProcessor, nên có thể bị bọc tiếp. Bức tranh cấu trúc:

classDiagram
    class IPaymentProcessor {
        <<interface>>
        +PayAsync(amount, customer, key) PaymentResult
    }
    class StripeProcessor
    class LoggingPaymentProcessor
    class RetryingPaymentProcessor
    class FraudCheckPaymentProcessor

    IPaymentProcessor <|.. StripeProcessor
    IPaymentProcessor <|.. LoggingPaymentProcessor
    IPaymentProcessor <|.. RetryingPaymentProcessor
    IPaymentProcessor <|.. FraudCheckPaymentProcessor

    LoggingPaymentProcessor o-- IPaymentProcessor : bọc
    RetryingPaymentProcessor o-- IPaymentProcessor : bọc
    FraudCheckPaymentProcessor o-- IPaymentProcessor : bọc

Để ý hình không nói cái nào mỗi decorator bọc — quyết định tại registration time.

Compose Decorator với DI trong .NET hiện đại thế nào?

Đăng ký tay dài dòng:

builder.Services.AddSingleton<StripeProcessor>();
builder.Services.AddSingleton<IPaymentProcessor>(sp =>
{
    IPaymentProcessor inner = sp.GetRequiredService<StripeProcessor>();
    inner = new LoggingPaymentProcessor(inner, sp.GetRequiredService<ILogger<LoggingPaymentProcessor>>());
    inner = new RetryingPaymentProcessor(inner);
    inner = new FraudCheckPaymentProcessor(inner, sp.GetRequiredService<IFraudService>());
    return inner;
});

Đúng nhưng tẻ. Library chuẩn cộng đồng Scrutor thêm services.Decorate<TInterface, TDecorator>() để cùng chuỗi đó dễ đọc:

builder.Services.AddSingleton<IPaymentProcessor, StripeProcessor>();
builder.Services.Decorate<IPaymentProcessor, FraudCheckPaymentProcessor>();
builder.Services.Decorate<IPaymentProcessor, RetryingPaymentProcessor>();
builder.Services.Decorate<IPaymentProcessor, LoggingPaymentProcessor>();

Đọc từ trên xuống, decorator ngoài cùng là cái đăng ký cuối. Vậy một call charge đi: Logging → Retrying → FraudCheck → StripeProcessor. Thứ tự .Decorate là từ trong ra ngoài.

Cho project không muốn dependency Scrutor, registration tay phía trên là cùng thứ. Cả hai biên dịch ra cùng chuỗi.

Khi nào Decorator viết tay xứng đáng?

AddSingleton cộng Decorate che hầu hết case. Vẫn có ba lý do viết wrapper tường minh thay vì với framework AOP:

Phân vân, viết decorator. Nó mười lăm dòng, dễ hiểu, dễ xoá khi không cần.

So sánh Decorator với Adapter và Proxy thế nào?

Pattern Cùng interface với cái bị bọc? Thêm hành vi? Kiểm soát truy cập?
Decorator Có (log, retry, v.v.) Không
Adapter Không (đổi interface) Không Không
Proxy Không (hoặc forward trong suốt) Có (lazy load, security, remote)

Câu rõ ràng nhất: Decorator thêm hành vi, Adapter đổi interface, Proxy kiểm soát truy cập. Ba cái trông giống hệt ở mức class definition — đều giữ reference cùng interface chúng implement — và khác biệt duy nhất là method làm gì. Ý đồ là yếu tố phân biệt.

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

Hình dạng đầy đủ ship được codebase thật. Để ý thứ tự Decorate: ngoài vào trong là log → retry → fraud check → call Stripe thật.

public interface IPaymentProcessor
{
    Task<PaymentResult> PayAsync(decimal amount, string customerId, string idemKey, CancellationToken ct);
}

public sealed class StripeProcessor : IPaymentProcessor { /* SDK call */ }

// Decorator 1: log structured
public sealed class LoggingPaymentProcessor : IPaymentProcessor
{
    private readonly IPaymentProcessor _inner;
    private readonly ILogger<LoggingPaymentProcessor> _log;
    public LoggingPaymentProcessor(IPaymentProcessor inner, ILogger<LoggingPaymentProcessor> log)
        => (_inner, _log) = (inner, log);

    public async Task<PaymentResult> PayAsync(decimal a, string c, string k, CancellationToken ct)
    {
        using var scope = _log.BeginScope(new Dictionary<string, object>
            { ["customer"] = c, ["idem"] = k });
        _log.LogInformation("Charge start: {Amount}", a);
        var sw = System.Diagnostics.Stopwatch.StartNew();
        try
        {
            var result = await _inner.PayAsync(a, c, k, ct);
            _log.LogInformation("Charge {Status} in {Ms}ms", result.Status, sw.ElapsedMilliseconds);
            return result;
        }
        catch (Exception ex) { _log.LogError(ex, "Charge failed"); throw; }
    }
}

// Decorator 2: retry khi lỗi transient
public sealed class RetryingPaymentProcessor : IPaymentProcessor
{
    private readonly IPaymentProcessor _inner;
    public RetryingPaymentProcessor(IPaymentProcessor inner) => _inner = inner;

    public async Task<PaymentResult> PayAsync(decimal a, string c, string k, CancellationToken ct)
    {
        for (var attempt = 1; ; attempt++)
        {
            try { return await _inner.PayAsync(a, c, k, ct); }
            catch (TransientException) when (attempt < 3)
            {
                await Task.Delay(TimeSpan.FromMilliseconds(200 * Math.Pow(2, attempt - 1)), ct);
            }
        }
    }
}

// Decorator 3: fraud check trước khi tính tiền
public sealed class FraudCheckPaymentProcessor : IPaymentProcessor
{
    private readonly IPaymentProcessor _inner;
    private readonly IFraudService _fraud;
    public FraudCheckPaymentProcessor(IPaymentProcessor inner, IFraudService fraud)
        => (_inner, _fraud) = (inner, fraud);

    public async Task<PaymentResult> PayAsync(decimal a, string c, string k, CancellationToken ct)
    {
        if (await _fraud.IsSuspiciousAsync(c, a, ct))
            return new PaymentResult("blocked", null, "Flagged for fraud review");
        return await _inner.PayAsync(a, c, k, ct);
    }
}

// Composition root (với Scrutor)
builder.Services.AddSingleton<IFraudService, FraudService>();
builder.Services.AddSingleton<IPaymentProcessor, StripeProcessor>();
builder.Services.Decorate<IPaymentProcessor, FraudCheckPaymentProcessor>();
builder.Services.Decorate<IPaymentProcessor, RetryingPaymentProcessor>();
builder.Services.Decorate<IPaymentProcessor, LoggingPaymentProcessor>();

// Caller inject IPaymentProcessor — không biết về việc bọc
public sealed class CheckoutController
{
    private readonly IPaymentProcessor _payment;
    public CheckoutController(IPaymentProcessor payment) => _payment = payment;
    // ...
}

// Test: bypass mọi decorator, chỉ test StripeProcessor
[Fact]
public async Task Stripe_charges_on_happy_path()
{
    var sut = new StripeProcessor(/* fake */);
    var result = await sut.PayAsync(9.99m, "cust_1", "key_1", default);
    Assert.Equal("succeeded", result.Status);
}

// Test: confirm decorator fraud chặn charge đáng ngờ
[Fact]
public async Task Fraud_decorator_blocks_suspicious_charge()
{
    var fraud = new Mock<IFraudService>();
    fraud.Setup(f => f.IsSuspiciousAsync(It.IsAny<string>(), It.IsAny<decimal>(), default))
         .ReturnsAsync(true);
    var sut = new FraudCheckPaymentProcessor(new StripeProcessor(/* ... */), fraud.Object);
    var result = await sut.PayAsync(50_000m, "new_customer", "k", default);
    Assert.Equal("blocked", result.Status);
}

Cái không xuất hiện: class concrete với nhiều cross-cutting concern trộn vào. StripeProcessor làm một việc: gọi Stripe. Mỗi decorator làm một việc. Chuỗi cấu hình một lần trong Program.cs và không sửa nữa trừ khi policy đổi.

Đọc tiếp gì trong series?

Một ghi chú thực dụng về tooling: ưu tiên cross-cutting concern mức call như Decorator hơn là concern global như middleware khi bạn muốn áp cho một service thay vì mọi HTTP request. Middleware xuất sắc ở biên request; Decorator pattern là cái đưa bạn từ đó vào sâu trong dependency graph.

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

Decorator khác middleware ở điểm gì?
Middleware là Decorator ở quy mô HTTP pipeline. app.Use(...) của ASP.NET Core dựng chuỗi wrapper quanh request handler — đúng pattern Decorator, chỉ với hình dạng RequestDelegate cố định. Decorator pattern ở mức class tổng quát hoá ý tưởng cho mọi interface: IPaymentProcessor, IOrderRepository, bất cứ thứ gì bạn muốn bọc.
Scrutor's Decorate tự động hoá Decorator registration thế nào?
Scrutor là package NuGet nhỏ thêm services.Decorate<TInterface, TDecorator>() vào IServiceCollection. Sau hậu trường nó thay registration có sẵn bằng decorator và inject implementation gốc vào constructor decorator. Kết quả: compose bốn decorator quanh một service là bốn dòng thay vì bốn mươi.
Nên viết Decorator hay dùng base class polymorphic?
Dùng Decorator khi hành vi cross-cutting là optional hoặc composable — bạn muốn log ở một số registration và retry ở một số khác, có thể cùng nhau. Dùng base class khi hành vi chia sẻ qua mọi implementation và không bao giờ compose. Decorator để mọi implementation đơn giản; thừa kế ép mọi implementation thừa kế concern dù muốn hay không.
Decorator khác Adapter và Proxy thế nào?
Cả ba bọc một object sau class. Adapter đổi interface; Decorator giữ interface và thêm hành vi; Proxy giữ interface và kiểm soát truy cập. Adapter để khớp type lạ; Decorator để thêm cross-cutting concern; Proxy cho lazy loading, security, hoặc remote call.