Geleneksel yazılım mimarilerinde uzun yıllar boyunca veriyi okuma ve yazma işlemleri aynı veri modelleri ve aynı servis yapıları üzerinden yürütülmüştür. Ancak sistemler karmaşıklaştıkça ve kullanıcı sayıları arttıkça bu tek tip yaklaşım ciddi sorunlar yaratmaya başlamıştır. Bir e-ticaret platformunda bir ürünün binlerce kez görüntülenmesi ile o ürünün stok bilgisinin bir kez güncellenmesi, operasyonel olarak tamamen farklı doğaya sahip işlerdir. Okuma işlemleri hız ve esneklik beklerken yazma işlemleri veri tutarlılığı ve güvenlik odaklıdır.
CQRS (Command Query Responsibility Segregation), bu iki zıt dünyanın aynı model ve sınıflar içerisine hapsedilmesinin yarattığı hantallığı aşma ihtiyacından doğmuştur.
Projenizde LegalService sınıfının olduğunu düşünelim. Bu servis dava dosyalarının oluşturulması, hukuki süreçlerin güncellenmesi ve bu verilerin diğer departmanlar tarafından sorgulanması için API metotları sunacaktır.

Görsel kaynak: ByteByteGo
Bu ‘LegalService’ sınıfının çok fazla işlem yapabildiğini düşünün. Yani içinde dava açma, dilekçe güncelleme, avukat atama, tüm davaları listeleme ve dava detaylarını getirme gibi onlarca metot bulunuyor.
Bu durum şu sorunları beraberinde getirir:
- Karmaşıklık: Kod büyüdükçe, bir değişikliğin yaratacağı yan etkileri kestirmek imkansızlaşır.
- Zaman kaybı: Yeni bir yazılımcının bu devasa yapıyı anlaması haftalar sürer.
- Performans: Okuma ve yazma işlemleri aynı modelleri kullandığı için, sadece bir listeleme işlemi bile arkadaki karmaşık iş kurallarına (validation) takılabilir.
CQRS Neyi Çözer?
CQRS veri üzerinde değişiklik yapan komutlar ile sadece veri okuyan işlemleri birbirinden ayırmamızı söyleyen bir mimari desendir.
Bir mikroserviste yazma işlemleri genellikle katı iş kuralları ve doğrulama gerektirirken, okuma işlemleri hız ve önbellekleme (caching) odaklıdır. CQRS bu iki dünyayı birbirinden ayırarak servisin ölçeklenebilirliğini ve bakım kolaylığını artırır.
Örneğimize geri dönersek, geleneksel bir ‘LegalService’ sınıf yapısını CQRS kurallarına göre parçaladığımızda karşımıza şu yapı çıkar:
Sorgular (Queries): Veriyi sadece okur ve sistemin durumunu değiştirmez. Örnek metotlar böyle olabilir:
FindAllLawsuitsQuery: Geriye bir liste döner.
GetCaseDetailsByNumberQuery: Belirli bir dosya numarasına göre dava detaylarını getirir.
Komutlar (Commands): Sistemde bir eylem gerçekleştirir ve veri durumunu değiştirir. Örnek metotlar böyle olabilir:
CreateLawsuitHandler: Bir dava taslağı oluşturur. Girdi olarak bir DTO alır ve sisteme yeni bir kayıt ekler.
UpdateCaseHandler: Mevcut verilen davanın durumunu güncelleyebilir.
Kodumuzu komutlar ve sorgular olarak parçalara ayırdığımızda, hangi komutun hangi sınıfta işleneceğini nasıl bileceğiz? İşte burada Mediator pattern devreye girer.
Mediator’ın görevi, bir isteği alıp onu doğru işleyiciye (handler) yönlendirmektir. Bu sayede servisleriniz birbirine sıkı sıkıya bağlı yani tightly coupled olmaz. Kodunuz daha modüler, test edilebilir ve spagetti kod riskinden uzak olur.
CQRS ve Event Sourcing
CQRS mimarisi, okuma ve yazma sorumluluklarını birbirinden ayırarak sistemdeki karmaşıklığı yönetmeye başlasa da command tarafında verinin sadece son halini saklamak bazen log verisinin eksik kalmasına neden olur. Geleneksel veritabanı yaklaşımında bir hukuk dosyasının durumu açıktan kapalıya güncellendiğinde eski veri silinir ve üzerine yenisi yazılır. Ancak yüksek trafikli ve kritik iş mantığına sahip microservice projelerinde verinin sadece son hali değil, o hale nasıl geldiği de hayati önem taşır. İşte bu noktada Event Sourcing mimarisi CQRS’in sunduğu bu ayrımı daha iyi kılan bir mekanizma olarak devreye girer.
Event Sourcing bir nesnenin mevcut durumunu saklamak yerine o nesne üzerinde gerçekleşen tüm değişimleri birer zaman çizelgesi olarak kaydeder. Örneğimiz üzerinden söylersek, ‘LegalService’ içerisinde bir davanın son durumuna bakmak yerine “dava açıldı”, “avukat atandı”, “dilekçe sunuldu” gibi gerçekleşen tüm olaylar sırasıyla bir event store içerisinde tutulur. Bu yaklaşım CQRS ile birleştiğinde çok daha iyi oluyor. Command bölümü sadece yeni olayları doğrular ve fırlatır, query tarafı ise bu olayları dinleyerek kendisine özel, hızlı ve optimize edilmiş modeller oluşturur. Bu iki desen genellikle birlikte anılır çünkü audit log ihtiyacı doğal bir şekilde karşılanıyor.

