Blog'a Dön
.NETClean ArchitectureCQRSMediatREnterprise

.NET 10'da MediatR ile Clean Architecture + CQRS

Umut Korkmaz2025-10-2514 min

Yıllarca kurumsal uygulamalar geliştirdikten sonra, defalarca başvurduğum bir kombinasyonda karar kıldım: MediatR ile güçlendirilmiş, CQRS'li Clean Architecture. Bu teorik bir mimari uzay yolculuğu değil — DigiFlow'da, 35 controller ve yaklaşık 200 endpoint ile binlerce kamu çalışanına hizmet veren bir uygulamada üretimde kullandığım bir desen.

Tam olarak nasıl yapılandırdığımı, neden belirli tercihler yaptığımı ve desenin nerede parladığını (ve nerede parlamadığını) adım adım anlatayım.

Kurumsal Uygulamalarda Neden CQRS?

CQRS — Command Query Responsibility Segregation (Komut Sorgu Sorumluluk Ayrımı) — akademik geliyor, ama temel fikir pratik: okumalar ve yazmaların farklı gereksinimleri vardır, dolayısıyla onları ayırın. DigiFlow'da okuma işlemlerimiz görüntüleme için optimize edilmiş Oracle view'larını sorgularken, yazma işlemlerimiz karmaşık doğrulamadan geçer, domain olayları tetikler ve normalize edilmiş tablolara yazar.

CQRS olmadan, GetDocument, GetDocumentSummary, CreateDocument, UpdateDocumentStatus, ApproveDocument gibi metotlara sahip servis sınıflarıyla baş başa kalırsınız — ve bunlar binlerce satıra büyür. CQRS ile her işlem, kendi handler'ına sahip kendi sınıfıdır. Tek Sorumluluk İlkesi'nin use-case seviyesinde uygulanması.

MediatR Pipeline'ı

MediatR 12.4.1 bize süreç içi (in-process) bir mesaj veri yolu sağlar. Her istek, handler'ına ulaşmadan önce bir davranışlar (behavior) pipeline'ından geçer:

csharp
// Program.cs - Pipeline kayıt sırası önemlidir
services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(ApplicationAssembly).Assembly);

    // Pipeline davranışları kayıt sırasına göre çalışır
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));
});

Her istek şu sırayla akar: Logging -> Validation -> Authorization -> Caching -> Transaction -> Handler. Bu pipeline, uygulamanın bel kemiğidir. Her bir davranışı ayrıntılı inceleyelim.

FluentValidation ile Validation Behavior

Her komut ve sorgunun ilişkili bir FluentValidation doğrulayıcısı olabilir. Validation behavior, istek tipi için tüm doğrulayıcıları toplar ve çalıştırır:

csharp
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
        => _validators = validators;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (!_validators.Any()) return await next();

        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, cancellationToken)));

        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);

        return await next();
    }
}

Doğrulayıcıların kendileri temiz ve okunabilir:

csharp
public class CreateDocumentCommandValidator
    : AbstractValidator<CreateDocumentCommand>
{
    public CreateDocumentCommandValidator()
    {
        RuleFor(x => x.Title)
            .NotEmpty().WithMessage("Document title is required")
            .MaximumLength(500).WithMessage("Title cannot exceed 500 characters");

        RuleFor(x => x.DepartmentId)
            .GreaterThan(0).WithMessage("Valid department is required");

        RuleFor(x => x.WorkflowTypeId)
            .Must(BeValidWorkflowType).WithMessage("Invalid workflow type");
    }

    private bool BeValidWorkflowType(int workflowTypeId)
        => Enumeration.GetAll<WorkflowType>()
            .Any(wt => wt.Id == workflowTypeId);
}

Caching Behavior

Sorgular için, bir işaretçi (marker) arayüzü ile Redis önbelleğini kullanıyoruz:

csharp
public interface ICacheable
{
    string CacheKey { get; }
    TimeSpan? CacheDuration { get; }
}

public class GetDepartmentListQuery : IRequest<List<DepartmentDto>>, ICacheable
{
    public string CacheKey => "departments:all";
    public TimeSpan? CacheDuration => TimeSpan.FromMinutes(30);
}

public class CachingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IDistributedCache _cache;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (request is not ICacheable cacheable)
            return await next();

        var cached = await _cache.GetStringAsync(
            cacheable.CacheKey, cancellationToken);

        if (cached is not null)
            return JsonSerializer.Deserialize<TResponse>(cached)!;

        var response = await next();

        await _cache.SetStringAsync(
            cacheable.CacheKey,
            JsonSerializer.Serialize(response),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow =
                    cacheable.CacheDuration ?? TimeSpan.FromMinutes(5)
            },
            cancellationToken);

        return response;
    }
}

Komutlar ve Sorgular: Gerçek Bir Örnek

İşte tam bir özelliğin nasıl göründüğü. Bir kullanıcı, bir iş akışındaki bir dokümanı onaylamak istiyor:

