Mediator Pattern trong C#: MediatR Notification và Hub
Mediator pattern trong C# / .NET 10: nhiều peer publish vào hub trung tâm thay vì gọi nhau trực tiếp, với MediatR notification là cài đặt chuẩn de facto.
Mục lục
Khi order được trả tiền, bốn việc phải xảy ra: inventory phải
reserve stock, warehouse phải được báo ship, customer phải nhận
email xác nhận, và analytics module phải ghi doanh thu. Phiên bản
đầu wire bốn cái đó vào checkout service: nó phụ thuộc
IInventoryService, IShippingService, INotificationService,
và IAnalytics. Sáu tháng sau, finance hỏi thêm call thứ năm
(post journal entry), rồi fraud hỏi thứ sáu (re-score customer).
Giờ checkout service có tám inject và team sở hữu nó đánh nhau
merge conflict mỗi release.
Mediator pattern là câu trả lời. Để checkout publish một
notification — OrderPaid — vào hub. Hub route nó tới các handler
quan tâm. Mỗi use case mới là một handler mới trong module riêng;
checkout service không đổi. Trong .NET hiện đại đây là cái
IMediator.Publish từ
MediatR làm, và cùng pattern
có thể tự cuộn khi không muốn dependency.
Mediator pattern giải quyết bài toán gì trong C#?
Pattern xứng chỗ khi nhiều peer object phải phối hợp, nhưng không nên giữ reference trực tiếp nhau. Ba hình dạng cụ thể:
- Domain event. "Order paid" phải trigger inventory, shipping, email, analytics. Publisher không nên biết list subscriber.
- Phản ứng cross-feature. Một feature mới muốn react khi event có sẵn xảy ra, không sửa code feature có sẵn.
- In-process bus. Một monolith với nhiều module muốn lợi ích loose coupling kiểu message bus mà không vận hành broker thật.
Cái không phải bài toán Mediator: "Tôi muốn thêm hành vi quanh một call" — đó là Decorator. "Tôi muốn dispatch một Command tới một handler" — đó là Command (và MediatR làm cả hai cùng lúc).
MediatR cài Mediator notification thế nào?
Một notification record, một hoặc nhiều handler, và một
_mediator.Publish ở source:
using MediatR;
public sealed record OrderPaid(string OrderId, string CustomerId, decimal Total)
: INotification;
public sealed class ReserveInventory : INotificationHandler<OrderPaid>
{
private readonly IInventoryService _inv;
public ReserveInventory(IInventoryService inv) => _inv = inv;
public Task Handle(OrderPaid n, CancellationToken ct)
=> _inv.ReserveForAsync(n.OrderId, ct);
}
public sealed class SendConfirmationEmail : INotificationHandler<OrderPaid>
{
private readonly INotificationService _emails;
public SendConfirmationEmail(INotificationService emails) => _emails = emails;
public Task Handle(OrderPaid n, CancellationToken ct)
=> _emails.SendOrderPaidAsync(n.CustomerId, n.OrderId, ct);
}
// Composition root
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());
// Publisher
public sealed class CheckoutService
{
private readonly IMediator _mediator;
public CheckoutService(IMediator m) => _mediator = m;
public async Task PaidAsync(Order order, CancellationToken ct)
{
// ... persist order, mark paid ...
await _mediator.Publish(new OrderPaid(order.Id, order.CustomerId, order.Total), ct);
}
}
Thêm phản ứng thứ sáu là một class handler mới cộng đăng ký DI mà
assembly scan của MediatR tự lấy. CheckoutService tiếp tục chỉ
phụ thuộc IMediator.
Bức tranh cấu trúc:
flowchart LR
Pub[CheckoutService<br>publish OrderPaid]
Pub --> Hub[hub IMediator]
Hub --> H1[ReserveInventory]
Hub --> H2[SendConfirmationEmail]
Hub --> H3[BookShipping]
Hub --> H4[RecordAnalytics]
Hub biết về handler; publisher chỉ biết hub. Handler mới slot vào hub; publisher không bị động.
Mediator khác Observer thế nào?
| Khía cạnh | Mediator | Observer |
|---|---|---|
| Class hub | Bắt buộc (component trung tâm) | Không bắt buộc |
| Hướng | Nhiều publisher tới nhiều subscriber qua hub | Một publisher tới nhiều subscriber thẳng |
| Phát hiện subscriber | Hub dùng DI / type matching | Subscriber attach qua event += hoặc Subscribe |
| .NET điển hình | IMediator.Publish của MediatR |
C# event, IObservable<T>, Channel<T> |
Tách rõ: Observer là list subscriber riêng của publisher; Mediator kéo list đó ra hub mà mọi người dùng. Khi publisher và subscriber gắn chặt, ưu tiên Observer (event). Khi publisher không nên biết subscriber có tồn tại, ưu tiên Mediator.
Khi nào Mediator pattern bắn trật?
Ba bẫy:
- Gián tiếp không lợi. Ba class, một notification, không kế hoạch mọc. Method call thẳng đơn giản hơn. Mediator xứng đáng ở scale.
- Publish chu trình. Handler A publish notification mà Handler B xử, B publish cái khác mà A xử. Hub không phát hiện chu trình; log debug lừa vì mỗi publish trông vô tội. Tài liệu hoá đồ thị publication; coi chu trình là bug.
- Phụ thuộc thứ tự ngầm. Một subscriber giả định subscriber khác đã chạy. Hub không bảo đảm thứ tự nào. Làm handler độc lập; nếu một cái phải chạy trước, đừng dùng notification — dùng Command với thứ tự tường minh.
MediatR xử exception trong notification thế nào?
Default, MediatR dùng ForeachAwaitPublisher: await handler theo
thứ tự đăng ký; nếu một cái throw, phần còn lại không chạy. Đổi
policy:
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.NotificationPublisherType = typeof(TaskWhenAllPublisher);
});
TaskWhenAllPublisher chạy handler song song và aggregate
exception. Cho fire-and-forget, viết INotificationPublisher
riêng. Luôn chọn policy tường minh — default hiếm khi là cái
production muốn.
Một ví dụ thật trong .NET 10 trông thế nào?
Kết hợp notification MediatR, pipeline log kiểu Decorator từ Command, và behavior Chain of Responsibility:
public sealed record OrderPaid(string OrderId, string CustomerId, decimal Total)
: INotification;
public sealed class ReserveInventory : INotificationHandler<OrderPaid>
{
private readonly IInventoryService _inv;
public ReserveInventory(IInventoryService inv) => _inv = inv;
public Task Handle(OrderPaid n, CancellationToken ct)
=> _inv.ReserveForAsync(n.OrderId, ct);
}
public sealed class SendConfirmationEmail : INotificationHandler<OrderPaid>
{
private readonly INotificationService _emails;
public SendConfirmationEmail(INotificationService emails) => _emails = emails;
public Task Handle(OrderPaid n, CancellationToken ct)
=> _emails.SendOrderPaidAsync(n.CustomerId, n.OrderId, ct);
}
public sealed class RecordAnalytics : INotificationHandler<OrderPaid>
{
private readonly IAnalytics _analytics;
public RecordAnalytics(IAnalytics a) => _analytics = a;
public Task Handle(OrderPaid n, CancellationToken ct)
=> _analytics.RecordRevenueAsync(n.Total, ct);
}
// Composition root
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.NotificationPublisherType = typeof(TaskWhenAllPublisher);
});
// Publisher
public sealed class CheckoutService
{
private readonly IMediator _mediator;
public CheckoutService(IMediator m) => _mediator = m;
public async Task<CheckoutResult> CheckoutAsync(CheckoutRequest req, CancellationToken ct)
{
// ... domain work, rồi ...
await _mediator.Publish(new OrderPaid(req.CartId, req.CustomerId, req.Total), ct);
return new CheckoutResult(req.CartId, req.Total, "buyer@x.com");
}
}
// Test
[Fact]
public async Task ReserveInventory_handles_OrderPaid()
{
var inv = new Mock<IInventoryService>();
var sut = new ReserveInventory(inv.Object);
await sut.Handle(new OrderPaid("o1", "c1", 9.99m), default);
inv.Verify(i => i.ReserveForAsync("o1", It.IsAny<CancellationToken>()));
}
Publisher không import inventory, email, hay analytics. Mỗi handler là class nhỏ với một dependency. Thêm phản ứng thứ năm (journal entry tài chính) là một file mới — và zero edit checkout service.
Đọc tiếp gì trong series?
- Bài trước: Iterator — traversal đồng nhất của collection.
- Bài kế: Memento — chụp snapshot state cho undo hoặc rollback transactional.
- Tham chiếu chéo: Observer — khi publisher tự sở hữu list subscriber.
- Tham chiếu chéo: Command — đi cặp với Mediator trong MediatR; một cho "send và lấy kết quả", cái kia cho "publish cho ai quan tâm".
- Cây quyết định: Cách chọn design pattern phù hợp.
Một ghi chú thực dụng: Mediator mua decoupling với chi phí khả
năng phát hiện. Một dev junior hỏi "chuyện gì xảy ra khi publish
OrderPaid?" không trả lời được bằng cách đọc publisher; họ cần
biết các assembly handler sống. Bù bằng quy ước đặt tên
(*Handler.cs), architecture-decision record, và test coverage
tốt cho mỗi handler.
Câu hỏi thường gặp
Mediator khác Observer ở điểm gì?
MediatR có thật là Mediator pattern không?
IMediator cộng INotificationHandler<T> subscriber) với Command (IRequest<T> cộng IRequestHandler<T,U>). Tên library là dấu hiệu. Đa số team .NET dùng MediatR đang dùng cả hai pattern; nhận ra cái nào là cái gì làm library dễ đọc hơn.Khi nào Mediator thêm quá nhiều gián tiếp?
Xử lý lỗi khi notification có nhiều handler thế nào?
INotificationPublisher để chuyển sang parallel-no-wait hoặc fire-and-forget nếu handler độc lập. Tài liệu hoá policy gần type notification để người đọc sau biết hành vi nào họ ký.