Skip to main content

16.7 — 6. MediatR — pipeline cho use case

Summary

Giá trị thật của MediatR không phải "tách controller khỏi service" — bạn làm được điều đó bằng một interface thường. Giá trị nằm ở pipeline behavior: viết validation, logging, transaction, retry một lần rồi áp cho mọi use case, và thứ tự chúng chạy là thứ bạn kiểm soát. Cái giá cũng thật: bạn mất khả năng "Go to Definition" — từ sender.Send(command) không nhảy tới handler được, và với người mới đó là rào cản đáng kể. Thêm một yếu tố thực tế: MediatR từ phiên bản 12 chuyển sang giấy phép thương mại cho dùng trong doanh nghiệp, nên nhiều đội đang cân nhắc thay thế — và với một dự án nhỏ, một interface thường cộng vài decorator cho 80% lợi ích mà không có phụ thuộc nào.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Viết command, query và handler.
  • Thiết kế pipeline behavior và sắp đúng thứ tự.
  • Nêu chi phí thật của MediatR.
  • Biết các phương án thay thế.

Nội dung bài học​

16.7.1 — Command, query, handler​

public sealed record CreateLeadCommand(string Name, string Email, decimal Value)
: IRequest<Result<Guid>>;

public sealed class CreateLeadHandler(
ILeadRepository leads, IUnitOfWork uow, TimeProvider clock)
: IRequestHandler<CreateLeadCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(CreateLeadCommand command, CancellationToken ct)
{
if (await leads.EmailExistsAsync(command.Email, ct))
return Result.Conflict<Guid>("Email đã tồn tại");

var lead = Lead.Create(command.Name, new EmailAddress(command.Email),
Money.Vnd(command.Value));

leads.Add(lead);
await uow.SaveChangesAsync(ct);

return Result.Success(lead.Id.Value);
}
}
[HttpPost]
public async Task<IActionResult> Create(CreateLeadCommand command, CancellationToken ct)
{
var result = await _sender.Send(command, ct);

return result.IsSuccess
? CreatedAtRoute("GetLead", new { id = result.Value }, result.Value)
: Conflict(result.Error);
}

Controller trở nên mỏng — nhưng một interface thường cũng làm được điều đó:

public interface ICreateLeadHandler
{
Task<Result<Guid>> HandleAsync(CreateLeadCommand command, CancellationToken ct);
}

Nên nếu lý do duy nhất bạn dùng MediatR là "controller mỏng", bạn đang trả phí cho thứ không cần.

Tách command và query là quy ước đáng giữ: command thay đổi trạng thái, query chỉ đọc. Nhờ đó bạn áp behavior khác nhau — transaction chỉ cho command, cache chỉ cho query.

16.7.2 — Pipeline behavior là giá trị thật​

public sealed class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
if (!validators.Any()) return await next();

var context = new ValidationContext<TRequest>(request);
var results = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, ct)));
var failures = results.SelectMany(r => r.Errors).Where(f => f is not null).ToList();

if (failures.Count > 0) throw new ValidationException(failures);

return await next();
}
}
public sealed class TransactionBehavior<TRequest, TResponse>(CrmDbContext db)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICommand // CHI command
{
public async Task<TResponse> Handle(
TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
if (db.Database.CurrentTransaction is not null) return await next(); // long nhau

await using var tx = await db.Database.BeginTransactionAsync(ct);

var response = await next();

await tx.CommitAsync(ct);
return response;
}
}
public sealed class LoggingBehavior<TRequest, TResponse>(
ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> Handle(
TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
var name = typeof(TRequest).Name;
var start = Stopwatch.GetTimestamp();

try
{
return await next();
}
finally
{
logger.LogInformation("{Request} mat {Ms}ms",
name, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
}
}
}

Đây là thứ interface thường không làm được gọn. Không có behavior, bạn viết try/catch, validation và BeginTransaction trong từng handler — 50 handler là 50 bản sao.

where TRequest : ICommand khiến transaction behavior chỉ áp cho command, không cho query — một transaction cho truy vấn chỉ đọc là chi phí thừa.

Kiểm tra CurrentTransaction is not null xử lý trường hợp handler gọi sender.Send lồng nhau — không có nó, bạn mở transaction lồng và nhận exception.

16.7.3 — Thứ tự behavior quyết định hành vi​

builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(CreateLeadCommand).Assembly);

cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); // 1 — NGOÀI cùng
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); // 2
cfg.AddOpenBehavior(typeof(TransactionBehavior<,>)); // 3
cfg.AddOpenBehavior(typeof(PerformanceBehavior<,>)); // 4 — TRONG cùng
});