csharp
// Komut - niyeti temsil eder
public record ApproveDocumentCommand(
    int DocumentId,
    string Comments,
    int NextStepId) : IRequest<ApprovalResultDto>;

// Handler - iş mantığını içerir
public class ApproveDocumentCommandHandler
    : IRequestHandler<ApproveDocumentCommand, ApprovalResultDto>
{
    private readonly IWorkflowRepository _workflowRepo;
    private readonly ICurrentUserService _currentUser;
    private readonly IMediator _mediator;

    public async Task<ApprovalResultDto> Handle(
        ApproveDocumentCommand request, CancellationToken ct)
    {
        var workflow = await _workflowRepo
            .GetActiveWorkflowAsync(request.DocumentId, ct);

        if (workflow.CurrentApprover.UserId != _currentUser.UserId)
            throw new ForbiddenAccessException("Not the current approver");

        workflow.Approve(request.Comments, request.NextStepId);

        await _workflowRepo.UpdateAsync(workflow, ct);

        // Yan etkiler için domain olayı yayınla
        await _mediator.Publish(
            new DocumentApprovedEvent(workflow.DocumentId, workflow.CurrentStep),
            ct);

        return new ApprovalResultDto(workflow.Status, workflow.CurrentStep);
    }
}

Aynı dokümanı okumaya yönelik karşılık gelen sorgu tamamen ayrıdır:

csharp
public record GetDocumentDetailQuery(int DocumentId)
    : IRequest<DocumentDetailDto>;

public class GetDocumentDetailQueryHandler
    : IRequestHandler<GetDocumentDetailQuery, DocumentDetailDto>
{
    private readonly IOracleConnectionFactory _db;

    public async Task<DocumentDetailDto> Handle(
        GetDocumentDetailQuery request, CancellationToken ct)
    {
        using var conn = _db.Create();

        // Görüntüleme için optimize edilmiş, denormalize bir Oracle view'ından oku
        return await conn.QueryFirstOrDefaultAsync<DocumentDetailDto>(
            "SELECT * FROM VW_DOCUMENT_DETAIL WHERE DOC_ID = :docId",
            new { docId = request.DocumentId });
    }
}

Komut, domain modeli aracılığıyla normalize edilmiş tablolara yazar. Sorgu, Dapper aracılığıyla denormalize bir view'dan okur. Her biri kendi amacı için optimize edilmiştir.

Controller Katmanı

Controller'lar ince dağıtıcılara dönüşür:

csharp
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
public class DocumentsController : ControllerBase
{
    private readonly IMediator _mediator;

    [HttpGet("{id}")]
    public async Task<ActionResult<DocumentDetailDto>> Get(int id)
        => Ok(await _mediator.Send(new GetDocumentDetailQuery(id)));

    [HttpPost("{id}/approve")]
    public async Task<ActionResult<ApprovalResultDto>> Approve(
        int id, [FromBody] ApproveDocumentRequest request)
        => Ok(await _mediator.Send(new ApproveDocumentCommand(
            id, request.Comments, request.NextStepId)));
}

Controller'larda iş mantığı yok. MediatR dışında servis enjeksiyonu yok. Her controller eylemi tek satır.

Bu Desenin Zorlandığı Yerler

Bunun kusursuz olduğunu iddia edecek değilim. 200 endpoint'ten sonra, işin sancılı hale geldiği yerler şunlar:

  1. Dosya sayısı patlaması. Her özellik bir command/query, handler, validator ve DTO gerektirir. Bu, endpoint başına 4-8 dosya demektir. Klasör-başına-özellik mantığıyla vertical slice mimarisi yardımcı oluyor, ama yine de çok sayıda dosya.
  2. Basit CRUD aşırı mühendislik gibi hissettirir. 5 alanlı temel bir referans tablosu için, tam CQRS pipeline'ı abartıdır. Basit referans verisi endpoint'leri için istisnalara izin veriyorum.
  3. Hata ayıklama dolaylı olabilir. Bir pipeline davranışında bir şey başarısız olduğunda, stack trace akışı her zaman net göstermez. Her davranışta iyi loglama şarttır.

Bu ödünleşmelere rağmen desen ölçeklenir. Ekibe yeni bir geliştirici katıldığında, deseni bir kez öğrenir ve aynı yapıyı izleyerek herhangi bir özelliği uygulayabilir. 200 endpoint genelinde tutarlılık, dosya sayısına değer.

Temel Çıkarımlar

  • Okuma ve yazmalarınızın gerçekten farklı gereksinimleri olduğunda CQRS kullanın
  • MediatR pipeline davranışları, aksi takdirde handler'larınızı dağıtacak çapraz kesen ilgileri (cross-cutting concerns) yerine geçer
  • Pipeline'da FluentValidation, handler'ların girdilerine güvenebilmesi demektir
  • Controller'ları ince dağıtıcılar olarak tutun — eylem başına bir satır
  • Ödünleşmeleri kabul edin: daha fazla dosya, ama her dosyanın tek bir sorumluluğu var