Hành vi Trung bình 7 phút đọc

Command Pattern trong C#: Record, Handler, và Undo

Command pattern trong C# / .NET 10: đóng gói request thành record, dispatch tới handler, và cách MediatR là pattern này ship dạng NuGet.

Mục lục
  1. Command pattern giải quyết bài toán gì trong C#?
  2. Cấu trúc Command giáo khoa trông thế nào?
  3. MediatR cài Command pattern thế nào?
  4. Cài undo / redo với Command pattern thế nào?
  5. Khi nào Command pattern bắn trật?
  6. So sánh Command với Strategy và Mediator thế nào?
  7. Một ví dụ thật trong .NET 10 trông thế nào?
  8. Đọc tiếp gì trong series?

Shop thêm tính năng "save for later": nếu checkout fail vì inventory, cart phải được queue và retry sau lần restock kế. Rồi "preview an order" — cùng cart, tính total, nhưng không charge thật. Rồi "replay 10 hành động cuối cho support debug". Ba tính năng, một quan sát: mỗi hành động user muốn tồn tại như data — để queue, mô phỏng, replay, hay audit — không chỉ thực thi rồi quên.

Command pattern là câu trả lời. Biến mỗi operation thành object: một record giữ input, một handler thực thi. Operation thành hạng nhất: bạn truyền được, lưu được, log được, retry được, undo được. Trong C# hiện đại đây là hình dạng mọi codebase CQRS dùng; library MediatR tồn tại gần như chỉ để pattern này dễ chịu.

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

Pattern xứng chỗ khi hành động "gọi method" phải được nâng lên thành thứ chương trình thao tác được. Ba hình dạng cụ thể:

  1. Queue hoặc schedule. "Charge thẻ này lại sau 24 giờ nếu bank trở lại." Hành động sống lâu hơn request gốc.
  2. Audit và replay. Mọi state change là Command persist xuống event log. Replay rebuild state hệ thống.
  3. Undo / preview. Tính cái sẽ xảy ra mà không commit; hoặc commit và nhớ đủ để đảo.

Cái không phải bài toán Command: "Tôi muốn đổi thuật toán" — đó là Strategy. "Tôi muốn gửi notification cho nhiều subscriber" — đó là Observer. Command đặc biệt nói về một operation thành data.

Cấu trúc Command giáo khoa trông thế nào?

Một Command type giữ input, một Handler có method ExecuteAsync, và (optional) một Dispatcher route Command tới Handler:

public interface ICommand<TResult> { }

public interface ICommandHandler<in TCmd, TResult>
    where TCmd : ICommand<TResult>
{
    Task<TResult> HandleAsync(TCmd cmd, CancellationToken ct);
}

public sealed record AddToCart(string CartId, string Sku, int Qty)
    : ICommand<AddToCartResult>;

public sealed record AddToCartResult(int NewItemCount, decimal NewTotal);

public sealed class AddToCartHandler : ICommandHandler<AddToCart, AddToCartResult>
{
    private readonly ICartRepository _carts;
    public AddToCartHandler(ICartRepository carts) => _carts = carts;

    public async Task<AddToCartResult> HandleAsync(AddToCart cmd, CancellationToken ct)
    {
        var cart = await _carts.GetAsync(cmd.CartId, ct) ?? throw new NotFoundException();
        cart.Add(cmd.Sku, cmd.Qty);
        await _carts.SaveAsync(cart, ct);
        return new AddToCartResult(cart.Items.Sum(i => i.Quantity), cart.Total);
    }
}

Bức tranh cấu trúc:

flowchart LR
    Caller -->|new AddToCart(...)| Cmd[AddToCart record]
    Cmd --> Disp[Dispatcher]
    Disp -->|tìm handler| Handler[AddToCartHandler]
    Handler --> Repo[ICartRepository]
    Handler --> Result[AddToCartResult]

Bản thân Command là chỉ data. Handler là executor. Tách hai làm queue, replay, audit khả thi.

MediatR cài Command pattern thế nào?

MediatR là Command pattern với dispatcher nhỏ và pipeline. Hình dạng:

using MediatR;

public sealed record AddToCart(string CartId, string Sku, int Qty)
    : IRequest<AddToCartResult>;