Behavior chạy theo thứ tự đăng ký, và bọc nhau như middleware (bài 8.3):

Logging   ─► Validation ─► Transaction ─► Performance ─► Handler
Logging ◄─ Validation ◄─ Transaction ◄─ Performance ◄─ Handler

Thứ tự này có chủ đích, và đảo lại gây hậu quả cụ thể:

Thứ tự saiHậu quả
Validation sau TransactionMở transaction cho request chắc chắn thất bại
Logging sau ValidationKhông log request bị validation từ chối
Transaction ngoài LoggingLog ghi trước khi commit; log có nhưng dữ liệu không có

Hàng cuối là loại lỗi rất khó chẩn đoán: log nói "đã tạo lead thành công" nhưng database không có gì, vì transaction rollback sau khi log đã ghi.

Quy tắc: thứ không cần transaction đặt ngoài; thứ cần biết transaction đã commit đặt ngoài cùng.

16.7.4 — Chi phí thật​

1. Mất khả năng lần theo luồng.

await _sender.Send(command, ct);          // Go to Definition -> ISender.Send

Bạn không nhảy tới handler được. Phải tìm kiếm toàn giải pháp theo tên kiểu command. Với người mới, đó là rào cản đáng kể — và với codebase 200 handler, nó là chi phí hằng ngày.

Giảm bằng quy ước đặt tên nghiêm ngặt: CreateLeadCommand → CreateLeadCommandHandler, cùng thư mục hoặc cùng file (đúng mẫu VSA ở bài 16.5).

2. Lỗi runtime thay vì lỗi biên dịch.

Quên đăng ký handler, hoặc handler trong assembly không được quét, cho:

InvalidOperationException: No service for type
'IRequestHandler`2[CreateLeadCommand, Result`1[Guid]]' has been registered.

Chỉ xuất hiện lúc chạy, khi ai đó gọi endpoint đó. Chặn bằng kiến trúc test:

[Fact]
public void EveryRequest_ShouldHave_Handler()
{
var requests = ApplicationAssembly.GetTypes()
.Where(t => t.GetInterfaces().Any(i => i.IsGenericType
&& i.GetGenericTypeDefinition() == typeof(IRequest<>)));

foreach (var request in requests)
Assert.NotNull(FindHandlerFor(request));
}

3. Ba file cho một endpoint. Command, handler, validator — thay vì một action. Với endpoint CRUD đơn giản, đó là chi phí thuần.

4. Giấy phép. Từ MediatR v12, dùng trong doanh nghiệp cần giấy phép thương mại. Đây là yếu tố thực tế mà nhiều đội phải cân nhắc, và nó đã thúc đẩy các thư viện thay thế.

16.7.5 — Phương án thay thế​

// 1. Interface thường + decorator — không phụ thuộc gì
public interface IHandler<TRequest, TResponse>
{
Task<TResponse> HandleAsync(TRequest request, CancellationToken ct);
}

public sealed class ValidationDecorator<TRequest, TResponse>(
IHandler<TRequest, TResponse> inner, IEnumerable<IValidator<TRequest>> validators)
: IHandler<TRequest, TResponse>
{
public async Task<TResponse> HandleAsync(TRequest request, CancellationToken ct)
{
// ... validate ...
return await inner.HandleAsync(request, ct);
}
}

// Đăng ký bằng Scrutor
services.Scan(s => s.FromAssemblyOf<CreateLeadHandler>()
.AddClasses(c => c.AssignableTo(typeof(IHandler<,>)))
.AsImplementedInterfaces().WithScopedLifetime());

services.Decorate(typeof(IHandler<,>), typeof(ValidationDecorator<,>));

Cách này cho cùng lợi ích behavior mà: giữ được "Go to Definition" (bạn tiêm IHandler<CreateLeadCommand, Result<Guid>> cụ thể), không thêm phụ thuộc, và không có vấn đề giấy phép.

Cái giá: tự viết khoảng 50 dòng hạ tầng, và mỗi decorator phải khai kiểu tường minh hơn.

Các thư viện thay thế cũng tồn tại với API gần giống MediatR và giấy phép mở — kiểm tra hiện trạng trước khi chọn, vì lĩnh vực này đang thay đổi.

Khi nào không cần gì cả:

Tình huốngNên
CRUD đơn giản, ít quy tắcGọi thẳng service
Dưới 20 endpointService thường
Nhiều use case, cần behavior chungMediatR hoặc decorator
Cần transaction và validation nhất quánMediatR hoặc decorator

Với một API 15 endpoint, controller gọi thẳng service là đúng nhất — ít file, dễ đọc, và bạn không mất gì.

16.7.6 — Rà lại code của bạn​

