MediatR ile CQRS: bir .NET API'de neden, nasıl ve nerede durmalı

Controller'ları inceltmek için değil, iş kurallarını tek bir yere toplamak için. Tedarik Yönetimi'nde uyguladığımız MediatR ve CQRS deseni; komut kayıtları, aggregate başına tek handler, doğrulama pipeline'ı, durum koruması ve MediatR 13 lisans notu.

“CQRS” duyunca çoğu kişinin aklına ayrı okuma ve yazma veritabanları, olay kaynaklama, mesaj kuyrukları gelir. O büyük resim var, ama bir kurumsal API’de günlük hayatta işe yarayan kısım çok daha sadedir: her isteğin bir adı, tek bir sahibi ve tek bir giriş kapısı olması. Bu yazı, Tedarik Yönetimi’nde .NET üzerinde uyguladığımız MediatR tabanlı CQRS düzenini anlatıyor: neden seçtik, nasıl kurduk, hangi noktada durduk.

Sorun: iş kuralı nereye yazılır?

Klasik bir katmanlı API’de “talebi onayla” işlemi üç yerde birden yaşar: controller parametreyi alır ve biraz kontrol eder, servis sınıfı asıl işi yapar ama başka servisleri de çağırır, repository veriyi yazar. Altı ay sonra aynı kural mobil uç için ikinci bir controller’da tekrar yazılır ve iki kopya sessizce ayrışır. Onay adımlarının koşulları arttıkça, “onaylanmış bir talep tekrar onaylanabilir mi?” sorusunun cevabı kodun neresinde olduğuna göre değişir.

CQRS’in temel ayrımı, isteği komut (durum değiştirir) ve sorgu (yalnızca okur) olarak ikiye bölmektir. MediatR ise bu isteği taşıyan nesneyi, onu işleyen sınıfa ulaştıran küçük bir aracıdır. İkisinin birleşiminden şu düzen çıkar: her iş kuralının tek bir komutu, her komutun tek bir handler’ı vardır.

Komutlar: isteğin adı ve sözleşmesi

Komutlar record olarak tanımlanır ve yalnızca veri taşır. Talep akışının bir kesiti:

public record CreateMaterialRequestCommand(MaterialRequestInputModel Model)
    : IRequest<MaterialRequestOutputModel>;
public record ApproveMaterialRequestCommand(int Id, string? Note)
    : IRequest<MaterialRequestOutputModel>;
public record SubmitOfferCommand(int Id, int SupplierId, decimal? Amount,
    string? Currency, string? Note, int TechnicalApproverId)
    : IRequest<MaterialRequestOutputModel>;
public record ReceiveMaterialRequestCommand(int Id, bool Incomplete = false, string? Note = null)
    : IRequest<MaterialRequestOutputModel>;

Bu dosya, uygulamanın iş sözlüğüdür. Yeni gelen bir geliştirici, controller’ları gezmeden burada “bu sistem ne yapabiliyor” sorusunun cevabını görür. Komut adı fiil içerir, parametreleri gerçekten gereken alanlarla sınırlıdır, dönüş tipi bellidir. Aynı yapı sorgular için de geçerlidir: GetMyRequestsQuery, GetPendingApprovalsQuery.

Controller: tek satırlık kapı

Controller’ın tek işi HTTP’yi komuta çevirmek ve sonucu standart zarfa sarmaktır:

public abstract class ApiControllerBase(ISender sender) : ControllerBase
{
    protected async Task<ApiResponse<TResponse>> Execute<TResponse>(IRequest<TResponse> request)
        => ApiResponse<TResponse>.Ok(await sender.Send(request));
}

[HttpPost("{id:int}/approve")]
[RequirePermission(Permissions.RequestApprove)]
public Task<ApiResponse<MaterialRequestOutputModel>> Approve(int id, [FromBody] DecisionInputModel m)
    => Execute(new ApproveMaterialRequestCommand(id, m.Note));