public sealed class AddToCartHandler : IRequestHandler<AddToCart, AddToCartResult>
{
    private readonly ICartRepository _carts;
    public AddToCartHandler(ICartRepository carts) => _carts = carts;

    public async Task<AddToCartResult> Handle(AddToCart cmd, CancellationToken ct)
    {
        var cart = await _carts.GetAsync(cmd.CartId, ct) ?? throw new NotFoundException();
        cart.Add(cmd.Sku, cmd.Qty);
        await _carts.SaveAsync(cart, ct);
        return new AddToCartResult(cart.Items.Sum(i => i.Quantity), cart.Total);
    }
}

// Composition root
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());

// Caller
[ApiController, Route("cart")]
public sealed class CartController : ControllerBase
{
    private readonly IMediator _mediator;
    public CartController(IMediator m) => _mediator = m;

    [HttpPost("add")]
    public Task<AddToCartResult> Add(AddToCart cmd, CancellationToken ct)
        => _mediator.Send(cmd, ct);
}

IRequest<TResult> là Command. IRequestHandler<TRequest, TResult> là executor. IMediator.Send là dispatcher. MediatR thêm pipeline (IPipelineBehavior<>) để bạn cắm hành vi Chain of Responsibility như validate và log một lần cho mọi command. Nhận diện pattern cơ bản giúp bạn không học MediatR như hộp thần kỳ.

Cài undo / redo với Command pattern thế nào?

Hai thành phần: Command đã chạy phải mang đủ data để đảo, và bạn giữ stack các command đã thực thi.

public interface IReversibleCommand
{
    Task ExecuteAsync(CancellationToken ct);
    Task UndoAsync(CancellationToken ct);
}

public sealed class AddToCartCommand : IReversibleCommand
{
    private readonly Cart _cart;
    private readonly LineItem _line;

    public AddToCartCommand(Cart cart, LineItem line) => (_cart, _line) = (cart, line);

    public Task ExecuteAsync(CancellationToken ct) { _cart.Add(_line); return Task.CompletedTask; }
    public Task UndoAsync   (CancellationToken ct) { _cart.Remove(_line); return Task.CompletedTask; }
}

public sealed class CommandHistory
{
    private readonly Stack<IReversibleCommand> _stack = new();

    public async Task ExecuteAsync(IReversibleCommand cmd, CancellationToken ct)
    {
        await cmd.ExecuteAsync(ct);
        _stack.Push(cmd);
    }

    public async Task UndoAsync(CancellationToken ct)
    {
        if (_stack.TryPop(out var cmd)) await cmd.UndoAsync(ct);
    }
}

Hình dạng hợp với edit cart và mọi operation rõ ràng đảo được. Không hợp với "send email" hay "delete file" — thế giới thực không cho lấy lại. Dùng undo cho thứ application sở hữu.

Cho undo qua process (editor document multi-user), cùng pattern kết hợp với Memento — Memento bắt snapshot state, Command biết cách áp hoặc đảo nó.

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

Ba bẫy:

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

Pattern Object là gì? Lifetime Code caller điển hình
Command Một hành động dự định với input Ngắn (một lần thực thi) mediator.Send(new AddToCart(...))
Strategy Một thuật toán dùng nhiều lần Dài (inject một lần) _strategy.Apply(state)
Mediator Một hub route Command/message Dài (service) mediator.Send(...) (dispatcher chính nó)

Câu rõ ràng nhất: Command là làm gì; Strategy là làm thế nào; Mediator là ai routing. Một app CQRS điển hình dùng cả ba: Mediator dispatch Command, mỗi handler có thể dùng Strategy nội bộ để tính một phần kết quả.

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

Setup MediatR tối thiểu với pipeline validation (Chain of Responsibility) trên đầu, cộng CommandHistory thủ công cho undo in-memory:

public sealed record AddToCart(string CartId, string Sku, int Qty)
    : IRequest<AddToCartResult>;
public sealed record RemoveFromCart(string CartId, string Sku)
    : IRequest<RemoveFromCartResult>;

public sealed record AddToCartResult(int NewItemCount, decimal NewTotal);
public sealed record RemoveFromCartResult(int NewItemCount);