Danh sách rà soát MediatR

  • •Đã trả lời được MediatR giải quyết vấn đề gì trong dự án này.
  • •Command và query được phân biệt rõ, có marker interface riêng.
  • •Transaction behavior chỉ áp cho command.
  • •Thứ tự behavior được chọn có chủ đích, không theo thứ tự ngẫu nhiên.
  • •Không có thứ gì cần biết transaction đã commit nằm bên trong transaction behavior.
  • •Transaction behavior xử lý được trường hợp gọi lồng nhau.
  • •Quy ước đặt tên nghiêm ngặt giữa request và handler.
  • •Có kiến trúc test kiểm tra mọi request đều có handler.
  • •Đã cân nhắc vấn đề giấy phép của MediatR.
  • •Dự án nhỏ không bị ép dùng MediatR khi service thường là đủ.

Bài tập áp dụng​

Bài 1 — Thứ tự behavior sai​

Đăng ký TransactionBehavior trước ValidationBehavior, gửi một command không hợp lệ, và quan sát transaction được mở rồi rollback vô ích.

Tiêu chí hoàn thành: bạn nêu được thứ tự đúng của các behavior thường dùng, và giải thích được nguyên tắc quyết định thứ tự đó.

Gợi ý và lời giải — Bài 1

Gợi ý. Pipeline behavior lồng nhau như búp bê Nga. Cái đăng ký trước nằm ở đâu?

Lời giải — thứ tự sai:

builder.Services.AddMediatR(c =>
{
c.RegisterServicesFromAssembly(typeof(ChotLeadHandler).Assembly);
c.AddOpenBehavior(typeof(TransactionBehavior<,>)); // NGOÀI CÙNG
c.AddOpenBehavior(typeof(ValidationBehavior<,>));
c.AddOpenBehavior(typeof(LoggingBehavior<,>));
});
curl -X POST http://localhost:8080/leads -d '{"name":"","value":-1000}'
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20200]
Beginning transaction with isolation level 'ReadCommitted'.
warn: Crm.Application[0]
Validation thất bại: Name không được rỗng; Value phải lớn hơn 0
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20202]
Rolling back transaction.

Transaction được mở, không làm gì, rồi rollback. Với mỗi request không hợp lệ.

Thứ tự đúng:

builder.Services.AddMediatR(c =>
{
c.RegisterServicesFromAssembly(typeof(ChotLeadHandler).Assembly);

c.AddOpenBehavior(typeof(LoggingBehavior<,>)); // 1. ngoài cùng
c.AddOpenBehavior(typeof(PerformanceBehavior<,>)); // 2.
c.AddOpenBehavior(typeof(AuthorizationBehavior<,>)); // 3.
c.AddOpenBehavior(typeof(ValidationBehavior<,>)); // 4.
c.AddOpenBehavior(typeof(TransactionBehavior<,>)); // 5.
c.AddOpenBehavior(typeof(DomainEventBehavior<,>)); // 6. trong cùng
});
Request
└─ Logging (bắt đầu)
└─ Performance (bắt đầu đếm giờ)
└─ Authorization (kiểm tra quyền)
└─ Validation (kiểm tra đầu vào)
└─ Transaction (BEGIN)
└─ DomainEvent
└─ Handler
└─ DomainEvent (dispatch)
└─ Transaction (COMMIT)
└─ Validation
└─ Authorization
└─ Performance (ghi thời gian)
└─ Logging (kết thúc)

Nguyên tắc quyết định thứ tự — ba quy tắc, theo thứ tự ưu tiên:

Quy tắc 1 — thứ rẻ và hay từ chối nhất đặt ra ngoài.

Validation từ chối 15% request, chi phí 0,1 ms
Transaction chi phí 2 ms, giữ kết nối và khoá

-> Validation ngoài Transaction: 15% request không bao giờ chạm database
-> Transaction ngoài Validation: 15% request mở rồi đóng transaction vô ích

Với 10.000 request mỗi giờ và 15% không hợp lệ, đó là 1.500 transaction vô ích mỗi giờ — mỗi cái chiếm một kết nối từ pool trong vài mili giây.

Quy tắc 2 — thứ cần thấy MỌI thứ đặt ra ngoài cùng.

// Logging phải thấy cả request bị validation từ chối
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
_logger.LogInformation("Bắt đầu {Request}", typeof(TRequest).Name);
try
{
var kq = await next();
_logger.LogInformation("Hoàn tất {Request}", typeof(TRequest).Name);
return kq;
}
catch (ValidationException ex)
{
_logger.LogWarning("{Request} thất bại validation: {Loi}",
typeof(TRequest).Name, ex.Message);
throw;
}
}

