Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
--results-directory TestResults/ \
--collect:"XPlat Code Coverage"

- name: Build samples
run: dotnet build samples/ -c Release

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This step likely rebuilds projects that were already built in the earlier dotnet build Compendium.sln step (the samples are now included in Compendium.sln). If the intent is only to verify dotnet build samples/ works, consider adding --no-restore here (restore already ran) and/or removing samples from Compendium.sln to avoid double-building in CI.

Suggested change
run: dotnet build samples/ -c Release
run: dotnet build samples/ -c Release --no-restore

Copilot uses AI. Check for mistakes.

- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
Expand Down
23 changes: 23 additions & 0 deletions Compendium.sln
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.ArchitectureTest
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.Shared", "src\Adapters\Compendium.Adapters.Shared\Compendium.Adapters.Shared.csproj", "{DE527E82-7509-4E3F-B002-8D53CFAA97DA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{7C29FF8B-D400-4FF4-85BB-76D23C8A5159}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickStart.OrderAggregate", "samples\01-QuickStart-OrderAggregate\QuickStart.OrderAggregate.csproj", "{8E4B32F2-DE15-4016-9D47-37B95BC72ADD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiTenant.WithPostgres", "samples\02-MultiTenant-WithPostgres\MultiTenant.WithPostgres.csproj", "{B5E3AD7B-61B8-4E12-A709-281B294AFB5E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AI.WithOpenRouter", "samples\03-AI-WithOpenRouter\AI.WithOpenRouter.csproj", "{15D89214-41A9-41BA-BA39-5C1574757B62}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -238,6 +246,18 @@ Global
{DE527E82-7509-4E3F-B002-8D53CFAA97DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DE527E82-7509-4E3F-B002-8D53CFAA97DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DE527E82-7509-4E3F-B002-8D53CFAA97DA}.Release|Any CPU.Build.0 = Release|Any CPU
{8E4B32F2-DE15-4016-9D47-37B95BC72ADD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8E4B32F2-DE15-4016-9D47-37B95BC72ADD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8E4B32F2-DE15-4016-9D47-37B95BC72ADD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8E4B32F2-DE15-4016-9D47-37B95BC72ADD}.Release|Any CPU.Build.0 = Release|Any CPU
{B5E3AD7B-61B8-4E12-A709-281B294AFB5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5E3AD7B-61B8-4E12-A709-281B294AFB5E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5E3AD7B-61B8-4E12-A709-281B294AFB5E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5E3AD7B-61B8-4E12-A709-281B294AFB5E}.Release|Any CPU.Build.0 = Release|Any CPU
{15D89214-41A9-41BA-BA39-5C1574757B62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{15D89214-41A9-41BA-BA39-5C1574757B62}.Debug|Any CPU.Build.0 = Debug|Any CPU
{15D89214-41A9-41BA-BA39-5C1574757B62}.Release|Any CPU.ActiveCfg = Release|Any CPU
{15D89214-41A9-41BA-BA39-5C1574757B62}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{FE421F00-7FFD-4666-A961-F1FF325ECD34} = {E35C8F52-5000-4427-9589-AEB5987C1AC6}
Expand Down Expand Up @@ -285,5 +305,8 @@ Global
{72B1C880-12A5-4568-85E0-3800536158DC} = {B23A5693-C266-43DC-8D3E-CBB108131762}
{D685E8D8-B3DC-4A65-9ED3-675776610190} = {72B1C880-12A5-4568-85E0-3800536158DC}
{DE527E82-7509-4E3F-B002-8D53CFAA97DA} = {73261E87-8FCA-40B6-940B-E25CBDBE33FB}
{8E4B32F2-DE15-4016-9D47-37B95BC72ADD} = {7C29FF8B-D400-4FF4-85BB-76D23C8A5159}
{B5E3AD7B-61B8-4E12-A709-281B294AFB5E} = {7C29FF8B-D400-4FF4-85BB-76D23C8A5159}
{15D89214-41A9-41BA-BA39-5C1574757B62} = {7C29FF8B-D400-4FF4-85BB-76D23C8A5159}
EndGlobalSection
EndGlobal
215 changes: 207 additions & 8 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,212 @@
# Getting Started

> Coming soon — tracked in [POM-182](https://github.com/sassy-solutions/compendium/issues?q=is%3Aissue+POM-182).
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.

This page will walk you through:
If you'd rather read working code, jump straight to [`samples/01-QuickStart-OrderAggregate`](https://github.com/sassy-solutions/compendium/tree/main/samples/01-QuickStart-OrderAggregate). Everything below is taken from that sample.

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guide says “Everything below is taken from that sample”, but multiple snippets diverge from samples/01-QuickStart-OrderAggregate/Program.cs (e.g., different validation messages and a different PlaceOrderHandler shape). Either update the snippets to match the sample verbatim, or soften the claim (e.g., “adapted from the sample”) so readers aren’t confused when comparing code.

Suggested change
If you'd rather read working code, jump straight to [`samples/01-QuickStart-OrderAggregate`](https://github.com/sassy-solutions/compendium/tree/main/samples/01-QuickStart-OrderAggregate). Everything below is taken from that sample.
If you'd rather read working code, jump straight to [`samples/01-QuickStart-OrderAggregate`](https://github.com/sassy-solutions/compendium/tree/main/samples/01-QuickStart-OrderAggregate). The examples below are adapted from that sample.

Copilot uses AI. Check for mistakes.

1. Installing the Compendium NuGet packages
2. Defining your first event-sourced aggregate
3. Wiring CQRS dispatchers in `Program.cs`
4. Dispatching a command and reading a projection
5. Running one of the [samples](https://github.com/sassy-solutions/compendium/tree/main/samples)
## 1. Prerequisites

In the meantime, the [README](https://github.com/sassy-solutions/compendium/blob/main/README.md) has a Quick Start snippet.
- [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)
- (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.

```bash
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 in `Compendium.Core` and `Compendium.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`.

```csharp
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:

1. The aggregate **never throws** for expected failures — it returns `Result` / `Result<T>`. See [ADR 0001](adr/0001-result-pattern.md).
2. `AddDomainEvent` and `IncrementVersion` are `protected` on `AggregateRoot<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`.

```csharp
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));
}
}
Comment on lines +119 to +130

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This command/query section introduces a PlaceOrderHandler implementation that doesn’t match the QuickStart sample (the sample also appends events to an in-memory event log). If the docs are meant to mirror the runnable sample, align the handler/projection flow with the sample so readers can copy/paste between them reliably.

Copilot uses AI. Check for mistakes.

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.

```csharp
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 own `Add*` extension (e.g. `AddPostgreSqlEventStore`, `AddCompendiumMultitenancy`, `AddOpenRouter`).

## 6. Dispatch a command

```csharp
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

```csharp
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](concepts/event-sourcing.md)** — the *why* behind aggregates, projections, and the result pattern.
- **[Adapters](adapters/postgresql.md)** — wire a real event store, multi-tenancy, AI provider, billing, or auth.
- **[Samples](https://github.com/sassy-solutions/compendium/tree/main/samples)** — three runnable projects:
- `01-QuickStart-OrderAggregate` — the code on this page, in a single file you can `dotnet run`.
- `02-MultiTenant-WithPostgres` — same model against a real Postgres event store, scoped per tenant.
- `03-AI-WithOpenRouter` — Compendium's provider-agnostic `IAIProvider` against OpenRouter (with offline fallback).
- **[Architecture decisions](adr/README.md)** — the trade-offs we made, and didn't make, in writing this framework.
Loading
Loading