CQRS + Event Sourcing, Scalare și Command Bus Custom
După ce o aplicație bazată pe CQRS a fost stabilizată — cu medierea prin MediatR, repository-uri clare și query-uri optimizate — vine momentul să te gândești la nivelul următor: arhitecturi scalabile, reziliente și complet separate între Command și Query. Aceasta este etapa unde CQRS devine cu adevărat o arhitectură enterprise, potrivită pentru aplicații distribuite și microservicii.
🔁 1. CQRS + Event Sourcing – reconstrucția stării din evenimente
Unul dintre principiile cele mai elegante, dar și complexe din CQRS este Event Sourcing. În loc să stochezi doar starea finală a unei entități (de exemplu, un articol de blog), aplici o abordare istorică: păstrezi toate evenimentele care au dus la acea stare.
De exemplu, în loc să salvezi o entitate Post cu titlu și conținut, salvezi evenimente precum:
-
PostCreatedEvent
-
PostTitleUpdatedEvent
-
PostContentEditedEvent
La reconstrucția unui obiect din domeniu, aplici aceste evenimente în ordine cronologică. Astfel, Aggregate Root devine sursa adevărului („single source of truth”).
public class PostAggregate
{
public Guid Id { get; private set; }
public string Title { get; private set; }
public string Content { get; private set; }
private readonly List<IDomainEvent> _events = new();
public void Apply(PostCreatedEvent @event)
{
Id = @event.PostId;
Title = @event.Title;
Content = @event.Content;
}
public void Apply(PostTitleUpdatedEvent @event)
{
Title = @event.NewTitle;
}
}
Prin acest model, starea este o proiecție a evenimentelor, nu o entitate statică.
Avantajele sunt semnificative:
-
✅ Istoric complet și auditabil al acțiunilor.
-
✅ Posibilitatea de a reconstrui starea la orice moment din timp.
-
✅ Compatibilitate naturală cu microservicii și mesaje distribuite.
Totuși, Event Sourcing implică o complexitate crescută: trebuie gestionate scheme de versiuni pentru evenimente, migrații și o strategie de proiecție asincronă pentru citire.
⚙️ 2. Scalare: baze de date separate pentru Write și Read
Odată ce aplicația începe să aibă trafic mare sau volum de date semnificativ, separarea fizică între Write DB și Read DB devine esențială.
În CQRS logic (cel clasic), separarea e doar conceptuală – adică aceleași date, dar în contexte diferite.
În CQRS fizic, se merg pe baze de date distincte:
-
Write DB (ex. SQL Server, PostgreSQL) pentru comenzi, tranzacții, validări.
-
Read DB (ex. ElasticSearch, MongoDB, SQLite) pentru proiecții optimizate de interogare.
Exemplu de implementare:
-
PostCommandHandler scrie în BlogWriteDbContext.
-
GetPostsQueryHandler citește din BlogReadDbContext, unde datele sunt sincronizate periodic sau prin evenimente.
// Command side
public class BlogWriteDbContext : DbContext
{
public DbSet<Post> Posts { get; set; }
}
// Query side
public class BlogReadDbContext : DbContext
{
public DbSet<PostReadModel> Posts { get; set; }
}
Beneficiile acestei separări:
-
🚀 Scalare independentă a bazelor de date.
-
⚡ Performanță mai bună pe interogări complexe.
-
🔒 Izolare completă între operațiunile de scriere și cele de citire.
Această abordare este des întâlnită în sistemele enterprise mari sau în arhitecturi event-driven unde citirea este asincronă față de scriere.
🧩 3. Command Bus / Query Bus custom – izolare totală
Ultima componentă din această etapă avansată este Command Bus-ul sau Query Bus-ul custom, care oferă o separare completă între straturile aplicației.
Deși MediatR este excelent pentru majoritatea cazurilor, într-o arhitectură distribuită poți avea nevoie de o rutare mai controlată a comenzilor.
Exemplu minimal de implementare pentru un Command Bus custom:
public interface ICommand { }
public interface ICommandHandler<TCommand>
where TCommand : ICommand
{
Task HandleAsync(TCommand command);
}
public class CommandBus
{
private readonly IServiceProvider _serviceProvider;
public CommandBus(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task SendAsync<TCommand>(TCommand command)
where TCommand : ICommand
{
var handler = _serviceProvider.GetRequiredService<ICommandHandler<TCommand>>();
await handler.HandleAsync(command);
}
}
Avantajul este clar: poți direcționa comenzile către alte procese, microservicii sau chiar mesaje RabbitMQ, fără să afectezi codul domeniului.
Acesta devine un pas natural spre arhitecturi DDD + Microservices.
🧾 4. Event Publishing și Audit Logging
În completare, Domain Events și Integration Events rămân cheia comunicării între module.
Domain Events sunt locale domeniului și pot fi declanșate din aggregate root-uri:
public record PostCreatedDomainEvent(Guid PostId, string Title) : INotification;
Pe de altă parte, Integration Events se publică către alte aplicații (prin message broker).
În paralel, Audit Logging poate fi implementat ca un comportament MediatR (Decorator) care salvează metadate precum:
-
Tipul comenzii executate
-
UserId-ul care a inițiat acțiunea
-
Timpul de execuție și rezultatul
Astfel, ai un sistem complet transparent și extensibil.
🧠 Concluzie
Etapa avansată a CQRS nu este doar o optimizare, ci o maturizare arhitecturală.
Event Sourcing aduce trasabilitate și consistență, baze de date separate oferă scalabilitate reală, iar Command Bus-ul custom asigură o izolare perfectă între layere.
Pe termen lung, aceste principii transformă o aplicație obișnuită într-un sistem robust, scalabil și pregătit pentru distribuție, fără a compromite claritatea și separarea logică dintre Command și Query.