Nếu Logging nằm trong Validation, bạn không có log cho những request bị từ chối — và đó thường là những request bạn cần điều tra nhất.

Quy tắc 3 — thứ cần bảo đảm nguyên tử đặt gần handler nhất.

public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
if (request is not ICommand) return await next(); // chỉ command mới cần transaction

var strategy = _db.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
await using var tx = await _db.Database.BeginTransactionAsync(ct);
var kq = await next();
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return kq;
});
}

Transaction càng ngắn càng tốt (bài 12.6), nên mọi thứ làm được ngoài transaction nên làm ngoài.

Chú ý CreateExecutionStrategy — nếu đã bật EnableRetryOnFailure, EF Core bắt buộc transaction do người dùng khởi tạo phải nằm trong execution strategy (bài 14.9).

Bốn cặp thứ tự quan trọng, và hậu quả nếu đảo:

CặpĐúngNếu đảo
Validation / TransactionValidation ngoàiTransaction vô ích cho mọi request lỗi
Authorization / ValidationAuthorization ngoàiRò rỉ thông tin: thông điệp validation tiết lộ cấu trúc dữ liệu cho người không có quyền
Logging / tất cảLogging ngoài cùngKhông có log cho request bị từ chối
DomainEvent / TransactionDomainEvent trongEvent được phát dù transaction rollback

Cặp thứ hai là cặp có yếu tố bảo mật. Nếu Validation chạy trước Authorization:

Người không có quyền gửi request sai định dạng
-> nhận về "Trường TenantId không hợp lệ"
-> biết được có một trường tên TenantId
-> lặp lại để dò cấu trúc dữ liệu

Trả 403 trước khi validate là hành vi đúng.

Cặp thứ tư quyết định tính đúng đắn của dữ liệu:

DomainEvent NGOÀI Transaction:
handler chạy -> event được phát -> email đã gửi
-> SaveChanges thất bại -> rollback
-> khách hàng nhận email về một lead KHÔNG TỒN TẠI

DomainEvent TRONG Transaction:
handler chạy -> event dispatch trong cùng transaction
-> nếu rollback, mọi thứ bị huỷ cùng nhau

Chi tiết về dispatch trước hay sau commit ở bài 16.8.

Kiểm chứng thứ tự bằng test:

[Fact]
public async Task Command_khong_hop_le_khong_duoc_mo_transaction()
{
var interceptor = new DemTransactionInterceptor();
await using var sp = TaoServiceProvider(interceptor);
var mediator = sp.GetRequiredService<IMediator>();

var act = () => mediator.Send(new TaoLeadCommand(Name: "", Value: -1000));

await act.Should().ThrowAsync<ValidationException>();
interceptor.SoTransaction.Should().Be(0, "validation phải chặn trước khi mở transaction");
}
[Fact]
public void Thu_tu_behavior_phai_dung()
{
var behaviors = _sp.GetServices<IPipelineBehavior<TaoLeadCommand, Result>>()
.Select(b => b.GetType().GetGenericTypeDefinition())
.ToList();

behaviors.Should().ContainInOrder(
typeof(LoggingBehavior<,>),
typeof(AuthorizationBehavior<,>),
typeof(ValidationBehavior<,>),
typeof(TransactionBehavior<,>));
}

Test thứ hai đáng có vì thứ tự đăng ký rất dễ bị đổi vô tình — ai đó thêm một behavior mới và đặt nó ở cuối danh sách cho tiện.

Và một lưu ý về AddOpenBehavior: thứ tự đăng ký quyết định thứ tự thực thi, nhưng nếu bạn dùng services.AddTransient(typeof(IPipelineBehavior<,>), ...) trực tiếp, thứ tự cũng theo thứ tự đăng ký trong DI container. Trộn hai cách sẽ cho thứ tự khó đoán — hãy chọn một cách và dùng nhất quán.


Bài 2 — Handler thiếu​

Tạo một command không có handler và gọi endpoint tương ứng. Ghi lại exception và thời điểm nó xảy ra. Thêm kiến trúc test và xác nhận nó bắt được.

Tiêu chí hoàn thành: bạn nêu được vì sao MediatR đánh đổi an toàn kiểu lúc biên dịch lấy sự linh hoạt, và biết ba cách bù lại.

Gợi ý và lời giải — Bài 2

Gợi ý. _mediator.Send(command) — trình biên dịch biết handler nào sẽ chạy không?

Lời giải:

