CQRS و الگوی Mediator
کدی که داده را تغییر میدهد و کدی که داده را میخواند، نیازهای متفاوتی دارند. CQRS این دو را جدا میکند. الگوی Mediator و کتابخانه MediatR هر درخواست را به یک Handler کوچک میرسانند و کارهای مشترک را یک جا انجام میدهند.
نویسنده: bezzad
مشکل: یک مدل برای دو کار مختلف
فروشگاه اینترنتی ما دو کار ساده دارد:
- ثبت سفارش. باید قانونها چک شوند: موجودی، سقف مبلغ، وضعیت سفارش. این کار با Aggregate سفارش انجام میشود، همان که در درس DDD دیدی.
- صفحه «سفارشهای من». یک لیست ساده: تاریخ، مبلغ، وضعیت. این صفحه هزار بار بیشتر از ثبت سفارش باز میشود.
اگر برای هر دو کار از یک مدل و یک سرویس استفاده کنیم:
- صفحه لیست کند میشود. برای نشان دادن سه ستون، کل سفارش با همه ردیفها لود و ردیابی میشود.
- مدل دامنه شلوغ میشود. صفحه نمایش فیلدی مثل «اسم کالای اول» میخواهد. کسی آن را به کلاس Order اضافه میکند.
- سرویس بزرگ میشود. کلاس OrderService ده متد خواندن و ده متد نوشتن دارد.
نوشتن به قانون نیاز دارد. خواندن به سرعت و شکل مناسب صفحه نیاز دارد. این دو با هم نمیسازند.
ایده CQRS: نوشتن و خواندن را جدا کن
اسم CQRS از Command Query Responsibility Segregation آمده است. یعنی مسئولیت دستور و پرسوجو را جدا کن.
دستور (Command)
- چیزی را تغییر میدهد.
- اسمش یک فعل امری است: PlaceOrder، CancelOrder.
- از مدل دامنه و قانونها عبور میکند.
- چیزی برنمیگرداند، یا فقط شناسه را برمیگرداند.
پرسوجو (Query)
- هیچ چیز را تغییر نمیدهد.
- اسمش یک سؤال است: GetMyOrders.
- مستقیم و سریع میخواند، بدون مدل دامنه.
- یک مدل کوچک (DTO) مخصوص همان صفحه برمیگرداند.
دو سطح CQRS
بیشتر آدمها با شنیدن CQRS فکر میکنند دو دیتابیس لازم است. این درست نیست. CQRS دو سطح دارد:
سطح اول: یک دیتابیس، دو مسیر
دستورها و پرسوجوها کلاسهای جدا دارند، ولی از یک دیتابیس استفاده میکنند. دستور با Aggregate کار میکند. پرسوجو با یک کوئری سبک، مثلاً با متد AsNoTracking و Select، فقط ستونهای لازم را میخواند. این سطح برای بیشتر پروژهها کافی است و تقریباً هیچ هزینهای ندارد.
سطح دوم: دیتابیس جدا برای خواندن
وقتی خواندن خیلی سنگین است، یک مدل خواندن جدا میسازیم. مثلاً یک جدول آماده برای صفحه، یا یک موتور جستجو. بعد از هر تغییر، یک رویداد منتشر میشود و مدل خواندن بهروز میشود.
این سطح قدرت زیادی دارد، ولی هزینه هم دارد:
- سازگاری نهایی (Eventual Consistency). کاربر سفارش ثبت میکند و بلافاصله لیست را باز میکند. ممکن است سفارش جدید هنوز آنجا نباشد.
- کد بیشتر. باید رویداد بفرستی، بهروزکننده بنویسی و خطاهای آن را مدیریت کنی.
- عملیات بیشتر. یک دیتابیس یا سرویس دیگر برای نگهداری و پایش.
الگوی Mediator: هر درخواست، یک Handler
با CQRS، به جای یک OrderService بزرگ، برای هر کار یک کلاس کوچک داریم. حالا endpoint چطور کلاس درست را پیدا کند؟
الگوی Mediator یک میانجی وسط میگذارد. endpoint فقط یک پیام میفرستد. میانجی آن را به Handler همان پیام میرساند. در .NET، کتابخانه MediatR رایجترین پیادهسازی این الگو است.
کد
دستور
public sealed record PlaceOrderCommand(Guid CustomerId, Guid CartId) : IRequest<Guid>;
public sealed class PlaceOrderHandler(ShopDbContext db)
: IRequestHandler<PlaceOrderCommand, Guid>
{
public async Task<Guid> Handle(PlaceOrderCommand command, CancellationToken ct)
{
var cart = await db.Carts
.Include(c => c.Items)
.SingleAsync(c => c.Id == command.CartId, ct);
var order = Order.PlaceFrom(cart, command.CustomerId); // rules live in the domain
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return order.Id;
}
}
پرسوجو
public sealed record GetMyOrdersQuery(Guid CustomerId) : IRequest<List<OrderSummary>>;
public sealed record OrderSummary(Guid Id, DateTime PlacedAt, decimal Total, OrderStatus Status);
public sealed class GetMyOrdersHandler(ShopDbContext db)
: IRequestHandler<GetMyOrdersQuery, List<OrderSummary>>
{
public Task<List<OrderSummary>> Handle(GetMyOrdersQuery query, CancellationToken ct) =>
db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == query.CustomerId)
.OrderByDescending(o => o.PlacedAt)
.Select(o => new OrderSummary(
o.Id, o.PlacedAt, o.Lines.Sum(l => l.Price * l.Quantity), o.Status))
.ToListAsync(ct);
}
به تفاوت دقت کن: پرسوجو هیچ قانونی را اجرا نمیکند، چیزی را ردیابی نمیکند و فقط چهار فیلد را میخواند.
endpoint ها
app.MapPost("/orders", async (PlaceOrderCommand command, ISender sender, CancellationToken ct) =>
{
var id = await sender.Send(command, ct);
return TypedResults.Created($"/orders/{id}", id);
});
app.MapGet("/customers/{customerId:guid}/orders",
(Guid customerId, ISender sender, CancellationToken ct) =>
sender.Send(new GetMyOrdersQuery(customerId), ct));
اینترفیس ISender فقط متد Send را دارد. وقتی endpoint فقط درخواست میفرستد، همین کافی است.
رفتارهای pipeline: کار مشترک، یک بار
قبل از هر دستور باید ورودی چک شود. لاگ و تراکنش هم برای همه لازم است. اگر این کارها را در هر Handler بنویسیم، صدها بار تکرار میشوند.
در MediatR، رفتار pipeline (Pipeline Behavior) دور همه Handler ها اجرا میشود. این همان ایده Decorator در درس الگوهای طراحی است.
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)
{
foreach (var validator in validators)
await validator.ValidateAndThrowAsync(request, ct);
return await next(ct);
}
}
ثبت در DI. اعتبارسنجها در این مثال از کتابخانه FluentValidation هستند:
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
- ترتیب ثبت مهم است. اولین رفتار ثبتشده، بیرونیترین لایه است. پس در این کد، لاگ همه درخواستها را میبیند، حتی آنهایی که اعتبارسنجی آنها را رد میکند.
- رفتار باید کلی باشد. اگر یک کار فقط برای یک دستور است، جایش داخل همان Handler است.
هزینههای Mediator
الگوی Mediator مجانی نیست. قبل از انتخاب، این هزینهها را بدان:
- پیدا کردن کد سختتر میشود. با «رفتن به تعریف» روی Send، به Handler نمیرسی. باید دنبال کلاس بگردی.
- وابستگیها پنهان میشوند. endpoint فقط ISender میگیرد. دیگر از روی سازنده نمیفهمی کد به چه چیزهایی وابسته است.
- کلاسهای زیاد. برای هر کار ساده یک record، یک Handler و گاهی یک Validator.
- زنجیره Send. اگر Handler ها همدیگر را با Send صدا بزنند، جریان کد گم میشود.
- لایسنس. کتابخانه MediatR از نسخه ۱۳ لایسنس تجاری دارد. یک نسخه رایگان Community برای شرکتهای کوچک هست. قبل از استفاده، شرایط لایسنس را برای شرکت خودت بخوان.
builder.Services.AddScoped<PlaceOrderHandler>();
app.MapPost("/orders", async (PlaceOrderCommand command, PlaceOrderHandler handler, CancellationToken ct) =>
TypedResults.Ok(await handler.Handle(command, ct)));
در این روش، «رفتن به تعریف» مستقیم به Handler میرسد و وابستگیها پنهان نیستند.
قانونهای مهم
- پرسوجو هیچ چیز را تغییر نمیدهد. نه دیتابیس، نه کش، نه وضعیت کاربر.
- دستور داده کامل برنمیگرداند. فقط شناسه یا نتیجه موفق و ناموفق. برای دیدن داده، یک پرسوجو بفرست.
- قانون کسبوکار در دامنه است، نه در Handler. Handler فقط هماهنگ میکند.
- یک Handler، Handler دیگری را صدا نمیزند. منطق مشترک را در یک کلاس عادی یا در دامنه بگذار.
- در سطح دوم، رابط کاربری را برای تأخیر آماده کن. مثلاً بعد از ثبت، صفحه تأیید را از جواب دستور نشان بده، نه از مدل خواندن.
اشتباههای رایج
| اشتباه | چرا بد است؟ | راه درست |
|---|---|---|
| فکر کنی CQRS یعنی دو دیتابیس | پیچیدگی زیاد برای مشکلی که نداری. | اول کلاسهای جدا روی یک دیتابیس. |
| پرسوجو با Aggregate کامل و ردیابی | کند است و حافظه زیادی مصرف میکند. | کوئری سبک با Select و بدون ردیابی. |
| پرسوجویی که چیزی را تغییر میدهد | اثر پنهان دارد و نمیشود آن را کش کرد یا تکرار کرد. | تغییر فقط در دستور. |
| MediatR فقط برای کم کردن پارامترهای سازنده | مشکل طراحی پنهان میشود، نه حل. | کلاس شلوغ را بر اساس کار تقسیم کن. |
| Handler هایی که همدیگر را با Send صدا میزنند | جریان کد گم میشود و تراکنشها قاطی میشوند. | منطق مشترک در یک سرویس یا در دامنه. |
| نادیده گرفتن تأخیر مدل خواندن | کاربر سفارش تازهاش را نمیبیند و دوباره ثبت میکند. | پیام روشن در رابط کاربری، یا خواندن از مدل نوشتن در همان لحظه. |
چه وقت CQRS و Mediator؟
مناسب
- خواندن و نوشتن نیازهای خیلی متفاوت دارند.
- پروژه use case های زیادی دارد.
- کارهای مشترک زیادی روی همه درخواستها لازم است.
- تیم با Vertical Slice کار میکند. هر قابلیت یک دستور یا پرسوجو است.
نامناسب
- سرویس کوچک CRUD که خواندن و نوشتنش تقریباً یک شکل است.
- تیم با این الگو آشنا نیست و پروژه کوتاهمدت است.
- هدف فقط «معماری مدرن» است، نه یک مشکل واقعی.
خلاصه در شش خط
- دستور چیزی را تغییر میدهد. پرسوجو فقط میخواند. CQRS این دو را جدا میکند.
- سطح اول یک دیتابیس و دو مسیر است. برای بیشتر پروژهها کافی است.
- مدل خواندن جدا سریع است، ولی تأخیر و کد بیشتر دارد.
- الگوی Mediator هر درخواست را به یک Handler کوچک میرساند.
- رفتارهای pipeline کارهای مشترک را یک بار و دور همه Handler ها اجرا میکنند.
- هزینهها را بدان: کد غیرمستقیم، کلاسهای زیاد و لایسنس MediatR. بدون کتابخانه هم میشود.