Getting Started
This page walks you through building a tiny event-sourced service with Compendium: prerequisites, install, define an aggregate, wire DI, dispatch a command, and read a projection. Plan ~10 minutes; longer if it's your first event-sourced .NET app.
If you'd rather read working code, jump straight to samples/01-QuickStart-OrderAggregate. Everything below is taken from that sample.
1. Prerequisites
- .NET 9 SDK
- (Optional) Docker — only needed for the multi-tenant + PostgreSQL sample
- A C# project (
dotnet new console -o MyService)
Note. Compendium targets
net9.0. Older runtimes are not supported.
2. Install the packages
The smallest useful set is Compendium.Core plus Compendium.Application. Add adapters as you need them.
dotnet add package Compendium.Core
dotnet add package Compendium.Abstractions
dotnet add package Compendium.Application
# Optional adapters
dotnet add package Compendium.Adapters.PostgreSQL
dotnet add package Compendium.Multitenancy
dotnet add package Compendium.Adapters.OpenRouter
Status. Compendium is at
v1.0.0-preview.1. APIs inCompendium.CoreandCompendium.Abstractions.*are intended to be stable; adapter APIs may evolve.
3. Define an aggregate
Aggregates inherit from AggregateRoot<TId> (in Compendium.Core.Domain.Primitives). Domain events derive from DomainEventBase (in Compendium.Core.Domain.Events). Both are in zero-dependency Compendium.Core.
using Compendium.Core.Domain.Events;
using Compendium.Core.Domain.Primitives;
using Compendium.Core.Results;
public sealed class OrderPlaced : DomainEventBase
{
public OrderPlaced(string orderId, string customerId, decimal totalAmount, long version)
: base(orderId, nameof(Order), version)
{
CustomerId = customerId;
TotalAmount = totalAmount;
}
public string CustomerId { get; }
public decimal TotalAmount { get; }
}
public sealed class OrderShipped : DomainEventBase
{
public OrderShipped(string orderId, DateTimeOffset shippedAt, long version)
: base(orderId, nameof(Order), version) => ShippedAt = shippedAt;
public DateTimeOffset ShippedAt { get; }
}
public sealed class Order : AggregateRoot<Guid>
{
private Order(Guid id) : base(id) { }
public string CustomerId { get; private set; } = "";
public decimal TotalAmount { get; private set; }
public OrderStatus Status { get; private set; } = OrderStatus.Pending;
public static Result<Order> Place(Guid id, string customerId, decimal totalAmount)
{
if (string.IsNullOrWhiteSpace(customerId))
return Result.Failure<Order>(Error.Validation("Order.CustomerId.Empty", "CustomerId required."));
if (totalAmount <= 0m)
return Result.Failure<Order>(Error.Validation("Order.TotalAmount.NotPositive", "Total must be > 0."));
var order = new Order(id) { CustomerId = customerId, TotalAmount = totalAmount, Status = OrderStatus.Placed };
order.AddDomainEvent(new OrderPlaced(id.ToString(), customerId, totalAmount, order.Version + 1));
order.IncrementVersion();
return Result.Success(order);
}
public Result Ship()
{
if (Status != OrderStatus.Placed)
return Result.Failure(Error.Conflict("Order.NotPlaced", $"Cannot ship in status {Status}."));
Status = OrderStatus.Shipped;
AddDomainEvent(new OrderShipped(Id.ToString(), DateTimeOffset.UtcNow, Version + 1));
IncrementVersion();
return Result.Success();
}
}
public enum OrderStatus { Pending, Placed, Shipped }
Two things to notice:
- The aggregate never throws for expected failures — it returns
Result/Result<T>. See ADR 0001. AddDomainEventandIncrementVersionareprotectedonAggregateRoot<TId>; only the aggregate itself can raise events.
4. Define a command and a query
Commands and queries live in Compendium.Abstractions.CQRS; handlers live in Compendium.Abstractions.CQRS.Handlers.
using Compendium.Abstractions.CQRS.Commands;
using Compendium.Abstractions.CQRS.Handlers;
using Compendium.Abstractions.CQRS.Queries;
using Compendium.Core.Results;
public sealed record PlaceOrderCommand(Guid OrderId, string CustomerId, decimal TotalAmount)
: ICommand<Guid>;
public sealed record GetOrderSummaryQuery(Guid OrderId) : IQuery<OrderSummary>;
public sealed record OrderSummary(Guid OrderId, string CustomerId, decimal TotalAmount, string Status);
public sealed class PlaceOrderHandler(OrderSummaryProjection projection)
: ICommandHandler<PlaceOrderCommand, Guid>
{
public Task<Result<Guid>> HandleAsync(PlaceOrderCommand cmd, CancellationToken ct = default)
{
var result = Order.Place(cmd.OrderId, cmd.CustomerId, cmd.TotalAmount);
if (result.IsFailure) return Task.FromResult(Result.Failure<Guid>(result.Error));
projection.Apply(result.Value!.GetUncommittedEvents());
return Task.FromResult(Result.Success(result.Value!.Id));
}
}
public sealed class GetOrderSummaryHandler(OrderSummaryProjection projection)
: IQueryHandler<GetOrderSummaryQuery, OrderSummary>
{
public Task<Result<OrderSummary>> HandleAsync(GetOrderSummaryQuery q, CancellationToken ct = default)
{
var s = projection.Get(q.OrderId);
return Task.FromResult(s is null
? Result.Failure<OrderSummary>(Error.NotFound("Order.NotFound", $"Order {q.OrderId} not found."))
: Result.Success(s));
}
}
The OrderSummaryProjection is just a Dictionary<Guid, OrderSummary> updated from incoming events — see the QuickStart sample for the full implementation.
5. Wire DI
Compendium ships dispatcher classes (CommandDispatcher, QueryDispatcher in Compendium.Application.CQRS) — register them and your handlers, and you're done.
using Compendium.Abstractions.CQRS.Handlers;
using Compendium.Application.CQRS;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
// Compendium dispatchers
services.AddSingleton<ICommandDispatcher, CommandDispatcher>();
services.AddSingleton<IQueryDispatcher, QueryDispatcher>();
// Your projection + handlers
services.AddSingleton<OrderSummaryProjection>();
services.AddSingleton<ICommandHandler<PlaceOrderCommand, Guid>, PlaceOrderHandler>();
services.AddSingleton<IQueryHandler<GetOrderSummaryQuery, OrderSummary>, GetOrderSummaryHandler>();
await using var provider = services.BuildServiceProvider();
Heads-up. There is no umbrella
AddCompendium(...)extension yet — register dispatchers and handlers explicitly. Each adapter brings its ownAdd*extension (e.g.AddPostgreSqlEventStore,AddCompendiumMultitenancy,AddOpenRouter).
6. Dispatch a command
var commands = provider.GetRequiredService<ICommandDispatcher>();
var orderId = Guid.NewGuid();
var result = await commands.DispatchAsync<PlaceOrderCommand, Guid>(
new PlaceOrderCommand(orderId, CustomerId: "cust-001", TotalAmount: 49.95m));
if (result.IsFailure)
{
Console.Error.WriteLine($"{result.Error.Code}: {result.Error.Message}");
return 1;
}
Console.WriteLine($"Placed order {result.Value}");
Dispatchers wrap your handler in a pipeline of IPipelineBehavior<TRequest, TResponse> (logging, validation, idempotency, transactions). Out of the box you get distributed tracing and metrics via CompendiumTelemetry.
7. Read a projection
var queries = provider.GetRequiredService<IQueryDispatcher>();
var summary = await queries.DispatchAsync<GetOrderSummaryQuery, OrderSummary>(
new GetOrderSummaryQuery(orderId));
Console.WriteLine(summary.Value);
// → OrderSummary { OrderId = ..., CustomerId = cust-001, TotalAmount = 49.95, Status = Placed }
Next steps
- Concepts — the why behind aggregates, projections, and the result pattern.
- Adapters — wire a real event store, multi-tenancy, AI provider, billing, or auth.
- Samples — three runnable projects:
01-QuickStart-OrderAggregate— the code on this page, in a single file you candotnet run.02-MultiTenant-WithPostgres— same model against a real Postgres event store, scoped per tenant.03-AI-WithOpenRouter— Compendium's provider-agnosticIAIProvideragainst OpenRouter (with offline fallback).
- Architecture decisions — the trade-offs we made, and didn't make, in writing this framework.