MediatR’a Gerçekten İhtiyacımız Var mı? .NET’te Kendi Mini CQRS Pipeline’ımızı Yazalım
.NET tarafında CQRS kullanan çoğu geliştiricinin yolu bir noktada MediatR ile kesişiyor. Bende projelerimde bol bol kullandım, kullanmaya da devam ediyorum. Burada kullandığım yapı ise aşağı yukarı şöyleydi:
Controller
|
v
MediatR
|
v
Command / Query
|
v
Handler
|
v
Database
Controller sade kalıyor, iş mantığı handler içinde tutuluyor, command ve query birbirinden ayrılıyor. Üzerine validation, logging, transaction gibi konuları da pipeline behavior üzerinden geçirince ortaya oldukça temiz ve basit bir yapı çıkıyor. Buraya kadar bir problem yok.. Ama bir süre sonra özellikle daha küçük servislerde veya nispeten basit projelerde iş uzamaya başlar gibi olunca kendime şu soruyu sormaya başladım:
Ben burada gerçekten MediatR'ın bütün özelliklerine mi ihtiyaç duyuyorum, yoksa sadece bir request'i doğru handler'a göndermek için mi kullanıyorum?
Bu soru gerçekten önemli. Çünkü bazı projelerde kullandığımız yapı düşündüğümüzden çok daha basit, ama biz belli bir şablona uyalım derken konuyu uzattıkça uzatıyoruz. Proje 2 saatte bitecekken günümüzü yemeye başlıyor. Önce bir request oluşturuyoruz, peşine handler yazıyoruz, dependency injection üzerinden de bu handler bulunuyor ve çalıştırılıyor. Temel senaryoda olan şey aslında bundan ibaret, çok büyük büyük olaylarda yok gibi kabaca baktığımızda.
O zaman biraz geriye çekilip şu soruya bakmak lazım: CQRS kullanmak için gerçekten MediatR'a ihtiyacımız var mı?
Önce CQRS ile MediatR'ı Ayıralım
Burada en sık karıştırılan nokta bu;
- CQRS bir mimari yaklaşım.
- MediatR ise mediator pattern uygulamasını kolaylaştıran bir .NET kütüphanesi.
İkisi aynı şey değil, birbirini tamamlıyorlar, tamam, ama aynı şey değiller.
CQRS, yani Command Query Responsibility Segregation, temel olarak okuma ve yazma operasyonlarını birbirinden ayırmayı öneriyor.
Bir Command sistemde bir değişiklik yapar. Örneğin:
CreateOrderCommand
CancelOrderCommand
UpdateCustomerCommand
Bir Query ise mevcut veriyi okur.
GetOrderQuery
GetCustomerQuery
GetPortfolioQuery
Bu ayrım ilk bakışta sadece isimlendirme gibi görünebilir, ama uygulama büyüdükçe etkisi ortaya çıkıyor.
- Bir işlem veri mi değiştiriyor, yoksa sadece veri mi okuyor?
- Transaction gerekli mi?
- Cache uygulanabilir mi?
- Validation nerede çalışacak?
- Yetki kontrolü gerekli mi?
Command ve query ayrımı bu soruların cevaplarını daha net hale getirebiliyor. Fakat bütün bunları yapmak için MediatR kullanmak zorunda değiliz. Hatta mediator pattern kullanmak için bile MediatR zorunlu değil.
Basit Bir CQRS Yapısı Kuralım
Önce command ve query için iki basit interface tanımlayalım.
public interface ICommand<TResponse>
{
}
public interface IQuery<TResponse>
{
}
Ardından bunların handler'larını oluşturalım.
public interface ICommandHandler<TCommand, TResponse>
where TCommand : ICommand<TResponse>
{
Task<TResponse> Handle(
TCommand command,
CancellationToken cancellationToken);
}
Query tarafı da benzer:
public interface IQueryHandler<TQuery, TResponse>
where TQuery : IQuery<TResponse>
{
Task<TResponse> Handle(
TQuery query,
CancellationToken cancellationToken);
}
Şimdi gerçek bir örnek üzerinden ilerleyelim; "Bir sipariş oluşturduğumuzu düşünelim."
public record CreateOrderCommand(
int CustomerId,
decimal Amount) : ICommand<int>;
Bu command'in handler'ı da şöyle olabilir:
public sealed class CreateOrderCommandHandler
: ICommandHandler<CreateOrderCommand, int>
{
private readonly AppDbContext _dbContext;
public CreateOrderCommandHandler(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<int> Handle(
CreateOrderCommand command,
CancellationToken cancellationToken)
{
var order = new Order
{
CustomerId = command.CustomerId,
Amount = command.Amount
};
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync(cancellationToken);
return order.Id;
}
}
Buraya kadar baktığımızda aslında CQRS yapısını kurmuş durumdayız. Command var. Handler var. İş mantığı ilgili handler içinde. Eksik olan tek şey bu handler'ı çağıracak mekanizma.
Kendi Mini Dispatcher'ımızı Yazalım
Bunun için küçük bir dispatcher yeterli olabilir.
Örneğin:
public interface ICommandDispatcher
{
Task<TResponse> Send<TResponse>(
ICommand<TResponse> command,
CancellationToken cancellationToken = default);
}
Basit bir implementasyon:
public sealed class CommandDispatcher : ICommandDispatcher
{
private readonly IServiceProvider _serviceProvider;
public CommandDispatcher(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task<TResponse> Send<TResponse>(
ICommand<TResponse> command,
CancellationToken cancellationToken = default)
{
var handlerType = typeof(ICommandHandler<,>)
.MakeGenericType(command.GetType(), typeof(TResponse));
dynamic handler =
_serviceProvider.GetRequiredService(handlerType);
return await handler.Handle(
(dynamic)command,
cancellationToken);
}
}
Bunu dependency injection tarafında kaydettikten sonra controller içinde kullanabiliriz.
[HttpPost]
public async Task<IActionResult> Create(
CreateOrderCommand command,
CancellationToken cancellationToken)
{
var orderId = await _commandDispatcher.Send(
command,
cancellationToken);
return Ok(orderId);
}
Ortaya çıkan kullanım şekli oldukça tanıdık:
await _commandDispatcher.Send(command);
Burada yaptığımız şey özünde küçük bir mediator oluşturmak. Request'in tipine bakıyoruz, ilgili handler'ı dependency injection container üzerinden buluyoruz ve çalıştırıyoruz.
Bu noktada doğal olarak şu soru geliyor: MediatR'ın yaptığı şey sadece bu mu? Hayır.
Production tarafında ihtiyaç duyulabilecek birçok detay var. Ama özellikle küçük projelerde bizim MediatR üzerinden kullandığımız temel davranış bazen gerçekten buna yakın olabiliyor.
Bence Asıl Değer Pipeline Tarafında
CQRS tarafında command ve query ayırmak güzel. Ama benim daha değerli bulduğum konu pipeline yaklaşımı. Çünkü gerçek projelerde handler'lar zaman içinde yalnızca iş mantığı taşımamaya başlıyor.
Örneğin bir handler'ın içinde şöyle bir yapı görmeye başlayabiliyoruz:
_logger.LogInformation("CreateOrder started.");
await _validator.ValidateAndThrowAsync(command);
await _transaction.BeginAsync();
try
{
// Business logic
await _transaction.CommitAsync();
}
catch
{
await _transaction.RollbackAsync();
throw;
}
İlk handler'da çok problem görünmüyor. Sonra ikinci handler geliyor. Sonra üçüncü.
Bir süre sonra aynı logging, validation ve transaction kodlarını onlarca yerde tekrar etmeye başlıyoruz. Ve asıl problem burada ortaya çıkıyor.
Çünkü bunların hiçbiri aslında CreateOrderCommandHandler sınıfının temel sorumluluğu değil.
Handler'ın sipariş oluşturması gerekiyor, loglama yapması değil, transaction altyapısını yönetmesi değil, işlemin ne kadar sürdüğünü ölçmesi hiç değil. Bu konuları request pipeline içine aldığımızda handler çok daha sade kalıyor.
Pipeline Behavior Nasıl Çalışıyor?
Basit bir pipeline abstraction'ı şöyle olabilir:
public interface IPipelineBehavior<TRequest, TResponse>
{
Task<TResponse> Handle(
TRequest request,
CancellationToken cancellationToken,
Func<Task<TResponse>> next);
}
Mantık middleware yapısına oldukça benziyor. Her behavior işlemden önce veya sonra bir şey yapabiliyor ve ardından zincirdeki bir sonraki adıma geçiyor.
Örneğin bir logging behavior:
public sealed class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
public LoggingBehavior(
ILogger<LoggingBehavior<TRequest, TResponse>> logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
CancellationToken cancellationToken,
Func<Task<TResponse>> next)
{
_logger.LogInformation(
"Handling {RequestName}",
typeof(TRequest).Name);
var response = await next();
_logger.LogInformation(
"Handled {RequestName}",
typeof(TRequest).Name);
return response;
}
}
Benzer şekilde farklı davranışlar eklenebilir:
ValidationBehavior
AuthorizationBehavior
LoggingBehavior
TransactionBehavior
PerformanceBehavior
Request'in işlenme sırası da örneğin şöyle olabilir:
Request
|
v
Authorization
|
v
Validation
|
v
Logging
|
v
Transaction
|
v
Handler
Bence mediator yaklaşımının en önemli avantajlarından biri bu. Cross-cutting concern dediğimiz, uygulamanın farklı yerlerinde tekrar eden ortak davranışları business logic'ten ayırabiliyoruz.
Handler Gerçekten Sadece İşini Yapmalı
İdeal olarak benim görmek istediğim handler aşağı yukarı şöyle:
public async Task<int> Handle(
CreateOrderCommand command,
CancellationToken cancellationToken)
{
var order = new Order
{
CustomerId = command.CustomerId,
Amount = command.Amount
};
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync(cancellationToken);
return order.Id;
}
Bu handler'ın şunları bilmesine gerek yok:
- Request loglandı mı?
- Validation çalıştı mı?
- Kullanıcının yetkisi kontrol edildi mi?
- İşlem kaç milisaniye sürdü?
- Transaction açıldı mı?
- Exception nasıl loglanacak?
Bunların hepsi başka katmanların veya pipeline davranışlarının sorumluluğu olabilir. Bu ayrım özellikle proje büyüdükçe ciddi fark yaratıyor.
O Zaman Neden Her Şeyi Kendimiz Yazmıyoruz?
Buraya kadar gelince şu fikir oluşabilir:
Madem bu kadar basit, MediatR kullanmayalım ve kendi sistemimizi yazalım.
Ben burada biraz daha temkinli olmakta fayda olduğunu düşünüyorum. Bir şeyi az kodla yazabiliyor olmamız, onu mutlaka kendimiz yazmamız gerektiği anlamına gelmiyor. Çünkü production seviyesinde bir kütüphane yalnızca ekranda gördüğümüz birkaç interface'ten oluşmuyor. Arka tarafta düşünülmesi gereken başka konular var:
- Generic type resolution
- Dependency injection entegrasyonu
- Pipeline sıralaması
- Exception handling
- CancellationToken propagation
- Notification mekanizmaları
- Async davranışlar
- Performans
- Edge case'ler
- Test kapsamı
- Dokümantasyon
Bence burada sorulması gereken soru şu değil: Bunu yazabilir miyim? Bir .NET geliştiricisi detaylı düşündüğünde büyük ihtimalle yazabilir, bunda bir sorun yok. Ama asıl soru şu: Bunu yıllarca maintain etmek istiyor muyum? Aradaki fark önemli. Bir kütüphane kullandığımızda yalnızca kod kullanmıyoruz. Aynı zamanda daha önce karşılaşılmış birçok sorunun çözümünden de faydalanıyoruz, bizim o anda veya sonrasında yaşayacağımız sorunu birileri yaşayıp, çoktan gidermiş oluyor.
Küçük Bir Serviste CQRS Fazla Gelebilir
Ben mimari kararların proje büyüklüğünden bağımsız verilmesini çok doğru bulmuyorum, en büyük sorunları da burada yaşıyoruz zaten. Örneğin toplam 5-10 endpoint'i olan basit bir internal API düşünelim. Şöyle bir yapı gayet yeterli olabilir:
Controller
|
v
Service
|
v
Repository
Kod okunabiliyor. Sorumluluklar belli. Test etmek kolay. Ekip yapıyı rahat anlayabiliyor. Böyle bir projeye sırf "daha clean olsun" diye şunları eklemek her zaman fayda sağlamıyor:
CreateOrderCommand
CreateOrderCommandHandler
CreateOrderCommandValidator
TransactionBehavior
LoggingBehavior
CommandDispatcher
OrderRepository
Basit bir işlem için 6-7 dosya açmaya başladığımızda mimarinin bize ne kazandırdığını tekrar sorgulamak gerekiyor. Ben burada kendime genellikle şu soruyu soruyorum:
Bu abstraction bugün yaşadığım bir problemi mi çözüyor, yoksa gelecekte oluşabileceğini düşündüğüm bir problem için mi var?
İkinci seçenek çok baskınsa erken abstraction yapıyor olabiliriz.
Büyük Projede Tablo Değişiyor
Uygulama büyüdüğünde durum farklı. Düşünün ki onlarca modülü ve yüzlerce endpoint'i olan bir backend uygulaması var. Böyle bir durumda her request için bazı ortak ihtiyaçlar ortaya çıkıyor:
- Validation
- Authorization
- Audit log
- Transaction
- Performance monitoring
- Logging
- Cache
- Retry
Bunların her endpoint içinde ayrı ayrı yönetilmesi bir süre sonra ciddi karmaşa oluşturur. İşte o zaman burada CQRS ve mediator pipeline yaklaşımı anlam kazanmaya başlıyor. Yeni bir developer projeye katıldığında standart bir request lifecycle görüyorsa sistemi anlaması da kolaylaşıyor.
Örneğin bütün command'ler şu yapıdan geçiyorsa:
Command
|
v
Authorization
|
v
Validation
|
v
Transaction
|
v
Handler
Bir süre sonra ekipte ortak bir çalışma şekli oluşuyor. Bu da sadece kod temizliği değil, ekip ölçeğinde bir standart sağlıyor.
Kendi CQRS Kütüphanemizi Yazmak Mantıklı mı?
Burada bence en ilginç senaryo şirket içi ortak kütüphanelerde yaşanıyor. Bir organizasyonda 20-30 microservice olduğunu düşünelim. Her servis içinde de tekrar tekrar şu yapılar yazılıyor:
ICommand
IQuery
ICommandHandler
IQueryHandler
ValidationBehavior
TransactionBehavior
LoggingBehavior
Bu noktada küçük bir internal NuGet package oluşturmak mantıklı olabilir. Örneğin:
Company.Application.CQRS
Bu paketin amacı yeni bir framework üretmek değil, amaç şirkette tekrar eden mimari kararları standartlaştırmak. Bence önemli fark burada. İki proje arasında kopyala-yapıştır yaptığımız kod üçüncü projede de karşımıza çıkıyorsa artık ortaklaştırmayı düşünmek mantıklı hale geliyor.
Internal Framework Yazmanın Tehlikesi
Burada başka bir problem başlıyor. Kendi kütüphanenizi yazmak çok masum başlayabiliyor. İlk sürüm:
ICommand
IQuery
Dispatcher
Bir süre sonra ihtiyaç geliyor:
Pipeline
Sonra:
Notification
Sonra biri retry desteği istiyor, bir başkası distributed transaction eklemek istiyor. Sonra cache desteği geliyor, o onu bu bunu istiyor derkeeennn sadece birkaç interface ile başlayan internal library yavaş yavaş genel amaçlı bir framework'e dönüşüyor. Ben burada mümkün olduğunca sınırı korumaya çalışmanın önemli olduğunu düşünüyorum.
Internal library'nin amacı genellikle şu olmalı: Organizasyon içinde sürekli tekrar eden dar bir problemi çözmek. Dünyadaki bütün problemlere çözüm olacak yeni bir framework üretmek değil.
Ne Zaman Abstraction Çıkarmalıyız?
Bu konuda kesin bir formül veya sınır yok. Ama benim pratikte sevdiğim basit bir yaklaşım var. İlk kez bir kod yazdığımda genellikle doğrudan abstraction çıkarmıyorum. İkinci kez benzer bir ihtiyaç geldiğinde ortak noktaları izlemeye başlıyorum. Üçüncü kez aynı yapı tekrar ediyorsa artık ortaklaştırmayı ciddi şekilde düşünüyorum. Yani kabaca:
1 kez -> çöz
2 kez -> gözlemle
3 kez -> abstraction düşün
Bu her problem için geçerli bir kural değil. Ama gereğinden erken framework üretmenin önüne geçmek için iyi bir zihinsel filtre.
Aynı şekilde birkaç soru daha sorulabilir:
- Bu kod gerçekten tekrar ediyor mu?
- Farklı projelerde gerçekten aynı davranışı mı istiyoruz?
- Ortak library değiştiğinde kaç servis etkilenecek?
- Version yönetimini kim yapacak?
- Bu problemi zaten iyi çözen bir kütüphane var mı?
- Kendi çözümümüzü uzun vadede maintain etmek istiyor muyuz?
Bu soruların cevapları bazen birkaç satır duplicate kod bırakmanın, büyük bir ortak abstraction oluşturmaktan daha sağlıklı olduğunu gösterebilir.
MediatR Ne Zaman Mantıklı?
Bana göre MediatR veya benzeri bir mediator altyapısı özellikle şu durumlarda anlamlı hale geliyor:
- Uygulamada çok sayıda command ve query varsa
- Request processing için standart bir pipeline gerekiyorsa
- Validation ve transaction gibi davranışlar merkezi yönetilecekse
- Handler yapısı ekip içinde standart olarak kullanılıyorsa
- Modüller arasında bağımlılığı azaltmak istiyorsak
- Cross-cutting concern sayısı giderek artıyorsa
Buna karşılık çok küçük bir uygulamada yalnızca Controller'dan Service'e geçmek için mediator kullanıyorsak ekstra abstraction maliyeti faydasından daha fazla olabilir. Burada mesele MediatR'ın iyi veya kötü olması değil. Mesele kullandığımız aracın gerçekten var olan bir problemi çözüp çözmediği.
Peki Ben Hangisini Tercih Ederdim?
Küçük ve sınırları belli bir serviste doğrudan kendi küçük dispatcher yapımı kullanmak bana gayet makul geliyor. Özellikle ihtiyaç yalnızca şu kadarsa:
Request -> Handler
Arada büyük bir abstraction katmanı kurmanın çok anlamı olmayabilir. Ama işin içine şunlar girmeye başladıysa:
Request
|
+-- Validation
|
+-- Authorization
|
+-- Logging
|
+-- Transaction
|
+-- Performance
|
+-- Handler
O noktada battle-tested bir kütüphane kullanmak bana daha mantıklı geliyor. Çünkü artık çözmeye çalıştığımız problem yalnızca bir handler bulmak değil. Request processing infrastructure oluşturuyoruz ve bu ikisi aynı şey değil.
Sonuç
CQRS kullanmak için MediatR zorunlu değil. Hatta basit bir mediator veya command dispatcher birkaç interface ve küçük bir servis ile oluşturulabilir. Ama konu production seviyesinde bir altyapıya dönüştüğünde işler hızla büyüyor. Validation, logging, transaction, authorization ve performance gibi ihtiyaçlar ortaya çıktıkça pipeline yaklaşımı ciddi değer kazandırıyor.
Benim bu konudaki yaklaşımım oldukça basit: Önce problemi çöz, tekrarları gözlemle, abstraction'ı ihtiyaç oluştuğunda çıkar.
Her küçük projeye büyük bir mimari taşımaya gerek yok. Ama büyük bir sistemde de aynı altyapı kodunu yüzlerce handler içinde tekrar etmenin de pek anlamı yok. Sonuçta MediatR da CQRS de sadece araç. Asıl önemli olan kullandığımız mimarinin projeyi daha anlaşılır, test edilebilir ve sürdürülebilir hale getirip getirmediği.