CQRS ve MediatR ile Komut/Sorgu Ayrımı

CQRS deseninin mantigi, MediatR kurulumu ve pipeline behaviour ile cross-cutting islemler.

Bir CMS’te editörün “Yayımla” işlemiyle ziyaretçinin bir yazıyı açması farklı ihtiyaçlara sahiptir. İlk işlem taslağı yükler, yayımlama kurallarını uygular ve değişikliği kaydeder. İkincisi yalnızca herkese açık yazının başlığını, içeriğini ve yayım tarihini getirir. CQRS, bu iki işi komut ve sorgu olarak ayrı modellerle ele alır. MediatR ise ASP.NET Core içinde gelen isteği ilgili handler’a iletir.

Bir komut ve bir sorguyla başlayın

PublishPostCommand, editörün bir yazıyı yayımlama niyetini ifade eder. Handler, taslağı yükleyen, alan modelindeki yayım kuralını uygulayan ve değişikliği kaydeden PublishPostService’i çağırır. Sonuç olarak yazının kimliğini döndürür. GetPublishedPostQuery ise ziyaretçiye gösterilecek alanları içeren bir DTO üretir. Komutun bir sonuç döndürmesi olağandır. Ayrım, bir tarafın iş durumunu değiştirmesi, diğer tarafın durumu okumasıdır.

public sealed record PublishPostCommand(Guid Id) : IRequest<Guid>;

public sealed class PublishPostHandler(PublishPostService publish)
    : IRequestHandler<PublishPostCommand, Guid>
{
    public Task<Guid> Handle(PublishPostCommand request, CancellationToken ct) =>
        publish.ExecuteAsync(request.Id, ct);
}

public sealed record PublishedPostDto(
    Guid Id, string Title, string Body, DateTimeOffset PublishedAtUtc);

public sealed record GetPublishedPostQuery(Guid Id) : IRequest<PublishedPostDto?>;

public sealed class GetPublishedPostHandler(IPublishedPostReader reader)
    : IRequestHandler<GetPublishedPostQuery, PublishedPostDto?>
{
    public Task<PublishedPostDto?> Handle(
        GetPublishedPostQuery request, CancellationToken ct) =>
        reader.FindAsync(request.Id, ct);
}

Application’daki IPublishedPostReader sözleşmesinin EF Core uygulaması, komutun kullandığı veritabanında aynı Posts tablosunu okuyabilir. Okuma kodu yalnızca yayımlanmış satırı seçer ve DTO’yu doğrudan sorguda oluşturur. Böylece taslak ziyaretçiye açılmaz ve yanıt için gerekli olmayan alanlar çekilmez.

// Infrastructure — IPublishedPostReader uygulamasından
public Task<PublishedPostDto?> FindAsync(Guid id, CancellationToken ct) =>
    db.Posts
        .AsNoTracking()
        .Where(post => post.Id == id && post.PublishedAtUtc != null)
        .Select(post => new PublishedPostDto(
            post.Id, post.Title, post.Body, post.PublishedAtUtc!.Value))
        .SingleOrDefaultAsync(ct);

İki model tek veritabanında çalıştığı için komut kaydını tamamladığında aynı veritabanına giden sonraki sorgu yeni durumu okuyabilir. Okuma tarafını ileride ayrı bir depoya taşırsanız, veriyi oraya aktarma ve iki taraf arasındaki gecikmeyi yönetme sorumluluğu da ortaya çıkar. Örneğin editörün başarılı yayım yanıtından hemen sonra yazıyı ön izlemesi gerekiyorsa bu gecikme doğrudan kullanıcıya yansır.

MediatR kaydı ve doğrulama

Program.cs içinde handler’ların bulunduğu assembly’yi kaydedin. Bu örnekte komut, sorgu ve iki handler aynı assembly’dedir. PublishPostService ve IPublishedPostReader uygulamasını da DI kapsayıcısına ayrıca ekleyin. Endpoint’ler ISender.Send ile istekleri iletir ve istemcinin iptal belirtecini aktarır. Editör endpoint’i ayrıca yayımlama yetkisini kontrol eder. Okuma endpoint’i okuyucu null döndürdüğünde 404 verir.

builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<PublishPostHandler>();
    cfg.AddOpenBehavior(typeof(InputValidationBehavior<,>));
});
builder.Services.AddTransient<IInputValidator<PublishPostCommand>, PublishPostInputValidator>();

AddOpenBehavior, doğrulama davranışını uyumlu istek türleri için kaydeder. Girdiyi doğrulayan kodu ve her istek için kullanılacak validator’ları siz yazıp kaydedersiniz. Aşağıdaki davranış handler’dan önce ilgili istek türüne ait validator’ları çalıştırır. Bir validator hata verdiğinde handler’a geçilmez. Bir istek türü için validator kaydedilmemişse davranış doğrudan sonraki adıma geçer.

public interface IInputValidator<in TRequest>
{
    void Validate(TRequest request);
}

public sealed class InputValidationBehavior<TRequest, TResponse>(
    IEnumerable<IInputValidator<TRequest>> validators)
    : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
    public Task<TResponse> Handle(
        TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        foreach (var validator in validators)
            validator.Validate(request);

        return next();
    }
}

public sealed class InvalidRequestException(string message) : Exception(message) { }

public sealed class PublishPostInputValidator : IInputValidator<PublishPostCommand>
{
    public void Validate(PublishPostCommand request)
    {
        if (request.Id == Guid.Empty)
            throw new InvalidRequestException("Yazı kimliği boş olamaz.");
    }
}

Bu örnekte validator, veritabanına gitmeden komutun kimliğini denetler. API’nin hata işleyicisi InvalidRequestException’ı 400 yanıtına çevirebilir. Taslağın yayıma uygun olup olmadığı ise yazı yüklendikten sonra alan modelinde belirlenir. Bir entegrasyon testi üç sonucu ayrı ayrı doğrulayabilir. Boş kimlikte komut handler’ı çalışmaz, taslak herkese açık sorgudan dönmez ve başarılı bir yayımdan sonra sorgu DTO döndürür.

Aynı doğrulama davranışı, HTTP dışında bir giriş noktası da ISender üzerinden komut gönderdiğinde çalışır. Böylece zamanlanmış bir yayım görevi de boş kimlik için aynı hatayı alır.

Komut handler’ını doğrudan çağıran birim testi yalnızca handler’ın kendi davranışını çalıştırır. Doğrulama kaydını sınamak için isteği, uygulamanın servis kayıtlarıyla oluşturulan ISender üzerinden gönderin. Böyle bir test, validator kaydının unutulması veya yanlış assembly’nin taranması gibi, handler’a doğrudan çağrıda ortaya çıkmayacak hataları yakalar.

Ayrımı nerede uygulamalı?

Yayımlama gibi iş kuralı olan bir komut ile halka açık yazı görünümü gibi ayrı veri ihtiyacı olan bir sorgu iyi bir başlangıçtır. Örneğin basit bir etiket yönetim ekranında okuma ve yazma aynı alanları kullanıyor ve güncelleme bir ad değişikliğinden ibaretse, doğrudan bir uygulama servisi daha az kodla işi görür. MediatR seçimi istekleri nasıl yönlendireceğinizi, CQRS seçimi ise okuma ve yazma sorumluluklarının nasıl ayrılacağını belirler.

Kaynaklar