Yetki kontrolü bir öznitelikte, iş kuralı handler’da, hata biçimi ortak bir middleware’de. Controller’da if görmek, bir şeyin yanlış yere yazıldığının işaretidir. Bu düzenin somut faydası, aynı komutun başka bir yerden de gönderilebilmesi: süreç motoru gelen kutusundan verilen kararı doğrudan ApproveMaterialRequestCommand olarak gönderir ve talep ekranıyla birebir aynı mantık çalışır.

Handler: aggregate başına tek sınıf

Burada yaygın uygulamadan ayrılıyoruz. MediatR örneklerinin çoğu her komuta ayrı bir handler sınıfı açar; kırk komutu olan bir modülde kırk dosya, her birinde aynı bağımlılıklar ve aynı yardımcı metotlar. Biz aggregate başına tek handler sınıfı kullanıyoruz:

public class MaterialRequestCommandHandler(
        SupplyManagementDbContext db, ICurrentUser currentUser,
        WorkflowEngine workflow, INotificationService notifications) :
    IRequestHandler<CreateMaterialRequestCommand, MaterialRequestOutputModel>,
    IRequestHandler<ApproveMaterialRequestCommand, MaterialRequestOutputModel>,
    IRequestHandler<RejectMaterialRequestCommand, MaterialRequestOutputModel>,
    IRequestHandler<SubmitOfferCommand, MaterialRequestOutputModel>
    // ... talebin tüm komutları
{
    public async Task<MaterialRequestOutputModel> Handle(
        ApproveMaterialRequestCommand cmd, CancellationToken ct)
    {
        var entity = await LoadAsync(cmd.Id, ct);
        EnsureStatus(entity, RequestStatus.PendingApproval);
        SetDecision(entity, cmd.Note);
        entity.Status = RequestStatus.Approved;
        await workflow.AdvanceAsync(WorkflowEntityTypes.MaterialRequest, entity.Id,
            WorkflowActions.Approve, cmd.Note, ct);
        await db.SaveChangesAsync(ct);
        return Map(entity);
    }

    private static void EnsureStatus(MaterialRequest entity, params RequestStatus[] expected)
    {
        if (!expected.Contains(entity.Status))
            throw new BusinessException(ErrorCodes.InvalidStatusTransition, entity.Status.DisplayText);
    }
}

Gerekçe basit: bir talebin onayı, reddi, teklif gönderimi ve teslim alınması aynı varlığın aynı yaşam döngüsünün parçalarıdır. Aynı dosyada durduklarında EnsureStatus, SetDecision, Map gibi yardımcılar tek kopya kalır ve durum geçiş kuralları bir arada okunur. Sorgular ayrı bir sınıftadır (MaterialRequestQueryHandler); okuma tarafı yazma tarafının bağımlılıklarını taşımaz ve AsNoTracking gibi okuma optimizasyonlarını rahatça uygular.

Bu sınıfın uzun olması bir sorun değil, bir işarettir: yaklaşık bin iki yüz satır, çünkü akışın on iki komutu var. Bölmek istediğimiz gün, komut grupları doğal sınırı gösterecek.

Pipeline: doğrulama bir kez, her komut için

MediatR’ın en değerli özelliği IPipelineBehavior: her isteğin handler’a ulaşmadan önce geçtiği ara katman. Bizde tek bir davranış var ve FluentValidation’ı bağlıyor:

public 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 v in validators)
        {
            var result = await v.ValidateAsync(request, ct);
            if (!result.IsValid)
                throw new BusinessException(ErrorCodes.ValidationFailed,
                    string.Join(" | ", result.Errors.Select(e => e.ErrorMessage)));
        }
        return await next();
    }
}

// Program.cs
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);

Bir komuta validator yazmak, onu pipeline’a eklemek için yeterlidir; handler doğrulanmış veriyle çalışacağını bilir ve giriş kontrolü yazmaz. Aynı kalıpla loglama, yetki, işlem (transaction) ve performans ölçümü de eklenebilir. Biz bilinçli olarak bir tane tuttuk: her davranış her isteğin yoluna girer ve pipeline uzadıkça “bu istek neden yavaş” sorusunun cevabı bulanıklaşır.