public record HuyLeadCommand(LeadId Id) : IRequest<Result>;
// Quên viết HuyLeadHandler
app.MapPost("/leads/{id}/huy", async (LeadId id, IMediator m, CancellationToken ct) =>
{
var kq = await m.Send(new HuyLeadCommand(id), ct); // BIÊN DỊCH THÀNH CÔNG
return kq.ThanhCong ? Results.NoContent() : Results.BadRequest(kq.Loi);
});
dotnet build
Build succeeded. 0 Warning(s) 0 Error(s)
curl -X POST http://localhost:8080/leads/abc/huy
System.InvalidOperationException: No service for type
'MediatR.IRequestHandler`2[Crm.Application.Leads.HuyLeadCommand,Crm.Domain.Result]'
has been registered.
at MediatR.Mediator.Send[TResponse](IRequest`1 request, CancellationToken ct)

HTTP 500
Thời điểm phát hiện:  khi endpoint được gọi LẦN ĐẦU TIÊN
Không phải: lúc biên dịch, lúc khởi động, hay trong unit test của handler khác

Nếu endpoint đó là một chức năng ít dùng — huỷ lead chẳng hạn — nó có thể lên production và chờ nhiều tuần trước khi có người bấm.

Vì sao MediatR đánh đổi an toàn kiểu lấy linh hoạt — đây là bản chất của mẫu Mediator:

// Gọi trực tiếp — trình biên dịch KIỂM TRA
await _huyLeadService.HuyAsync(id, ct);
// ^^^^^^^^^^^^^^^^ nếu không tồn tại -> LỖI BIÊN DỊCH
// F12 -> đi thẳng tới phần cài đặt

// Qua MediatR — trình biên dịch KHÔNG kiểm tra
await _mediator.Send(new HuyLeadCommand(id), ct);
// ^^^^^^^^^^^^^ chỉ biết có một IRequest<Result>
// F12 -> đi tới Mediator.Send, không tới handler

Liên kết giữa command và handler được giải quyết lúc chạy, qua DI container, dựa trên kiểu generic. Đó chính là thứ cho MediatR sức mạnh — pipeline behavior áp cho mọi request mà không ai phải sửa handler — nhưng nó phải trả giá bằng việc trình biên dịch không còn kiểm tra được.

Ba hệ quả, và hệ quả thứ hai thường gây khó chịu nhất trong công việc hằng ngày:

1. Handler thiếu -> lỗi lúc chạy, không lúc biên dịch
2. "Go to Definition" không dẫn tới handler
3. "Find All References" trên handler trả về 0 kết quả
-> công cụ phân tích code báo handler là "dead code"
-> ai đó có thể xoá nó

Ba cách bù lại:

Cách 1 — kiến trúc test kiểm tra mọi request có handler:

[Fact]
public void Moi_request_phai_co_handler()
{
var assembly = typeof(ChotLeadCommand).Assembly;

var requests = assembly.GetTypes()
.Where(t => !t.IsAbstract && !t.IsInterface)
.Where(t => t.GetInterfaces().Any(i => i.IsGenericType
&& (i.GetGenericTypeDefinition() == typeof(IRequest<>)
|| i.GetGenericTypeDefinition() == typeof(IRequest))))
.ToList();

var handlerTypes = assembly.GetTypes()
.Where(t => !t.IsAbstract)
.SelectMany(t => t.GetInterfaces())
.Where(i => i.IsGenericType
&& i.GetGenericTypeDefinition() == typeof(IRequestHandler<,>))
.Select(i => i.GetGenericArguments()[0])
.ToHashSet();

var thieu = requests.Where(r => !handlerTypes.Contains(r))
.Select(r => r.Name).ToList();

thieu.Should().BeEmpty("các request sau không có handler: {0}", string.Join(", ", thieu));
}
Xpect: các request sau không có handler: HuyLeadCommand

Test này chạy trong vài chục mili giây và bắt lỗi ở CI thay vì ở production.

Cách 2 — xác minh lúc khởi động (bắt sớm hơn nữa, trước khi pod sẵn sàng):

public static void KiemTraHandler(this IServiceProvider sp, Assembly assembly)
{
var thieu = new List<string>();

foreach (var request in assembly.GetTypes()
.Where(t => !t.IsAbstract && t.GetInterfaces()
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IRequest<>))))
{
var responseType = request.GetInterfaces()
.First(i => i.GetGenericTypeDefinition() == typeof(IRequest<>))
.GetGenericArguments()[0];

var handlerType = typeof(IRequestHandler<,>).MakeGenericType(request, responseType);
if (sp.GetService(handlerType) is null) thieu.Add(request.Name);
}

if (thieu.Count > 0)
throw new InvalidOperationException(
$"Thiếu handler cho: {string.Join(", ", thieu)}");
}
var app = builder.Build();
if (!app.Environment.IsProduction() || app.Configuration.GetValue<bool>("KiemTraHandlerLucKhoiDong"))
app.Services.KiemTraHandler(typeof(ChotLeadCommand).Assembly);

Cách này có ưu điểm của ValidateOnStart ở bài 15.4: pod mới không bao giờ đạt Ready, nên rolling update dừng và pod cũ vẫn phục vụ.

Nó cũng mạnh hơn cách 1 vì nó kiểm tra qua DI container thật — nên nó bắt được cả trường hợp handler tồn tại nhưng không được đăng ký (ví dụ nằm ở assembly không được quét).

Cách 3 — quy ước đặt tên cộng test kiểm tra quy ước:

[Fact]
public void Handler_phai_dat_ten_theo_quy_uoc()
{
var kq = Types.InAssembly(typeof(ChotLeadHandler).Assembly)
.That().ImplementInterface(typeof(IRequestHandler<,>))
.Should().HaveNameEndingWith("Handler").And().BeSealed()
.GetResult();

kq.IsSuccessful.Should().BeTrue();
}

Quy ước làm cho việc tìm handler bằng Ctrl+T trở nên đáng tin: gõ ChotLead cho ra ChotLeadCommand và ChotLeadHandler cạnh nhau.

Và câu hỏi nên hỏi trước: bạn có cần MediatR không?

So sánh cùng một use case:

// MediatR
public record ChotLeadCommand(LeadId Id) : IRequest<Result>;
public class ChotLeadHandler : IRequestHandler<ChotLeadCommand, Result> { ... }
await _mediator.Send(new ChotLeadCommand(id), ct);
// Interface + decorator
public interface IChotLeadUseCase { Task<Result> ThucThiAsync(LeadId id, CancellationToken ct); }
public class ChotLeadUseCase : IChotLeadUseCase { ... }
await _chotLead.ThucThiAsync(id, ct);
// Decorator cho cross-cutting — Scrutor
services.AddScoped<IChotLeadUseCase, ChotLeadUseCase>();
services.Decorate<IChotLeadUseCase, ChotLeadUseCaseCoTransaction>();
services.Decorate<IChotLeadUseCase, ChotLeadUseCaseCoValidation>();
services.Decorate<IChotLeadUseCase, ChotLeadUseCaseCoLogging>();
MediatRInterface + decorator
An toàn kiểu lúc biên dịchKhôngCó
Go to DefinitionKhông dẫn tới handlerDẫn thẳng
Cross-cutting cho MỌI use caseMột dòng đăng kýPhải decorate từng interface
Số kiểu cho mỗi use case2 (command + handler)2 (interface + lớp)
Phụ thuộc bên ngoàiCó, và có giấy phép từ v12Không
Người mới hiểu luồngKhó hơnDễ hơn

Giá trị thật của MediatR nằm ở pipeline behavior, không ở chỗ "giảm khớp nối" — controller phụ thuộc IMediator cũng là một phụ thuộc, chỉ là một phụ thuộc khác.

Dùng MediatR khi:
- có từ 4–5 cross-cutting concern cần áp cho MỌI use case
- có trên 30–40 use case (chi phí decorate từng cái trở nên lớn)
- nhóm đã quen với mẫu này

Không cần MediatR khi:
- dưới 20 use case
- chỉ cần 1–2 cross-cutting concern (dùng middleware hoặc filter)
- nhóm mới, ưu tiên code dễ lần theo

Và từ MediatR v12, thư viện chuyển sang giấy phép thương mại cho một số kịch bản sử dụng — đây là một yếu tố cần cân nhắc cho dự án thương mại, và là lý do nhiều nhóm đang xem lại lựa chọn này.


Bài 3 — So sánh hai cách cài use case​

Cài cùng một use case bằng MediatR và bằng interface cộng decorator. So sánh số dòng, số phụ thuộc, và khả năng "Go to Definition".

Tiêu chí hoàn thành: bạn đo được ba tiêu chí và đưa ra khuyến nghị có điều kiện, không phải khuyến nghị tuyệt đối.

Gợi ý và lời giải — Bài 3

Gợi ý. Chi phí của MediatR gần như cố định; lợi ích tăng theo số use case. Điểm hoà vốn ở đâu?

Lời giải — cài đầy đủ cả hai:

Cách A — MediatR:

// ChotLeadCommand.cs — 3 dòng
public record ChotLeadCommand(LeadId Id) : IRequest<Result>;

// ChotLeadHandler.cs — 24 dòng
public sealed class ChotLeadHandler : IRequestHandler<ChotLeadCommand, Result>
{
private readonly CrmDbContext _db;
private readonly ICurrentUser _user;
private readonly TimeProvider _clock;

public ChotLeadHandler(CrmDbContext db, ICurrentUser user, TimeProvider clock)
=> (_db, _user, _clock) = (db, user, clock);

public async Task<Result> Handle(ChotLeadCommand c, CancellationToken ct)
{
var lead = await _db.Leads.FirstOrDefaultAsync(l => l.Id == c.Id, ct);
if (lead is null) return Result.KhongTimThay();

return lead.ChuyenSangWon(_user.ToNguoiDung(), _clock.GetUtcNow().UtcDateTime);
}
}

// Program.cs — 6 dòng, dùng chung cho MỌI use case
builder.Services.AddMediatR(c =>
{
c.RegisterServicesFromAssembly(typeof(ChotLeadHandler).Assembly);
c.AddOpenBehavior(typeof(LoggingBehavior<,>));
c.AddOpenBehavior(typeof(ValidationBehavior<,>));
c.AddOpenBehavior(typeof(TransactionBehavior<,>));
});

Cách B — interface + decorator:

// IChotLeadUseCase.cs — 4 dòng
public interface IChotLeadUseCase
{
Task<Result> ThucThiAsync(LeadId id, CancellationToken ct);
}

// ChotLeadUseCase.cs — 22 dòng (tương tự handler)

// Program.cs — 4 dòng CHO MỖI use case
builder.Services.AddScoped<IChotLeadUseCase, ChotLeadUseCase>();
builder.Services.Decorate<IChotLeadUseCase, ChotLeadTransactionDecorator>();
builder.Services.Decorate<IChotLeadUseCase, ChotLeadValidationDecorator>();
builder.Services.Decorate<IChotLeadUseCase, ChotLeadLoggingDecorator>();
// ChotLeadTransactionDecorator.cs — 20 dòng, CHO MỖI use case
public sealed class ChotLeadTransactionDecorator : IChotLeadUseCase
{
private readonly IChotLeadUseCase _trong;
private readonly CrmDbContext _db;

public ChotLeadTransactionDecorator(IChotLeadUseCase trong, CrmDbContext db)
=> (_trong, _db) = (trong, db);

public async Task<Result> ThucThiAsync(LeadId id, CancellationToken ct)
{
var strategy = _db.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
await using var tx = await _db.Database.BeginTransactionAsync(ct);
var kq = await _trong.ThucThiAsync(id, ct);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return kq;
});
}
}

Đo ba tiêu chí:

MediatRInterface + decorator
Dòng cho use case đầu tiên3390
Dòng cho mỗi use case tiếp theo2786
Số phụ thuộc bên ngoài1 (MediatR)1 (Scrutor)
Go to Definition từ nơi gọiTới Mediator.SendTới IChotLeadUseCase
Find All References trên handler0 kết quảĐúng các chỗ gọi
Dòng để thêm một cross-cutting concern mới120 × số use case

Dòng cuối là điểm quyết định, và nó cho ra điểm hoà vốn:

Thêm PerformanceBehavior:
MediatR: 1 dòng đăng ký, áp cho TẤT CẢ use case
Decorator: 1 lớp decorator × 40 use case = 800 dòng

Điểm hoà vốn:

Chi phí cố định của MediatR:   ~200 dòng (4 behavior) + học cách dùng
Tiết kiệm mỗi use case: ~59 dòng

Hoà vốn: khoảng 4 use case

Nhưng chi phí THẬT của MediatR không phải số dòng — mà là
khả năng lần theo luồng, và cái đó tăng theo số người trong nhóm
và theo thời gian dự án tồn tại.

Khuyến nghị có điều kiện, không tuyệt đối:

Bối cảnhKhuyến nghị
Dưới 20 use case, 1–2 cross-cuttingInterface trực tiếp, không decorator, dùng middleware cho cross-cutting
20–40 use case, 2–3 cross-cuttingInterface + decorator, hoặc MediatR — cả hai đều hợp lý
Trên 40 use case, 4+ cross-cuttingMediatR
Nhóm đã quen MediatRMediatR, kể cả dự án nhỏ — chi phí học đã trả rồi
Nhóm mới, ưu tiên dễ lần theoInterface trực tiếp
Dự án thương mại nhạy cảm về giấy phépCân nhắc kỹ MediatR v12+, hoặc dùng bản tự viết

Một lựa chọn thứ ba ít được nhắc: middleware của ASP.NET Core.

Nhiều thứ mà người ta dùng pipeline behavior để làm thật ra thuộc về middleware:

// Logging và đo hiệu năng — middleware làm tốt hơn, và áp cho MỌI endpoint
app.UseSerilogRequestLogging();

// Validation — filter của Minimal API
app.MapPost("/leads", ...).AddEndpointFilter<ValidationFilter<TaoLeadRequest>>();

// Transaction — chỉ cần cho command, và một filter là đủ
app.MapPost("/leads", ...).AddEndpointFilter<TransactionFilter>();
public class ValidationFilter<T> : IEndpointFilter where T : class
{
private readonly IValidator<T> _validator;

public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
var arg = ctx.Arguments.OfType<T>().FirstOrDefault();
if (arg is not null)
{
var kq = await _validator.ValidateAsync(arg);
if (!kq.IsValid) return Results.ValidationProblem(kq.ToDictionary());
}
return await next(ctx);
}
}

Với cách này bạn có cross-cutting concern mà không cần MediatR và không cần decorator, và use case là một lớp bình thường được inject trực tiếp:

app.MapPost("/leads/{id}/chot", async (LeadId id, IChotLeadUseCase uc, CancellationToken ct) =>
{
var kq = await uc.ThucThiAsync(id, ct);
return kq.ThanhCong ? Results.NoContent() : Results.BadRequest(kq.Loi);
})
.AddEndpointFilter<TransactionFilter>();
Số dòng mỗi use case:  ~26
Go to Definition: dẫn thẳng tới IChotLeadUseCase
Cross-cutting: middleware và filter, một lần cho cả ứng dụng
Phụ thuộc bên ngoài: không có

Giới hạn của cách này: middleware và filter chỉ chạy cho request HTTP. Một use case được gọi từ background job hay message consumer sẽ không đi qua chúng — và bạn phải tự lo transaction, validation, logging cho đường đó.

Đây chính là điểm mà MediatR có lợi thế thật: pipeline của nó chạy bất kể ai gọi, từ HTTP, từ Hangfire, hay từ MassTransit consumer.

Câu hỏi quyết định cuối cùng:

"Use case của bạn có được gọi từ nhiều nguồn ngoài HTTP không?"

Chỉ HTTP           -> middleware và filter là đủ, đơn giản nhất
HTTP + job + message -> pipeline behavior có giá trị thật, cân nhắc MediatR

Và dù chọn gì, hãy viết lý do vào docs/architecture-decisions/. Sáu tháng sau, người kế tiếp sẽ hỏi "vì sao dự án này dùng MediatR?" — và một đoạn ba câu trả lời được câu đó đáng giá hơn nhiều so với việc tranh luận lại từ đầu.

Tự kiểm tra​

Frequently asked questions

Giá trị thật của MediatR là gì?

Pipeline behavior: viết validation, logging, transaction một lần rồi áp cho mọi use case. Việc tách controller khỏi service thì một interface thường cũng làm được, nên nếu đó là lý do duy nhất thì bạn đang trả phí cho thứ không cần.

Vì sao thứ tự behavior quan trọng?

Behavior bọc nhau như middleware. Validation sau Transaction nghĩa là mở transaction cho request chắc chắn thất bại. Logging sau Validation nghĩa là không log request bị từ chối. Và thứ cần biết transaction đã commit mà nằm trong transaction sẽ ghi log trước khi commit, cho tình huống log nói thành công nhưng database không có gì.

Vì sao transaction behavior chỉ nên áp cho command?

Vì query chỉ đọc nên mở transaction cho nó là chi phí thừa. Dùng marker interface riêng cho command và ràng buộc generic để behavior chỉ áp đúng nhóm đó.

Chi phí lớn nhất của MediatR là gì?

Mất khả năng lần theo luồng: từ lời gọi Send không nhảy tới handler được, phải tìm kiếm toàn giải pháp theo tên kiểu. Với người mới đó là rào cản đáng kể, và với codebase hàng trăm handler thì đó là chi phí hằng ngày.

Lỗi thiếu handler xuất hiện khi nào?

Lúc chạy, khi ai đó gọi đúng endpoint đó, chứ không phải lúc biên dịch. Chặn bằng một kiến trúc test duyệt mọi kiểu implement IRequest và khẳng định có handler tương ứng.

Phương án thay thế MediatR là gì?

Interface thường cộng decorator đăng ký bằng Scrutor. Nó cho cùng lợi ích behavior, giữ được khả năng đi tới định nghĩa vì bạn tiêm handler cụ thể, không thêm phụ thuộc và không có vấn đề giấy phép. Cái giá là tự viết khoảng năm mươi dòng hạ tầng.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Behavior là giá trị; controller mỏng thì interface thường cũng làm được.
  2. Thứ tự behavior quyết định hành vi — validation trước transaction, và log sau commit.
  3. Dự án nhỏ không cần MediatR. Service thường là đủ và dễ đọc hơn.

Tham khảo​

Điều hướng​