CQRS'i Aşırıya Kaçmadan Uygulamak
CQRS'i Aşırıya Kaçmadan Uygulamak
CQRS Nedir ve Neden Yanlış Anlaşılır?
CQRS — Command Query Responsibility Segregation — okuma ve yazma operasyonlarını birbirinden ayıran bir yazılım desenidir. Greg Young tarafından 2010'da formüle edilmiş, o günden bu yana yazılım dünyasında hem çok doğru hem çok yanlış uygulanmıştır.
Yanlış anlaşılmanın temel nedeni şudur: CQRS, Event Sourcing, ayrı veritabanları, mesaj kuyrukları ve mikroservislerle birlikte anlatılır. Bunları görünce çoğu geliştirici şöyle düşünür — "Bu mimari bizim için fazla karmaşık" — ve deseni tamamen reddeder.
Oysa CQRS özünde son derece basit bir fikirdir.
Komutlar (Commands): Sistemi değiştirirler. Veri döndürmezler ya da sadece sonuç kodu döndürürler.
Sorgular (Queries): Sistemi değiştirmezler. Sadece veri döndürürler.
Bu ayrımı yapmak için ayrı veritabanına, event sourcing'e veya mesaj kuyruğuna ihtiyacınız yoktur. Sınıflarınızı ve sorumluluklarınızı doğru organize etmeniz yeterlidir.
Hangi Acıyı Çözüyor?
Klasik repository pattern ile büyüyen bir projede şu tablo kaçınılmazdır:
public interface IOrderRepository
{
Task<Order> GetByIdAsync(Guid id);
Task<List<Order>> GetByCustomerIdAsync(Guid customerId);
Task<List<Order>> GetActiveOrdersAsync();
Task<List<OrderSummaryDto>> GetOrderSummaryForDashboardAsync(DateTime start, DateTime end);
Task<List<OrderDetailDto>> GetOrdersWithItemsAndPaymentsAsync(Guid customerId);
Task<PagedResult<Order>> GetPagedAsync(int page, int size, string? search);
Task AddAsync(Order order);
Task UpdateAsync(Order order);
Task DeleteAsync(Guid id);
Task UpdateStatusAsync(Guid id, OrderStatus status);
Task BulkUpdateStatusAsync(List<Guid> ids, OrderStatus status);
}
Bu interface'i gören biri ne düşünür? "Bu sınıf çok fazla şey yapıyor." Doğru. Hem domain operasyonlarını hem raporlama sorgularını hem CRUD'u hem de toplu işlemleri barındırıyor.
CQRS bu sorunu şöyle çözer — her işlemin kendi sınıfı, kendi handler'ı, kendi sorumluluğu olur.
MediatR ile Temel Kurulum
NuGet paketi:
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
Program.cs kaydı:
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
Command Tarafı — Yazmak
Command ve Handler Yapısı
// Command — ne yapmak istiyoruz?
public record CreateOrderCommand(
Guid CustomerId,
List<OrderItemDto> Items,
string DeliveryAddress
) : IRequest<Guid>;
// Handler — nasıl yapıyoruz?
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
private readonly AppDbContext _context;
public CreateOrderCommandHandler(AppDbContext context)
{
_context = context;
}
public async Task<Guid> Handle(CreateOrderCommand command, CancellationToken ct)
{
var order = Order.Create(
command.CustomerId,
command.DeliveryAddress);
foreach (var item in command.Items)
{
var product = await _context.Products.FindAsync(item.ProductId, ct)
?? throw new NotFoundException(nameof(Product), item.ProductId);
order.AddItem(product, item.Quantity);
}
_context.Orders.Add(order);
await _context.SaveChangesAsync(ct);
return order.Id;
}
}
Controller'da kullanım:
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<IActionResult> Create(
CreateOrderCommand command,
CancellationToken ct)
{
var orderId = await _mediator.Send(command, ct);
return CreatedAtAction(nameof(GetById), new { id = orderId }, null);
}
}
Sonuç Döndürmeyen Command
Bazı komutlar sadece bir şey yapar, sonuç döndürmez:
// Unit — MediatR'ın void karşılığı
public record CancelOrderCommand(Guid OrderId, string Reason) : IRequest<Unit>;
public class CancelOrderCommandHandler : IRequestHandler<CancelOrderCommand, Unit>
{
private readonly AppDbContext _context;
public CancelOrderCommandHandler(AppDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(CancelOrderCommand command, CancellationToken ct)
{
var order = await _context.Orders.FindAsync(command.OrderId, ct)
?? throw new NotFoundException(nameof(Order), command.OrderId);
order.Cancel(command.Reason);
await _context.SaveChangesAsync(ct);
return Unit.Value;
}
}
Query Tarafı — Okumak
Query tarafında en önemli karar şudur: domain entity'lerini mi döndürürsünüz, DTO'ları mı?
Her zaman DTO döndürün. Query'ler sistemi değiştirmez, dolayısıyla domain nesnelerine gerek yoktur. Doğrudan ekrana ne gidecekse onu üretin — bu hem performansı artırır hem de gereksiz veri transferini engeller.
Basit Query
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDetailDto>;
public class GetOrderByIdQueryHandler
: IRequestHandler<GetOrderByIdQuery, OrderDetailDto>
{
private readonly AppDbContext _context;
public GetOrderByIdQueryHandler(AppDbContext context)
{
_context = context;
}
public async Task<OrderDetailDto> Handle(
GetOrderByIdQuery query,
CancellationToken ct)
{
var order = await _context.Orders
.AsNoTracking()
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.Where(o => o.Id == query.OrderId)
.Select(o => new OrderDetailDto(
o.Id,
o.Status.ToString(),
o.TotalAmount,
o.CreatedAt,
o.Items.Select(i => new OrderItemDto(
i.Product.Name,
i.Quantity,
i.UnitPrice)).ToList()))
.FirstOrDefaultAsync(ct)
?? throw new NotFoundException(nameof(Order), query.OrderId);
return order;
}
}
Dapper ile Karmaşık Query
Raporlama ve analitik sorgular için query tarafında Dapper kullanmak çok doğaldır — command tarafı EF Core'la, query tarafı Dapper'la çalışabilir:
public record GetOrderDashboardQuery(
DateTime StartDate,
DateTime EndDate,
int Page,
int PageSize
) : IRequest<PagedResult<OrderDashboardDto>>;
public class GetOrderDashboardQueryHandler
: IRequestHandler<GetOrderDashboardQuery, PagedResult<OrderDashboardDto>>
{
private readonly IDbConnection _connection;
public GetOrderDashboardQueryHandler(IDbConnection connection)
{
_connection = connection;
}
public async Task<PagedResult<OrderDashboardDto>> Handle(
GetOrderDashboardQuery query,
CancellationToken ct)
{
var sql = @"
SELECT
o.OrderId,
c.Name AS CustomerName,
o.TotalAmount,
o.Status,
o.CreatedAt,
COUNT(oi.ItemId) AS ItemCount,
SUM(oi.Quantity) AS TotalQuantity
FROM Orders o
INNER JOIN Customers c ON c.CustomerId = o.CustomerId
INNER JOIN OrderItems oi ON oi.OrderId = o.OrderId
WHERE o.CreatedAt BETWEEN @StartDate AND @EndDate
GROUP BY o.OrderId, c.Name, o.TotalAmount, o.Status, o.CreatedAt
ORDER BY o.CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;
SELECT COUNT(DISTINCT o.OrderId)
FROM Orders o
WHERE o.CreatedAt BETWEEN @StartDate AND @EndDate;";
using var multi = await _connection.QueryMultipleAsync(
sql,
new
{
query.StartDate,
query.EndDate,
Offset = (query.Page - 1) * query.PageSize,
query.PageSize
});
var items = (await multi.ReadAsync<OrderDashboardDto>()).ToList();
var totalCount = await multi.ReadSingleAsync<int>();
return new PagedResult<OrderDashboardDto>(items, totalCount, query.Page, query.PageSize);
}
}
Pipeline Behavior — Kesişen Sorumluluklar
CQRS ile MediatR'ın en güçlü tarafı Pipeline Behavior'lardır. Validation, logging, exception handling ve caching gibi cross-cutting concern'leri her handler'a tekrar yazmak yerine pipeline'a bir kez eklersiniz.
Validation Pipeline
// FluentValidation
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
// Command için validator
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty().WithMessage("Müşteri ID boş olamaz.");
RuleFor(x => x.Items)
.NotEmpty().WithMessage("Sipariş en az bir ürün içermelidir.");
RuleFor(x => x.DeliveryAddress)
.NotEmpty().WithMessage("Teslimat adresi zorunludur.")
.MaximumLength(500);
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(x => x.ProductId).NotEmpty();
item.RuleFor(x => x.Quantity).GreaterThan(0);
});
}
}
// Pipeline behavior
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 ct)
{
if (!_validators.Any()) return await next();
var context = new ValidationContext<TRequest>(request);
var failures = _validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.Where(e => e != null)
.ToList();
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
}
Logging Pipeline
public class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var requestName = typeof(TRequest).Name;
_logger.LogInformation("İşlem başladı: {RequestName}", requestName);
var stopwatch = Stopwatch.StartNew();
var response = await next();
stopwatch.Stop();
if (stopwatch.ElapsedMilliseconds > 500)
{
_logger.LogWarning(
"Yavaş işlem tespit edildi: {RequestName} — {ElapsedMs}ms",
requestName,
stopwatch.ElapsedMilliseconds);
}
else
{
_logger.LogInformation(
"İşlem tamamlandı: {RequestName} — {ElapsedMs}ms",
requestName,
stopwatch.ElapsedMilliseconds);
}
return response;
}
}
Pipeline Kayıtları
// Program.cs
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
// Pipeline behavior'ları sırayla kaydet
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
// FluentValidation validator'larını kaydet
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
Klasör Yapısı — Organize Etmek
CQRS uygulandığında proje yapısının da buna uygun organize edilmesi okunabilirliği artırır:
src/
├── Application/
│ ├── Orders/
│ │ ├── Commands/
│ │ │ ├── CreateOrder/
│ │ │ │ ├── CreateOrderCommand.cs
│ │ │ │ ├── CreateOrderCommandHandler.cs
│ │ │ │ └── CreateOrderCommandValidator.cs
│ │ │ ├── CancelOrder/
│ │ │ │ ├── CancelOrderCommand.cs
│ │ │ │ └── CancelOrderCommandHandler.cs
│ │ │ └── UpdateOrderStatus/
│ │ │ ├── UpdateOrderStatusCommand.cs
│ │ │ └── UpdateOrderStatusCommandHandler.cs
│ │ ├── Queries/
│ │ │ ├── GetOrderById/
│ │ │ │ ├── GetOrderByIdQuery.cs
│ │ │ │ ├── GetOrderByIdQueryHandler.cs
│ │ │ │ └── OrderDetailDto.cs
│ │ │ └── GetOrderDashboard/
│ │ │ ├── GetOrderDashboardQuery.cs
│ │ │ ├── GetOrderDashboardQueryHandler.cs
│ │ │ └── OrderDashboardDto.cs
│ │ └── Common/
│ │ └── OrderItemDto.cs
│ ├── Behaviors/
│ │ ├── LoggingBehavior.cs
│ │ └── ValidationBehavior.cs
│ └── Common/
│ └── PagedResult.cs
Her feature kendi klasöründe yaşar. Yeni bir geliştirici projeye girdiğinde "Sipariş oluşturma nerede?" sorusunun cevabı açıktır.
Ne Zaman Overkill Olur?
CQRS her proje için doğru seçim değildir. Aşağıdaki durumlarda ekstra karmaşıklık getirip değer üretmez:
Küçük CRUD uygulamaları:
5 entity'li, basit bir yönetim paneli. Her entity için ayrı command ve query sınıfı yazmak, tek bir service sınıfından daha fazla dosya ve daha az netlik anlamına gelir.
Prototip ve MVP:
Hızlı doğrulama aşamasındaki bir proje. Mimari doğruluk değil, hız önceliklidir. Sonra refactor edilebilir.
Takım CQRS'i bilmiyor:
Deseni bilmeyen bir takıma zorla uygulatmak teknik borç yaratır, çözmez. Önce kavramı paylaşın, sonra uygulayın.
Sinyaller — CQRS'e geçme zamanı gelmiş:
⚠️ Service sınıfları 500+ satır oldu
⚠️ Aynı entity için onlarca farklı "GetBy..." metodu var
⚠️ Read ve write modelleri giderek ayrışıyor
⚠️ Validation mantığı her yere dağıldı
⚠️ Unit test yazmak giderek zorlaşıyor
Sık Yapılan Hatalar
1. Her şeyi Command yapmak
// ❌ Bu bir query, command değil
public record GetUserByIdCommand(Guid UserId) : IRequest<UserDto>;
// ✅ Doğrusu
public record GetUserByIdQuery(Guid UserId) : IRequest<UserDto>;
Komutlar sistemi değiştirir. Sorgular sadece okur. Bu ayrımı dil düzeyinde de koruyun.
2. Handler içinde başka handler çağırmak
// ❌ Handler içinden mediator çağrısı — spaghetti başlangıcı
public async Task<Guid> Handle(CreateOrderCommand command, CancellationToken ct)
{
var customer = await _mediator.Send(new GetCustomerByIdQuery(command.CustomerId));
// ...
}
// ✅ Doğrudan repository veya DbContext kullanın
public async Task<Guid> Handle(CreateOrderCommand command, CancellationToken ct)
{
var customer = await _context.Customers.FindAsync(command.CustomerId, ct);
// ...
}
3. Domain logic'i handler'a taşımak
// ❌ İş kuralı handler'da — test edilemez, tekrar kullanılamaz
public async Task<Unit> Handle(CancelOrderCommand command, CancellationToken ct)
{
var order = await _context.Orders.FindAsync(command.OrderId, ct);
if (order.Status == OrderStatus.Shipped)
throw new InvalidOperationException("Kargoya verilmiş sipariş iptal edilemez.");
if (order.Status == OrderStatus.Cancelled)
throw new InvalidOperationException("Sipariş zaten iptal edilmiş.");
order.Status = OrderStatus.Cancelled;
order.CancelledAt = DateTime.UtcNow;
order.CancelReason = command.Reason;
await _context.SaveChangesAsync(ct);
return Unit.Value;
}
// ✅ İş kuralı domain entity'de — test edilebilir, tutarlı
public async Task<Unit> Handle(CancelOrderCommand command, CancellationToken ct)
{
var order = await _context.Orders.FindAsync(command.OrderId, ct)
?? throw new NotFoundException(nameof(Order), command.OrderId);
order.Cancel(command.Reason); // Domain logic burada
await _context.SaveChangesAsync(ct);
return Unit.Value;
}
// Order entity
public class Order
{
public void Cancel(string reason)
{
if (Status == OrderStatus.Shipped)
throw new DomainException("Kargoya verilmiş sipariş iptal edilemez.");
if (Status == OrderStatus.Cancelled)
throw new DomainException("Sipariş zaten iptal edilmiş.");
Status = OrderStatus.Cancelled;
CancelledAt = DateTime.UtcNow;
CancelReason = reason;
}
}
Temel Çıkarımlar
- CQRS özünde basit bir fikirdir — okuma ve yazmayı ayırın
- Event sourcing, ayrı veritabanı, mesaj kuyruğu zorunlu değildir
- MediatR ile uygulayın ama MediatR olmadan da uygulanabilir
- Pipeline behavior'lar validation ve logging için güçlü bir araçtır
- Query tarafında DTO döndürün, domain entity değil
- Domain logic handler'da değil, entity veya domain service'te yaşamalıdır
- Küçük projeler ve MVP'lerde overkill olabilir — önce acıyı hissedin, sonra uygulayın
CQRS bir mimari devrim değildir. Sorumlulukları net çizgilerle ayıran, zamanla büyüyen projelerde düzeni koruyan bir organizasyon desenidir. Ne zaman işe yarayacağını bilmek, nasıl uygulanacağını bilmek kadar önemlidir.