Hata modeli: istisna, zarf, kod

Handler’lar iş kuralı ihlalinde BusinessException fırlatır; hata kodu bir sabittir, mesaj kullanıcıya gösterilir. Tek bir middleware bu istisnayı yakalar ve her yanıtın taşıdığı zarfa yazar: { success, data, errorCode, message }. İstemci, ister web ister mobil, tek bir sözleşmeyle konuşur ve hata kodu üzerinden çeviri yapabilir. “Geçersiz durum geçişi” hatası, hangi handler’dan gelirse gelsin aynı kodla döner.

Nerede durmalı?

CQRS’in cazibesi, her katmanı ikiye bölmeye davet etmesidir. Durduğumuz yerler:

  • Tek veritabanı. Okuma ve yazma modelleri aynı EF Core bağlamını kullanır; sorgular yalnızca projeksiyon ve AsNoTracking ile hafifler. Ayrı okuma deposu, raporlama yükü üretim tablolarını gerçekten zorlayınca gündeme gelir, önce değil.
  • Olay kaynaklama yok. Denetim izi ayrı bir tabloya yazılır; durum, varlığın üzerindedir. Geçmişi yeniden oynatma ihtiyacı doğmadan olay deposu kurmak, karmaşıklığı erkenden satın almaktır.
  • Bildirim yayınlama sınırlı. MediatR’ın INotification mekanizmasını yalnızca gerçekten bağımsız yan etkiler için (e-posta, push) kullanıyoruz. İş akışının kendisi, handler içinde açıkça çağrılan bir motorla ilerler; “kim tetikledi” sorusunun cevabı koddan okunur.
  • Aracı, mimari değil. MediatR bir kütüphanedir; handler’ları doğrudan çağırmak da mümkündür. Yarın kaldırılsa komutlar, handler’lar ve validator’lar aynen kalır, yalnızca Send çağrısı değişir.

MediatR 13 ve lisans

2025’te MediatR ticari bir ürüne dönüştü: 13.0 ve sonrası bir lisans anahtarı istiyor. Yıllık brüt geliri 5 milyon doların altında olan ve 10 milyon dolardan fazla dış sermaye almamış şirketler için ücretsiz Community lisansı var; kamu kurumları ve üniversiteler bunun dışında. Ücretli planlar on geliştiriciye kadar yıllık 799 dolardan başlıyor. Anahtar yoksa kütüphane çalışmaya devam ediyor, yalnızca günlüğe uyarı yazıyor.

Tedarik Yönetimi 12.x sürümünde, yani Apache lisanslı son nesilde. Küçük bir ekip için Community lisansı yeterli; kurumsal bir müşteriye teslim ederken ise lisans durumu teslimat kapsamının parçası olmalı. Bağımlılığın hafif tutulmasının bir sebebi de bu: ISender.Send çağrısını sarmalayan tek bir arayüz, gerekirse kütüphaneyi değiştirmeyi bir günlük iş yapar.

Özet

Bu düzenin bize verdiği şey, teknik bir kalıp değil bir disiplin: her iş kuralının adı var, tek bir yerde yaşıyor, doğrulaması ayrı, yetkisi ayrı, hatası standart. Yeni bir onay adımı eklemek, bir komut ve bir Handle metodu; yeni bir istemci eklemek, hiçbir iş kuralına dokunmadan yalnızca bir kapı. CQRS’in büyük resmine ihtiyaç duyulduğunda bu temel zaten hazır olur; duyulmadığında da fazladan bir şey taşımamış olursunuz.

Tuncay AydınKurucu · Yazılım mühendisi · Aydinfo

10+ yıldır bankacılık yazılımı geliştiriyor; .NET, React, Flutter ve yapay zekâ destekli geliştirme üzerine yazıyor.

Tüm yazılar

Bu konuyu projenizde konuşalım

30 dakikalık ücretsiz ön analiz görüşmesinde ihtiyacınızı dinleyip yol haritası önerelim.

Görüşme planla