public sealed class AddToCartHandler : IRequestHandler<AddToCart, AddToCartResult>
{
    private readonly ICartRepository _carts;
    public AddToCartHandler(ICartRepository carts) => _carts = carts;
    public async Task<AddToCartResult> Handle(AddToCart cmd, CancellationToken ct)
    {
        var cart = await _carts.GetAsync(cmd.CartId, ct) ?? throw new NotFoundException();
        cart.Add(cmd.Sku, cmd.Qty);
        await _carts.SaveAsync(cart, ct);
        return new AddToCartResult(cart.ItemCount, cart.Total);
    }
}

// Pipeline: behavior validate chạy trước mọi command
public sealed class ValidationBehavior<TReq, TRes> : IPipelineBehavior<TReq, TRes>
    where TReq : IRequest<TRes>
{
    private readonly IEnumerable<IValidator<TReq>> _validators;
    public ValidationBehavior(IEnumerable<IValidator<TReq>> v) => _validators = v;

    public async Task<TRes> Handle(TReq req, RequestHandlerDelegate<TRes> next, CancellationToken ct)
    {
        foreach (var v in _validators)
        {
            var result = await v.ValidateAsync(req, ct);
            if (!result.IsValid)
                throw new ValidationException(result.Errors);
        }
        return await next();
    }
}

// Composition root
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

// Caller
[ApiController, Route("cart")]
public sealed class CartController : ControllerBase
{
    private readonly IMediator _mediator;
    public CartController(IMediator m) => _mediator = m;

    [HttpPost("add")]
    public Task<AddToCartResult> Add(AddToCart cmd, CancellationToken ct) => _mediator.Send(cmd, ct);

    [HttpPost("remove")]
    public Task<RemoveFromCartResult> Remove(RemoveFromCart cmd, CancellationToken ct) => _mediator.Send(cmd, ct);
}

// Test
[Fact]
public async Task AddToCart_increments_count()
{
    var carts = new InMemoryCartRepository();
    var sut = new AddToCartHandler(carts);
    var result = await sut.Handle(new AddToCart("c1", "BOOK", 1), default);
    Assert.Equal(1, result.NewItemCount);
}

Cái bạn đọc trên trang: mỗi hành động user là record. Mỗi handler làm đúng một việc. Pipeline chạy concern cross-cutting một lần, cho mọi command. Thêm tính năng "preview", "queue", hay "audit" nghĩa là consume Command, không phải viết lại business logic.

Đọc tiếp gì trong series?

Một quan sát thực dụng: Command là pattern dùng nhiều nhất trong .NET hiện đại mà không ai gọi tên. Mọi handler CQRS, mọi IRequest MediatR, mọi record bạn Send tới dispatcher — đều là Command. Biết pattern cho phép bạn đọc framework đó nhanh hơn và tự thiết kế dispatcher khi không library nào hợp.

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

Command khác Strategy thế nào?
Strategy và Command đều biến hành vi thành object, nhưng mục đích khác. Strategy là làm một việc thế nào — chọn thuật toán discount. Command là làm gì một lần — áp discount này, vào cart này, ngay bây giờ. Strategy thường sống lâu và dùng lại; Command thường sống ngắn và đại diện một hành động user.
MediatR có thật là Command pattern không?
Đúng. IRequest<TResult> là Command (data của việc cần làm); IRequestHandler<TRequest, TResult> là executor; IMediator.Send(...) là dispatcher. Library thêm pipeline (validate, log) phía trên, nhưng hình dạng cơ bản đúng là Command. CQRS framework thường gọi command là 'Command' và query là 'Query' — cả hai đều là pattern với ngữ nghĩa trả về khác.
Cài undo với Command pattern thế nào?
Mỗi Command mang đủ thông tin để tự đảo, hoặc handler trả về Command nghịch đảo. Sau khi execute, push Command đã chạy (hoặc inverse) lên stack; Undo() pop và chạy ngược. Pattern chỉ sạch khi command tất định và hiệu ứng đảo được — DeleteFile có thể không.
Khi nào Command pattern thành nghi lễ trong C# hiện đại?
Khi mọi operation có một Command, một Handler, và một DTO mang cùng field. Pattern xứng đáng khi cần queue, log, retry, replay, hay undo. Không có những thứ đó, một method bình thường trên application service ít file hơn và dễ theo dõi hơn.