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
- Command pattern giải quyết bài toán gì trong C#?
- Cấu trúc Command giáo khoa trông thế nào?
- MediatR cài Command pattern thế nào?
- Cài undo / redo với Command pattern thế nào?
- Khi nào Command pattern bắn trật?
- So sánh Command với Strategy và Mediator thế nào?
- Một ví dụ thật trong .NET 10 trông thế nào?
- Đọ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ể:
- 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.
- Audit và replay. Mọi state change là Command persist xuống event log. Replay rebuild state hệ thống.
- 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:
- Nghi lễ một-Command-mỗi-method. Mỗi method thành record + handler + DTO chỉ forward argument. Pattern giờ thuần overhead. Dùng Command khi cái khác (queue, log, undo stack) consume Command. Không có thì method call thường thắng.
- Trộn Command và Query. Command đổi state và thường trả data
tối thiểu ("done"). Query không đổi state và thường trả data
phong phú. Trộn —
SubmitOrdertrả nguyên DTO order — vi phạm CQRS lặng lẽ. Giữ contract riêng. - Undo không thật sự undo.
DeleteOrderCommand.Undo()không thể mang lại row mà transaction khác đã xoá. Coi undo là best-effort có guarantee tường minh, hoặc lưu đủ snapshot data để dựng lại.
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?
- Bài trước: Chain of Responsibility — hình dạng pipeline mà behavior MediatR dùng.
- Bài kế: Interpreter — khi bản thân Command thành ngôn ngữ nhỏ để evaluate.
- Tham chiếu chéo: Mediator — hub dispatch Command; MediatR là cả hai pattern kết hợp.
- Tham chiếu chéo: Memento — cho undo cross-process, capture state với Memento và đảo với Command.
- Cây quyết định: Cách chọn design pattern phù hợp.
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?
MediatR có thật là Command pattern khô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?
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.