Görsel kaynak: ByteByteGo
MediatR ile Uygulama
MediatR paketi .NET ekosisteminde kullanılan ve Mediator design pattern uygulayan popüler bir açık kaynak kütüphanedir. Uygulamanızın içindeki farklı bileşenlerin birbiriyle doğrudan konuşmak yerine, bir arabulucu aracılığıyla haberleşmesini sağlayan bir in-memory mesajlaşma hattıdır.
Bir komutu (command) veya sorguyu (query) tek noktadan gönderirsiniz, MediatR bu mesajı alır ve onu işleyecek olan tek bir işleyiciye iletir.
MVC bir yapı üzerinden örnek verelim. Controller veritabanı işlemlerini yapan repository sınıflarına bağlı olmaz. Controller sadece “bir dosya getir” mesajını MediatR’a verir. Yani Controller işi kimin nasıl yaptığıyla ilgilenmez.
MediatR’ı projenize eklemek için:
Install-Package MediatRArdından, bu yapıyı DI tarafına kaydetmemiz gerekir. .NET projenizin servis yapılandırma kısmında şu satırı ekleyerek MediatR’ı aktif hale getirebilirsiniz:
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));MediatR ile bir query oluşturmak için, IRequest arayüzünü uygulayan bir sınıf oluşturmalı ve bu sorgunun beklediği dönüş tipini belirtmeliyiz. Örneğimizde bir dava dosyasını numarasına göre sorgulayacağız:
public class GetCaseDetailsByNumberQuery : IRequest<LawsuitDto>
{
public string CaseNumber { get; set; }
}Bu modelin parametrelerini Controller katmanında nasıl karşılarız? Bir endpoint üzerinden gelen dosya numarasını MediatR’a şu şekilde iletiriz:
private readonly IMediator _mediator;
public LawsuitsController(IMediator mediator)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
}
// GET api/lawsuits/{number}
[HttpGet("{number}")]
public async Task<ActionResult> GetByNumber([FromRoute] string number)
{
var result = await _mediator.Send(new GetLawsuitByNumberQuery { CaseNumber = number });
return Ok(result);
}Bu yapıda Controller içinde hiçbir iş yükü bulunmaz. Tek sorumluluğu gelen isteği almak, arabulucuya iletmek ve sonucu istemciye JSON olarak dönmektir.
Handler Tanımlama
CQRS çözümümüzün en kritik parçası bu mesajı karşılayacak olan sınıftır. Yani her mesaj tipine cevap verecek bir handler (yani işleyici sınıf) tanımlamamız gerekir. MediatR ile bu işlem oldukça standarttır:
public class GetLawsuitByNumberHandler : IRequestHandler<GetLawsuitByNumberQuery, LawsuitDto>
{
private readonly ILawsuitRepository _lawsuitRepository;
public GetLawsuitByNumberHandler(ILawsuitRepository lawsuitRepository)
{
_lawsuitRepository = lawsuitRepository ?? throw new ArgumentNullException(nameof(lawsuitRepository));
}
}Bu sınıf IRequestHandler arayüzünü kullanarak input (GetLawsuitByNumberQuery) ve output (LawsuitDto) tiplerini tanımlar. Ayrıca “Handle” metodunu zorunlu kılar ve asıl işleri onun içerisinde yürütürüz.
public async Task<LawsuitDto> Handle(GetLawsuitByNumberQuery request, CancellationToken cancellationToken)
{
// Repo
var result = await _lawsuitRepository.FindByNumber(request.CaseNumber);
if (result == null) return null;
// Mapping
return new LawsuitDto
{
CaseNumber = result.CaseNumber,
Title = result.Title,
Description = result.Description,
OpeningDate = result.OpeningDate,
CurrentStatus = result.Status,
AttorneyName = result.Attorney?.FullName,
Documents = result.Documents != null ? LawsuitMapper.ToDocumentDtoList(result.Documents) : null
};
}Mapping işlemi de handler sınıfı içerisinde gerçekleştirilir. İhtiyaca göre burada Mapster gibi paketler kullanarak özelleştirme yapabilirsiniz.
Şimdi bir command sınıfı tanımlayalım ve bir örnek yapalım.
// Handler
public class CreateLawsuitCommandHandler : IRequestHandler<CreateLawsuitCommand, Guid>
{
private readonly ILawsuitRepository _repository;
private readonly IUnitOfWork _uow;
public CreateLawsuitCommandHandler(ILawsuitRepository repository, IUnitOfWork uow)
{
_repository = repository;
_uow = uow;
}
public async Task<Guid> Handle(CreateLawsuitCommand request, CancellationToken cancellationToken)
{
var existingCase = await _repository.FindByNumber(request.CaseNumber);
if (existingCase != null)
throw new Exception("File already exists");
var lawsuit = new Lawsuit(
Guid.NewGuid(),
request.CaseNumber,
request.Title,
request.CaseType
)
{
Description = request.Description,
OpeningDate = request.OpeningDate,
Status = "Yeni Başvuru"
};
await _repository.AddAsync(lawsuit);
await _uow.SaveChangesAsync(cancellationToken);
return lawsuit.Id;
}
}
// Endpoint
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateLawsuitCommand command)
{
var lawsuitId = await _mediator.Send(command);
return CreatedAtAction(nameof(GetByNumber), new { id = lawsuitId }, command);
}MediatR Notifications ve Event Handling
Bir komut başarıyla tamamlandığında, sistemde bir şeylerin değiştiğini uygulamanın diğer parçalarına duyurmak gerekir. Eğer e-posta gönderme kodunu CommandHandler içine yazarsanız, yarın farklı bir istek geldiğinde o sınıfı tekrar değiştirmek zorunda kalırsınız. Notification yapısı bizi bu bağımlılıktan kurtarır.
Bu yapıda yeni bir özellik eklendiğinde mevcut CreateLawsuit koduna dokunmazsınız. Sadece yeni bir INotificationHandler sınıfı eklersiniz. E-posta servisi o an çalışmıyor olsa bile dava başarıyla veritabanına kaydedilmiş olur.
Ayrıca bu olay başka bir microservice daha ilgilendiriyorsa, MediatR içindeki bu Notification öğesini yakalayıp RabbitMQ veya Kafka gibi bir araca fırlatarak servisler arası iletişimi de başlatabilirsiniz.
Şimdi bir event tanımlayalım.
public class LawsuitCreatedEvent : INotification
{
public Guid LawsuitId { get; }
public string CaseNumber { get; }
public string Title { get; }
public LawsuitCreatedEvent(Guid lawsuitId, string caseNumber, string title)
{
LawsuitId = lawsuitId;
CaseNumber = caseNumber;
Title = title;
}
}Daha önce yazdığımız CreateLawsuitCommandHandler içerisine gidiyoruz ve kayıt işlemi başarıyla bittikten sonra bu olayı yayınlıyoruz:
// Handler içerisindeki Handle metodu sonu
await _uow.SaveChangesAsync(cancellationToken);
// Event publish
await _mediator.Publish(new LawsuitCreatedEvent(lawsuit.Id, lawsuit.CaseNumber, lawsuit.Title));
return lawsuit.Id;Şimdi ise notification handler sınıfını yazmalıyız:
public class EmailNotificationHandler : INotificationHandler<LawsuitCreatedEvent>
{
public async Task Handle(LawsuitCreatedEvent notification, CancellationToken cancellationToken)
{
Console.WriteLine($"{notification.CaseNumber} file created");
await Task.CompletedTask;
}
}Sonuç
Microservice dünyasına geçtiğimizde, CQRS bize okuma ve yazma yüklerini bağımsızca yönetme gücü verir. CQRS ve MediatR ikilisi, bize sadece teknik bir araç seti değil ayrıca disiplin sunar. Bu disiplinin en büyük kazanımı zihinsel yükü azaltmasıdır. Sisteme yeni bir kural eklemek istediğinizde projenin diğer bölümlerini bozma korkusu yaşamadan sadece ilgili command ve notification sınıflarına odaklanabilmek, modern yazılım geliştirmenin en büyük lükslerinden biridir.
CQRS ve MediatR ikilisini kullanacağınız projeyi iyi bilmelisiniz. Basit CRUD işlemleri yapan bir projede fazlalık olabilir. Ancak çok fazla business logic olan bir yapıda, yani verilerin çok sorgulandığı ve girdi çıktının çok olduğu bir yapıda bu çok iyi bir mimari tercihi olabilir. Bu sebeple ihtiyaçlarınızı iyi bilmeli ve buna uygun bir design pattern seçilmelidir.



Yorum bırakın