- Core Interfaces
- Base Classes
- Routing and Handling
- Domain and Repository
- Snapshotting
- Caching
- Exceptions
Namespace: CQRSlite.Messages
Marker interface for all messages (commands, events, queries).
public interface IMessage { }Usage: All commands, events, and queries must implement this interface (through their specific interfaces).
Namespace: CQRSlite.Commands
Marker interface for commands. Commands represent intentions to change state.
public interface ICommand : IMessage { }Example:
public class CreateInventoryItem : ICommand
{
public Guid Id { get; set; }
public string Name { get; set; }
}Best Practices:
- Use imperative naming:
CreateProduct,UpdatePrice,DeleteOrder - Include all necessary data for the command
- Consider including
ExpectedVersionfor optimistic concurrency
Namespace: CQRSlite.Events
Interface for domain events. Events represent facts that have occurred.
public interface IEvent : IMessage
{
Guid Id { get; set; }
int Version { get; set; }
DateTimeOffset TimeStamp { get; set; }
}Properties:
Id: The aggregate identifierVersion: The version of the aggregate when this event occurredTimeStamp: When the event occurred (UTC)
Example:
public class InventoryItemCreated : IEvent
{
public Guid Id { get; set; }
public int Version { get; set; }
public DateTimeOffset TimeStamp { get; set; }
public string Name { get; set; }
public int InitialCount { get; set; }
}Best Practices:
- Use past tense naming:
ProductCreated,PriceUpdated,OrderDeleted - Events are immutable - never change after creation
- Include all necessary data (events should be self-contained)
- Consider versioning strategy for schema evolution
Namespace: CQRSlite.Queries
Interface for queries. Queries represent requests for information.
public interface IQuery<TReturn> : IMessage { }Generic Parameters:
TReturn: The type of data this query returns
Example:
public class GetInventoryItemDetails : IQuery<InventoryItemDetailsDto>
{
public Guid Id { get; set; }
}
public class InventoryItemDetailsDto
{
public Guid Id { get; set; }
public string Name { get; set; }
public int CurrentCount { get; set; }
public int Version { get; set; }
}Best Practices:
- Queries should not modify state
- Name clearly indicates what is being queried
- Return DTOs, not domain entities
Namespace: CQRSlite.Commands
Interface for handling commands.
public interface ICommandHandler<in T> : IHandler<T> where T : ICommand
{
Task Handle(T message);
}Example:
public class InventoryCommandHandlers : ICommandHandler<CreateInventoryItem>
{
private readonly ISession _session;
public InventoryCommandHandlers(ISession session)
{
_session = session;
}
public async Task Handle(CreateInventoryItem message)
{
var item = new InventoryItem(message.Id, message.Name);
await _session.Add(item);
await _session.Commit();
}
}Rules:
- Exactly one handler per command type
- Throws
InvalidOperationExceptionif multiple handlers registered
Namespace: CQRSlite.Commands
Interface for handling commands with cancellation support.
public interface ICancellableCommandHandler<in T> : ICancellableHandler<T> where T : ICommand
{
Task Handle(T message, CancellationToken token);
}Example:
public class InventoryCommandHandlers : ICancellableCommandHandler<CreateInventoryItem>
{
public async Task Handle(CreateInventoryItem message, CancellationToken token)
{
var item = new InventoryItem(message.Id, message.Name);
await _session.Add(item, token);
await _session.Commit(token);
}
}Namespace: CQRSlite.Events
Interface for handling events.
public interface IEventHandler<in T> : IHandler<T> where T : IEvent
{
Task Handle(T message);
}Example:
public class InventoryItemDetailView : IEventHandler<InventoryItemCreated>
{
private readonly IReadDatabase _database;
public async Task Handle(InventoryItemCreated message)
{
await _database.Insert(new InventoryItemDetailsDto
{
Id = message.Id,
Name = message.Name,
CurrentCount = 0,
Version = message.Version
});
}
}Rules:
- Zero or more handlers per event type
- All handlers execute in parallel via
Task.WhenAll - Handler failures don't prevent other handlers from executing
Namespace: CQRSlite.Events
Interface for handling events with cancellation support.
public interface ICancellableEventHandler<in T> : ICancellableHandler<T> where T : IEvent
{
Task Handle(T message, CancellationToken token);
}Namespace: CQRSlite.Queries
Interface for handling queries.
public interface IQueryHandler<in T, TResponse> where T : IQuery<TResponse>
{
Task<TResponse> Handle(T message);
}Generic Parameters:
T: The query typeTResponse: The return type
Example:
public class InventoryItemDetailView :
IQueryHandler<GetInventoryItemDetails, InventoryItemDetailsDto>
{
private readonly IReadDatabase _database;
public async Task<InventoryItemDetailsDto> Handle(GetInventoryItemDetails message)
{
return await _database.GetById(message.Id);
}
}Rules:
- Exactly one handler per query type
- Must return
TResponse
Namespace: CQRSlite.Queries
Interface for handling queries with cancellation support.
public interface ICancellableQueryHandler<in T, TResponse> where T : IQuery<TResponse>
{
Task<TResponse> Handle(T message, CancellationToken token);
}Namespace: CQRSlite.Commands
Interface for sending commands to handlers.
public interface ICommandSender
{
Task Send<T>(T command, CancellationToken cancellationToken = default) where T : ICommand;
}Usage:
public class ProductController
{
private readonly ICommandSender _commandSender;
[HttpPost]
public async Task<IActionResult> Create(CreateProduct command, CancellationToken token)
{
await _commandSender.Send(command, token);
return Ok();
}
}Behavior:
- Routes command to registered handler
- Throws if no handler or multiple handlers registered
- Executes handler asynchronously
Namespace: CQRSlite.Events
Interface for publishing events to handlers.
public interface IEventPublisher
{
Task Publish<T>(T @event, CancellationToken cancellationToken = default) where T : IEvent;
}Usage:
public class InMemoryEventStore : IEventStore
{
private readonly IEventPublisher _publisher;
public async Task Save(IEnumerable<IEvent> events, CancellationToken cancellationToken)
{
foreach (var @event in events)
{
// Save to storage
_storage.Add(@event);
// Publish after save
await _publisher.Publish(@event, cancellationToken);
}
}
}Behavior:
- Routes event to all registered handlers
- Handlers execute in parallel
- Returns when all handlers complete
Namespace: CQRSlite.Queries
Interface for processing queries.
public interface IQueryProcessor
{
Task<TResponse> Query<TResponse>(IQuery<TResponse> query, CancellationToken cancellationToken = default);
}Usage:
public class ProductController
{
private readonly IQueryProcessor _queryProcessor;
[HttpGet("{id}")]
public async Task<ActionResult<ProductDto>> Get(Guid id)
{
var result = await _queryProcessor.Query(new GetProduct { Id = id });
return Ok(result);
}
}Behavior:
- Routes query to registered handler
- Throws if no handler or multiple handlers registered
- Returns typed result from handler
Namespace: CQRSlite.Routing
Interface for registering message handlers.
public interface IHandlerRegistrar
{
void RegisterHandler<T>(Func<T, CancellationToken, Task> handler) where T : IMessage;
}Usage (Manual Registration):
var registrar = serviceProvider.GetService<IHandlerRegistrar>();
registrar.RegisterHandler<CreateProduct>(async (cmd, token) =>
{
var handler = serviceProvider.GetService<ProductCommandHandler>();
await handler.Handle(cmd, token);
});Usage (Automatic Registration):
var routeRegistrar = new RouteRegistrar(serviceProvider);
routeRegistrar.Register(typeof(ProductCommandHandler).Assembly);Namespace: CQRSlite.Domain
Base class for all aggregates using event sourcing.
public abstract class AggregateRoot
{
public Guid Id { get; protected set; }
public int Version { get; protected set; }
protected void ApplyChange(IEvent @event);
protected virtual void ApplyEvent(IEvent @event);
// Internal methods (used by Repository)
internal IEnumerable<IEvent> FlushUncommittedChanges();
internal void LoadFromHistory(IEnumerable<IEvent> history);
}Key Methods:
Applies a new event to the aggregate and records it as uncommitted.
Usage:
public class Product : AggregateRoot
{
public void ChangePrice(decimal newPrice)
{
// Validate business rules
if (newPrice < 0)
throw new ArgumentException("Price cannot be negative");
// Apply and record event
ApplyChange(new ProductPriceChanged(Id, newPrice));
}
}Behavior:
- Calls
ApplyEvent()to update internal state - Adds event to uncommitted changes list
- Increments version
Virtual method that applies an event to aggregate state. Uses convention-based routing by default.
Default Behavior:
Searches for method with signature: Apply(EventType @event)
Example:
public class Product : AggregateRoot
{
private string _name;
private decimal _price;
// Convention-based event application
private void Apply(ProductCreated e)
{
_name = e.Name;
_price = e.Price;
}
private void Apply(ProductPriceChanged e)
{
_price = e.NewPrice;
}
}Custom Override:
protected override void ApplyEvent(IEvent @event)
{
switch (@event)
{
case ProductCreated e:
_name = e.Name;
_price = e.Price;
break;
case ProductPriceChanged e:
_price = e.NewPrice;
break;
default:
base.ApplyEvent(@event); // Fall back to convention
break;
}
}Constructor Requirements:
- Public/protected constructor for creating new aggregates
- Private parameterless constructor for rehydration
public class Product : AggregateRoot
{
// Constructor for new aggregates
public Product(Guid id, string name, decimal price)
{
Id = id;
ApplyChange(new ProductCreated(id, name, price));
}
// Required for rehydration
private Product() { }
}Namespace: CQRSlite.Snapshotting
Base class for aggregates that support snapshotting.
public abstract class SnapshotAggregateRoot<TSnapshot> : AggregateRoot
where TSnapshot : Snapshot
{
protected abstract TSnapshot CreateSnapshot();
protected abstract void RestoreFromSnapshot(TSnapshot snapshot);
// Internal methods (used by SnapshotRepository)
internal TSnapshot GetSnapshot();
internal void Restore(TSnapshot snapshot);
}Key Methods:
Creates a snapshot of the current aggregate state.
Example:
public class Product : SnapshotAggregateRoot<ProductSnapshot>
{
private string _name;
private decimal _price;
private bool _discontinued;
protected override ProductSnapshot CreateSnapshot()
{
return new ProductSnapshot
{
Id = Id,
Version = Version,
Name = _name,
Price = _price,
Discontinued = _discontinued
};
}
}Restores aggregate state from a snapshot.
Example:
protected override void RestoreFromSnapshot(ProductSnapshot snapshot)
{
_name = snapshot.Name;
_price = snapshot.Price;
_discontinued = snapshot.Discontinued;
}Namespace: CQRSlite.Snapshotting
Base class for snapshot objects.
public abstract class Snapshot
{
public Guid Id { get; set; }
public int Version { get; set; }
}Example:
public class ProductSnapshot : Snapshot
{
public string Name { get; set; }
public decimal Price { get; set; }
public bool Discontinued { get; set; }
}Best Practices:
- Include all state necessary to rebuild the aggregate
- Keep snapshots serializable (for storage)
- Consider versioning for snapshot evolution
Namespace: CQRSlite.Domain
Interface for aggregate persistence.
public interface IRepository
{
Task Save<T>(T aggregate, int? expectedVersion = null, CancellationToken cancellationToken = default)
where T : AggregateRoot;
Task<T> Get<T>(Guid aggregateId, CancellationToken cancellationToken = default)
where T : AggregateRoot;
}Methods:
Saves an aggregate by persisting its uncommitted events.
Parameters:
aggregate: The aggregate to saveexpectedVersion: Expected version for optimistic concurrency (optional)cancellationToken: Cancellation token
Throws:
ConcurrencyException: If expectedVersion doesn't match current versionAggregateNotFoundException: If aggregate not found when expectedVersion specified
Example:
public async Task Handle(ChangeProductPrice command)
{
var product = await _repository.Get<Product>(command.Id);
product.ChangePrice(command.NewPrice);
await _repository.Save(product, command.ExpectedVersion);
}Loads an aggregate by replaying its event history.
Parameters:
aggregateId: The aggregate identifiercancellationToken: Cancellation token
Returns: The rehydrated aggregate
Throws:
AggregateNotFoundException: If no events found for aggregate
Example:
var product = await _repository.Get<Product>(productId);Namespace: CQRSlite.Domain
Default implementation of IRepository.
public class Repository : IRepository
{
public Repository(IEventStore eventStore, IEventPublisher publisher = null)
}Constructor Parameters:
eventStore: Event store for loading/saving eventspublisher: Optional event publisher (deprecated - use event store to publish)
Usage:
services.AddScoped<IRepository>(sp =>
new Repository(sp.GetService<IEventStore>()));Namespace: CQRSlite.Domain
Interface for Unit of Work pattern implementation.
public interface ISession
{
Task Add<T>(T aggregate, CancellationToken cancellationToken = default) where T : AggregateRoot;
Task<T> Get<T>(Guid id, int? expectedVersion = null, CancellationToken cancellationToken = default) where T : AggregateRoot;
Task Commit(CancellationToken cancellationToken = default);
}Methods:
Adds a new aggregate to be tracked by the session.
Example:
var product = new Product(Guid.NewGuid(), "New Product", 99.99m);
await _session.Add(product);
await _session.Commit();Gets an aggregate, either from the session tracking or from the repository.
Parameters:
id: Aggregate identifierexpectedVersion: Expected version for optimistic concurrencycancellationToken: Cancellation token
Returns: The aggregate
Throws:
ConcurrencyException: If expectedVersion doesn't match
Example:
var product = await _session.Get<Product>(productId, expectedVersion: 5);
product.ChangePrice(150m);
await _session.Commit();Saves all tracked aggregates to the repository.
Example:
var product = await _session.Get<Product>(productId);
product.ChangePrice(150m);
var category = await _session.Get<Category>(categoryId);
category.AddProduct(productId);
// Saves both aggregates
await _session.Commit();Namespace: CQRSlite.Domain
Default implementation of ISession.
public class Session : ISession
{
public Session(IRepository repository)
}Usage:
services.AddScoped<ISession, Session>();Behavior:
- Tracks aggregates in memory during transaction
- Prevents duplicate loads of same aggregate
- Saves all tracked aggregates on commit
- Maintains original version for concurrency checking
Namespace: CQRSlite.Events
Interface for event persistence. You must implement this interface.
public interface IEventStore
{
Task Save(IEnumerable<IEvent> events, CancellationToken cancellationToken = default);
Task<IEnumerable<IEvent>> Get(Guid aggregateId, int fromVersion, CancellationToken cancellationToken = default);
}Methods:
Persists events and publishes them.
Best Practice Implementation:
public class SqlEventStore : IEventStore
{
private readonly IEventPublisher _publisher;
private readonly IDbConnection _connection;
public async Task Save(IEnumerable<IEvent> events, CancellationToken cancellationToken)
{
using var transaction = _connection.BeginTransaction();
try
{
foreach (var @event in events)
{
// Save event to database
await SaveEventToDatabase(@event);
// Publish after save
await _publisher.Publish(@event, cancellationToken);
}
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}Retrieves events for an aggregate starting from a specific version.
Parameters:
aggregateId: The aggregate identifierfromVersion: Starting version (exclusive - events after this version)cancellationToken: Cancellation token
Returns: Events in order by version
Example Implementation:
public async Task<IEnumerable<IEvent>> Get(Guid aggregateId, int fromVersion, CancellationToken cancellationToken)
{
var eventRecords = await _connection.QueryAsync<EventRecord>(
"SELECT * FROM Events WHERE AggregateId = @Id AND Version > @Version ORDER BY Version",
new { Id = aggregateId, Version = fromVersion });
return eventRecords.Select(DeserializeEvent);
}Namespace: CQRSlite.Snapshotting
Interface for snapshot persistence.
public interface ISnapshotStore
{
Task<Snapshot> Get(Guid id, CancellationToken cancellationToken = default);
Task Save(Snapshot snapshot, CancellationToken cancellationToken = default);
}Methods:
Retrieves the latest snapshot for an aggregate.
Returns:
- The latest snapshot, or
nullif none exists
Saves a snapshot.
Example Implementation:
public class SqlSnapshotStore : ISnapshotStore
{
public async Task Save(Snapshot snapshot, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(snapshot, snapshot.GetType());
await _connection.ExecuteAsync(
"INSERT INTO Snapshots (Id, Version, Type, Data) VALUES (@Id, @Version, @Type, @Data) " +
"ON CONFLICT (Id) DO UPDATE SET Version = @Version, Data = @Data",
new
{
snapshot.Id,
snapshot.Version,
Type = snapshot.GetType().AssemblyQualifiedName,
Data = json
});
}
public async Task<Snapshot> Get(Guid id, CancellationToken cancellationToken)
{
var record = await _connection.QueryFirstOrDefaultAsync<SnapshotRecord>(
"SELECT * FROM Snapshots WHERE Id = @Id", new { Id = id });
if (record == null) return null;
var type = Type.GetType(record.Type);
return (Snapshot)JsonSerializer.Deserialize(record.Data, type);
}
}Namespace: CQRSlite.Snapshotting
Interface for snapshot strategies.
public interface ISnapshotStrategy
{
bool IsSnapshotable(Type aggregateType);
bool ShouldMakeSnapShot(AggregateRoot aggregate);
}Methods:
Determines if an aggregate type supports snapshots.
Determines if a snapshot should be created for the aggregate.
Namespace: CQRSlite.Snapshotting
Default implementation that snapshots every 100 events.
public class DefaultSnapshotStrategy : ISnapshotStrategy
{
private const int SnapshotInterval = 100;
public bool IsSnapshotable(Type aggregateType)
{
return aggregateType.GetInterfaces().Any(i =>
i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(ISnapshotAggregate<>));
}
public bool ShouldMakeSnapShot(AggregateRoot aggregate)
{
if (!IsSnapshotable(aggregate.GetType())) return false;
var i = aggregate.Version;
return i >= SnapshotInterval && i % SnapshotInterval == 0;
}
}Custom Strategy Example:
public class CustomSnapshotStrategy : ISnapshotStrategy
{
public bool IsSnapshotable(Type aggregateType)
{
return typeof(ISnapshotAggregate<>).IsAssignableFrom(aggregateType);
}
public bool ShouldMakeSnapShot(AggregateRoot aggregate)
{
// Snapshot after 50 events, then every 100
if (aggregate.Version == 50) return true;
return aggregate.Version >= 100 && aggregate.Version % 100 == 0;
}
}Namespace: CQRSlite.Snapshotting
Repository decorator that adds snapshot support.
public class SnapshotRepository : IRepository
{
public SnapshotRepository(
ISnapshotStore snapshotStore,
ISnapshotStrategy snapshotStrategy,
IRepository repository,
IEventStore eventStore)
}Usage:
services.AddScoped<IRepository>(sp =>
new SnapshotRepository(
sp.GetService<ISnapshotStore>(),
sp.GetService<ISnapshotStrategy>(),
new Repository(sp.GetService<IEventStore>()),
sp.GetService<IEventStore>()));Behavior:
- Get: Tries to load from snapshot, then applies events after snapshot version
- Save: Saves aggregate, then checks if snapshot should be created
Namespace: CQRSlite.Caching
Interface for cache implementations.
public interface ICache
{
bool IsTracked(Guid id);
void Set(Guid id, AggregateRoot aggregate);
AggregateRoot Get(Guid id);
void Remove(Guid id);
void RegisterEvictionCallback(Action<Guid> action);
}Namespace: CQRSlite.Caching
Default in-memory cache implementation using Microsoft.Extensions.Caching.Memory.
public class MemoryCache : ICache
{
public MemoryCache()
}Usage:
services.AddSingleton<ICache, MemoryCache>();Namespace: CQRSlite.Caching
Thread-safe repository decorator that adds caching.
public class CacheRepository : IRepository
{
public CacheRepository(IRepository repository, IEventStore eventStore, ICache cache)
}Usage:
services.AddSingleton<ICache, MemoryCache>();
services.AddScoped<IRepository>(sp =>
new CacheRepository(
new Repository(sp.GetService<IEventStore>()),
sp.GetService<IEventStore>(),
sp.GetService<ICache>()));Behavior:
- Get: Checks cache first, applies new events if cached
- Save: Invalidates cache
- Thread-safe per-aggregate using semaphores
- Detects and handles skipped events
Namespace: CQRSlite.Routing
Central message router implementing all routing interfaces.
public class Router : IHandlerRegistrar, ICommandSender, IEventPublisher, IQueryProcessorUsage:
var router = new Router();
services.AddSingleton(router);
services.AddSingleton<ICommandSender>(router);
services.AddSingleton<IEventPublisher>(router);
services.AddSingleton<IQueryProcessor>(router);
services.AddSingleton<IHandlerRegistrar>(router);Namespace: CQRSlite.Routing
Automatic handler registration via reflection.
public class RouteRegistrar
{
public RouteRegistrar(IServiceProvider serviceProvider)
public void Register(Assembly assembly)
public void Register(params Type[] typesFromAssemblies)
}Usage:
var registrar = new RouteRegistrar(serviceProvider);
// Register all handlers from assembly
registrar.Register(typeof(ProductCommandHandler).Assembly);
// Register specific types
registrar.Register(typeof(ProductCommandHandler), typeof(ProductEventHandler));Namespace: CQRSlite.Domain.Exception
Thrown when optimistic concurrency check fails.
public class ConcurrencyException : Exception
{
public Guid Id { get; }
public int ExpectedVersion { get; }
public int ActualVersion { get; }
}Example:
try
{
await _repository.Save(product, expectedVersion: 5);
}
catch (ConcurrencyException ex)
{
// Handle conflict - retry, merge, or inform user
Console.WriteLine($"Concurrency conflict: Expected v{ex.ExpectedVersion}, was v{ex.ActualVersion}");
}Namespace: CQRSlite.Domain.Exception
Thrown when aggregate cannot be found.
public class AggregateNotFoundException : Exception
{
public Guid Id { get; }
public Type Type { get; }
}Example:
try
{
var product = await _repository.Get<Product>(productId);
}
catch (AggregateNotFoundException ex)
{
return NotFound($"Product {ex.Id} not found");
}Namespace: CQRSlite.Domain.Exception
Thrown when aggregate or event is missing required Id.
Namespace: CQRSlite.Domain.Exception
Thrown when aggregate lacks required parameterless constructor for rehydration.
public class MissingParameterLessConstructorException : Exception
{
public Type AggregateType { get; }
}Resolution: Add private parameterless constructor:
public class Product : AggregateRoot
{
private Product() { } // Required for rehydration
}This API reference covers all public interfaces and classes in CQRSlite. For implementation examples and best practices, see DEVELOPER.md.