From c1cdc99a49bc22dd4f2cf1788d83474b63c7b0de Mon Sep 17 00:00:00 2001 From: sacha Date: Sat, 25 Apr 2026 10:22:15 +0200 Subject: [PATCH 1/4] docs(samples): add 01-QuickStart-OrderAggregate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-memory walkthrough demonstrating AggregateRoot, IDomainEvent, ICommandDispatcher/IQueryDispatcher wiring, and a synchronous projection update — zero external infra so newcomers can dotnet run it immediately. --- .../01-QuickStart-OrderAggregate/Program.cs | 255 ++++++++++++++++++ .../QuickStart.OrderAggregate.csproj | 19 ++ .../01-QuickStart-OrderAggregate/README.md | 44 +++ samples/Directory.Build.props | 11 + 4 files changed, 329 insertions(+) create mode 100644 samples/01-QuickStart-OrderAggregate/Program.cs create mode 100644 samples/01-QuickStart-OrderAggregate/QuickStart.OrderAggregate.csproj create mode 100644 samples/01-QuickStart-OrderAggregate/README.md create mode 100644 samples/Directory.Build.props diff --git a/samples/01-QuickStart-OrderAggregate/Program.cs b/samples/01-QuickStart-OrderAggregate/Program.cs new file mode 100644 index 0000000..3b4af78 --- /dev/null +++ b/samples/01-QuickStart-OrderAggregate/Program.cs @@ -0,0 +1,255 @@ +// QuickStart sample: define an Order aggregate, dispatch a command, +// update an in-memory projection, and read it back through a query. +// +// Run: dotnet run + +using Compendium.Abstractions.CQRS.Commands; +using Compendium.Abstractions.CQRS.Handlers; +using Compendium.Abstractions.CQRS.Queries; +using Compendium.Application.CQRS; +using Compendium.Core.Domain.Events; +using Compendium.Core.Domain.Primitives; +using Compendium.Core.Results; +using Microsoft.Extensions.DependencyInjection; + +namespace QuickStart.OrderAggregate; + +// ── 1. Domain events ──────────────────────────────────────────────────────── + +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; } +} + +// ── 2. Aggregate ──────────────────────────────────────────────────────────── + +public sealed class Order : AggregateRoot +{ + private Order(Guid id) : base(id) { } + + public string CustomerId { get; private set; } = string.Empty; + public decimal TotalAmount { get; private set; } + public OrderStatus Status { get; private set; } = OrderStatus.Pending; + public DateTimeOffset? ShippedAt { get; private set; } + + public static Result Place(Guid id, string customerId, decimal totalAmount) + { + if (string.IsNullOrWhiteSpace(customerId)) + { + return Result.Failure(Error.Validation("Order.CustomerId.Empty", "CustomerId cannot be empty.")); + } + + if (totalAmount <= 0m) + { + return Result.Failure(Error.Validation("Order.TotalAmount.NotPositive", "TotalAmount must be greater than zero.")); + } + + 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 order in status {Status}.")); + } + + ShippedAt = DateTimeOffset.UtcNow; + Status = OrderStatus.Shipped; + + AddDomainEvent(new OrderShipped(Id.ToString(), ShippedAt.Value, Version + 1)); + IncrementVersion(); + return Result.Success(); + } +} + +public enum OrderStatus { Pending, Placed, Shipped } + +// ── 3. Command + handler ──────────────────────────────────────────────────── + +public sealed record PlaceOrderCommand(Guid OrderId, string CustomerId, decimal TotalAmount) + : ICommand; + +public sealed class PlaceOrderHandler : ICommandHandler +{ + private readonly IOrderEventLog _eventLog; + private readonly OrderSummaryProjection _projection; + + public PlaceOrderHandler(IOrderEventLog eventLog, OrderSummaryProjection projection) + { + _eventLog = eventLog; + _projection = projection; + } + + public Task> HandleAsync(PlaceOrderCommand command, CancellationToken cancellationToken = default) + { + var result = Order.Place(command.OrderId, command.CustomerId, command.TotalAmount); + if (result.IsFailure) + { + return Task.FromResult(Result.Failure(result.Error)); + } + + var order = result.Value!; + var events = order.GetUncommittedEvents(); + _eventLog.Append(events); + _projection.Apply(events); + + return Task.FromResult(Result.Success(order.Id)); + } +} + +// ── 4. Query + handler ────────────────────────────────────────────────────── + +public sealed record GetOrderSummaryQuery(Guid OrderId) : IQuery; + +public sealed record OrderSummary(Guid OrderId, string CustomerId, decimal TotalAmount, string Status); + +public sealed class GetOrderSummaryHandler : IQueryHandler +{ + private readonly OrderSummaryProjection _projection; + + public GetOrderSummaryHandler(OrderSummaryProjection projection) => _projection = projection; + + public Task> HandleAsync(GetOrderSummaryQuery query, CancellationToken cancellationToken = default) + { + var summary = _projection.Get(query.OrderId); + return Task.FromResult(summary is null + ? Result.Failure(Error.NotFound("Order.NotFound", $"Order {query.OrderId} not found.")) + : Result.Success(summary)); + } +} + +// ── 5. In-memory event log + projection ───────────────────────────────────── + +public interface IOrderEventLog +{ + void Append(IEnumerable events); + IReadOnlyList All(); +} + +public sealed class InMemoryOrderEventLog : IOrderEventLog +{ + private readonly List _events = new(); + + public void Append(IEnumerable events) => _events.AddRange(events); + + public IReadOnlyList All() => _events.ToList(); +} + +public sealed class OrderSummaryProjection +{ + private readonly Dictionary _summaries = new(); + + public void Apply(IEnumerable events) + { + foreach (var @event in events) + { + switch (@event) + { + case OrderPlaced placed: + _summaries[Guid.Parse(placed.AggregateId)] = new OrderSummary( + Guid.Parse(placed.AggregateId), placed.CustomerId, placed.TotalAmount, "Placed"); + break; + + case OrderShipped shipped when _summaries.TryGetValue(Guid.Parse(shipped.AggregateId), out var existing): + _summaries[Guid.Parse(shipped.AggregateId)] = existing with { Status = "Shipped" }; + break; + } + } + } + + public OrderSummary? Get(Guid orderId) => _summaries.GetValueOrDefault(orderId); +} + +// ── 6. Composition root ───────────────────────────────────────────────────── + +public static class Program +{ + public static async Task Main() + { + var services = new ServiceCollection(); + + // Compendium dispatchers + services.AddSingleton(); + services.AddSingleton(); + + // In-memory infrastructure + services.AddSingleton(); + services.AddSingleton(); + + // Handlers + services.AddSingleton, PlaceOrderHandler>(); + services.AddSingleton, GetOrderSummaryHandler>(); + + await using var provider = services.BuildServiceProvider(); + + var commands = provider.GetRequiredService(); + var queries = provider.GetRequiredService(); + var log = provider.GetRequiredService(); + + Console.WriteLine("=== Compendium QuickStart: Order aggregate ===\n"); + + // 1. Place an order + var orderId = Guid.NewGuid(); + var place = await commands.DispatchAsync( + new PlaceOrderCommand(orderId, CustomerId: "cust-001", TotalAmount: 49.95m)); + + if (place.IsFailure) + { + Console.Error.WriteLine($"PlaceOrder failed: {place.Error.Code} - {place.Error.Message}"); + return 1; + } + + Console.WriteLine($" ✓ Order placed: {place.Value}"); + + // 2. Read the projection + var summary = await queries.DispatchAsync( + new GetOrderSummaryQuery(orderId)); + + if (summary.IsFailure) + { + Console.Error.WriteLine($"GetOrderSummary failed: {summary.Error.Code} - {summary.Error.Message}"); + return 1; + } + + Console.WriteLine($" ✓ Projection: {summary.Value}"); + + // 3. Show events that were captured + Console.WriteLine($"\nDomain events captured ({log.All().Count}):"); + foreach (var @event in log.All()) + { + Console.WriteLine($" • {@event}"); + } + + Console.WriteLine("\nDone."); + return 0; + } +} diff --git a/samples/01-QuickStart-OrderAggregate/QuickStart.OrderAggregate.csproj b/samples/01-QuickStart-OrderAggregate/QuickStart.OrderAggregate.csproj new file mode 100644 index 0000000..fd19df6 --- /dev/null +++ b/samples/01-QuickStart-OrderAggregate/QuickStart.OrderAggregate.csproj @@ -0,0 +1,19 @@ + + + + Exe + QuickStart.OrderAggregate + QuickStart.OrderAggregate + + + + + + + + + + + + + diff --git a/samples/01-QuickStart-OrderAggregate/README.md b/samples/01-QuickStart-OrderAggregate/README.md new file mode 100644 index 0000000..5ed57ce --- /dev/null +++ b/samples/01-QuickStart-OrderAggregate/README.md @@ -0,0 +1,44 @@ +# 01 — QuickStart: Order Aggregate + +A zero-dependency, in-memory walkthrough of the smallest interesting Compendium app: define an aggregate, dispatch a command, project an event into a read model, and query it. + +## What it shows + +- `AggregateRoot` with `AddDomainEvent` / `IncrementVersion` +- `IDomainEvent` via `DomainEventBase` +- `Result` / `Result` for success / failure paths +- `ICommand` / `IQuery` + handlers wired through `ICommandDispatcher` / `IQueryDispatcher` +- An in-memory event log and projection updated synchronously from the command handler + +No databases, no Docker, no cloud APIs. + +## Run it + +```bash +dotnet run -c Release +``` + +Expected output (the `OrderId` and timestamps will differ): + +```text +=== Compendium QuickStart: Order aggregate === + + ✓ Order placed: c5d58d25-d8da-4889-a86c-954426ec6551 + ✓ Projection: OrderSummary { OrderId = c5d58d25-d8da-4889-a86c-954426ec6551, CustomerId = cust-001, TotalAmount = 49.95, Status = Placed } + +Domain events captured (1): + • OrderPlaced [EventId=..., AggregateId=..., AggregateType=Order, Version=1, EventVersion=1, OccurredOn=...] + +Done. +``` + +## Files + +- `Program.cs` — the entire sample in one file (events → aggregate → command/handler → query/handler → projection → composition root). +- `QuickStart.OrderAggregate.csproj` — references `Compendium.Core`, `Compendium.Abstractions`, and `Compendium.Application`. + +## What to read next + +- `samples/02-MultiTenant-WithPostgres/` — same building blocks against a real PostgreSQL event store, scoped per tenant. +- `docs/concepts/event-sourcing.md` +- `docs/getting-started.md` diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props new file mode 100644 index 0000000..574048b --- /dev/null +++ b/samples/Directory.Build.props @@ -0,0 +1,11 @@ + + + + + + + false + false + $(NoWarn);CS1591 + + From 328f970b6c7f876acb379aea412a8493c726ebe3 Mon Sep 17 00:00:00 2001 From: sacha Date: Sat, 25 Apr 2026 10:27:56 +0200 Subject: [PATCH 2/4] docs(samples): add multi-tenant Postgres and OpenRouter samples 02-MultiTenant-WithPostgres demonstrates row-level tenant isolation through Compendium.Multitenancy + the PostgreSQL event store adapter, with a docker-compose for a throw-away Postgres 16 container. 03-AI-WithOpenRouter shows the provider-agnostic IAIProvider against OpenRouter, with an offline stub fallback so the sample still runs in CI and on flights. samples/README.md indexes all three; Samples.sln makes `dotnet build samples/` work for the CI step added in a follow-up commit. Compendium.sln gets the projects under the `samples` solution folder. --- Compendium.sln | 23 +++ .../MultiTenant.WithPostgres.csproj | 23 +++ .../02-MultiTenant-WithPostgres/Program.cs | 191 ++++++++++++++++++ samples/02-MultiTenant-WithPostgres/README.md | 54 +++++ .../docker-compose.yml | 20 ++ .../AI.WithOpenRouter.csproj | 21 ++ samples/03-AI-WithOpenRouter/Program.cs | 129 ++++++++++++ samples/03-AI-WithOpenRouter/README.md | 37 ++++ samples/README.md | 23 +++ samples/Samples.sln | 34 ++++ 10 files changed, 555 insertions(+) create mode 100644 samples/02-MultiTenant-WithPostgres/MultiTenant.WithPostgres.csproj create mode 100644 samples/02-MultiTenant-WithPostgres/Program.cs create mode 100644 samples/02-MultiTenant-WithPostgres/README.md create mode 100644 samples/02-MultiTenant-WithPostgres/docker-compose.yml create mode 100644 samples/03-AI-WithOpenRouter/AI.WithOpenRouter.csproj create mode 100644 samples/03-AI-WithOpenRouter/Program.cs create mode 100644 samples/03-AI-WithOpenRouter/README.md create mode 100644 samples/README.md create mode 100644 samples/Samples.sln diff --git a/Compendium.sln b/Compendium.sln index 873e95e..106e86e 100644 --- a/Compendium.sln +++ b/Compendium.sln @@ -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 @@ -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} @@ -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 diff --git a/samples/02-MultiTenant-WithPostgres/MultiTenant.WithPostgres.csproj b/samples/02-MultiTenant-WithPostgres/MultiTenant.WithPostgres.csproj new file mode 100644 index 0000000..ebbb4ed --- /dev/null +++ b/samples/02-MultiTenant-WithPostgres/MultiTenant.WithPostgres.csproj @@ -0,0 +1,23 @@ + + + + Exe + MultiTenant.WithPostgres + MultiTenant.WithPostgres + + + + + + + + + + + + + + + + + diff --git a/samples/02-MultiTenant-WithPostgres/Program.cs b/samples/02-MultiTenant-WithPostgres/Program.cs new file mode 100644 index 0000000..f1e10a9 --- /dev/null +++ b/samples/02-MultiTenant-WithPostgres/Program.cs @@ -0,0 +1,191 @@ +// MultiTenant + PostgreSQL sample. +// +// Two tenants share a single event_store table; isolation is enforced by the +// PostgreSqlEventStore writing the current TenantId on every event row. +// +// Run: docker compose up -d → dotnet run + +using Compendium.Adapters.PostgreSQL.Configuration; +using Compendium.Adapters.PostgreSQL.DependencyInjection; +using Compendium.Adapters.PostgreSQL.EventStore; +using Compendium.Abstractions.EventSourcing; +using Compendium.Core.Domain.Events; +using Compendium.Core.Domain.Primitives; +using Compendium.Core.EventSourcing; +using Compendium.Core.Results; +using Compendium.Multitenancy; +using Compendium.Multitenancy.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace MultiTenant.WithPostgres; + +// ── Domain ────────────────────────────────────────────────────────────────── + +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 Order : AggregateRoot +{ + private Order(Guid id) : base(id) { } + + public static Order Place(Guid id, string customerId, decimal totalAmount) + { + var order = new Order(id); + order.AddDomainEvent(new OrderPlaced(id.ToString(), customerId, totalAmount, order.Version + 1)); + order.IncrementVersion(); + return order; + } +} + +// ── Composition root ──────────────────────────────────────────────────────── + +public static class Program +{ + private const string ConnectionString = + "Host=localhost;Port=5433;Database=compendium_sample;Username=compendium;Password=compendium"; + + public static async Task Main() + { + Console.WriteLine("=== Compendium MultiTenant + PostgreSQL ===\n"); + + if (!await IsPostgresReachableAsync()) + { + Console.Error.WriteLine($""" + ✗ Could not connect to PostgreSQL at {ConnectionString}. + + Start the bundled container: + docker compose up -d + + Then re-run: + dotnet run + """); + return 1; + } + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCompendiumMultitenancy(); + services.AddPostgreSqlEventStore(options => + { + options.ConnectionString = ConnectionString; + options.AutoCreateSchema = true; + }); + + await using var provider = services.BuildServiceProvider(); + + // Whitelist domain events so the secure deserializer can re-hydrate them. + var registry = provider.GetRequiredService(); + registry.RegisterEventType(typeof(OrderPlaced)); + + // Initialize the event_store table on the very first run. + var concrete = provider.GetRequiredService(); + var schemaResult = await concrete.InitializeSchemaAsync(); + if (schemaResult.IsFailure) + { + Console.Error.WriteLine($"✗ Schema init failed: {schemaResult.Error.Code} - {schemaResult.Error.Message}"); + return 1; + } + + var tenantSetter = provider.GetRequiredService(); + var eventStore = provider.GetRequiredService(); + + var tenantA = new TenantInfo { Id = "acme", Name = "Acme Corp" }; + var tenantB = new TenantInfo { Id = "globex", Name = "Globex" }; + + // Place one order under each tenant. + var acmeOrderId = await PlaceOrderForTenant(tenantSetter, eventStore, tenantA, "cust-a-1", 19.99m); + var globexOrderId = await PlaceOrderForTenant(tenantSetter, eventStore, tenantB, "cust-b-1", 42.50m); + + Console.WriteLine(); + + // Read each tenant's order back, scoped by tenant context. + await ReadOrderForTenant(tenantSetter, eventStore, tenantA, acmeOrderId); + await ReadOrderForTenant(tenantSetter, eventStore, tenantB, globexOrderId); + + Console.WriteLine(); + + // Cross-tenant read attempt: switching to Globex but asking for Acme's + // aggregate returns nothing — isolation is enforced at the row level. + await ReadOrderForTenant(tenantSetter, eventStore, tenantB, acmeOrderId, label: "(cross-tenant — expected: empty)"); + + Console.WriteLine("\nDone."); + return 0; + } + + private static async Task PlaceOrderForTenant( + ITenantContextSetter tenantSetter, + IEventStore eventStore, + TenantInfo tenant, + string customerId, + decimal totalAmount) + { + tenantSetter.SetTenant(tenant); + + var order = Order.Place(Guid.NewGuid(), customerId, totalAmount); + var events = order.GetUncommittedEvents(); + + var append = await eventStore.AppendEventsAsync( + aggregateId: order.Id.ToString(), + events: events, + expectedVersion: 0); + + if (append.IsFailure) + { + Console.Error.WriteLine($" ✗ Append for tenant '{tenant.Id}' failed: {append.Error.Code} - {append.Error.Message}"); + return order.Id; + } + + Console.WriteLine($" ✓ Tenant {tenant.Id,-7} placed order {order.Id} (customer={customerId}, total={totalAmount})"); + return order.Id; + } + + private static async Task ReadOrderForTenant( + ITenantContextSetter tenantSetter, + IEventStore eventStore, + TenantInfo tenant, + Guid orderId, + string? label = null) + { + tenantSetter.SetTenant(tenant); + + var read = await eventStore.GetEventsAsync(orderId.ToString()); + var marker = label ?? ""; + if (read.IsFailure) + { + Console.WriteLine($" • Tenant {tenant.Id,-7} read {orderId} → error: {read.Error.Code} {marker}"); + return; + } + + Console.WriteLine($" • Tenant {tenant.Id,-7} read {orderId} → {read.Value!.Count} event(s) {marker}"); + foreach (var @event in read.Value!) + { + Console.WriteLine($" - {@event.GetType().Name} v{@event.AggregateVersion} @ {@event.OccurredOn:O}"); + } + } + + private static async Task IsPostgresReachableAsync() + { + try + { + await using var connection = new NpgsqlConnection(ConnectionString); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + await connection.OpenAsync(cts.Token); + return true; + } + catch + { + return false; + } + } +} diff --git a/samples/02-MultiTenant-WithPostgres/README.md b/samples/02-MultiTenant-WithPostgres/README.md new file mode 100644 index 0000000..ec97224 --- /dev/null +++ b/samples/02-MultiTenant-WithPostgres/README.md @@ -0,0 +1,54 @@ +# 02 — Multi-tenant with PostgreSQL + +Two tenants share a single `event_store` table; isolation is enforced by `PostgreSqlEventStore` reading the active `ITenantContext` and stamping every row with `tenant_id`. Reading events for a stream while another tenant is active returns an empty result. + +## What it shows + +- `AddCompendiumMultitenancy()` registers `ITenantContext` / `ITenantContextSetter` +- `AddPostgreSqlEventStore(...)` configures the adapter with `AutoCreateSchema = true` +- `ITenantContextSetter.SetTenant(...)` flips the active tenant per operation +- The exact same aggregate ID is invisible across tenants — proving row-level isolation + +## Prerequisites + +- .NET 9 SDK +- Docker (only used to run a throw-away PostgreSQL 16 container on port `5433`) + +## Run it + +```bash +# 1. Start PostgreSQL (port 5433 to avoid clashes with your local dev DB). +docker compose up -d + +# 2. Run the sample. +dotnet run -c Release + +# 3. (When you're done) tear it down. +docker compose down -v +``` + +If Postgres isn't reachable, `Program.cs` prints a clear instruction and exits with code 1 — no silent crash. + +## Expected output + +```text +=== Compendium MultiTenant + PostgreSQL === + + ✓ Tenant acme placed order ... + ✓ Tenant globex placed order ... + + • Tenant acme read → 1 event(s) + - OrderPlaced v1 @ ... + • Tenant globex read → 1 event(s) + - OrderPlaced v1 @ ... + + • Tenant globex read → 0 event(s) (cross-tenant — expected: empty) + +Done. +``` + +## Going further + +- See `src/Adapters/Compendium.Adapters.PostgreSQL/` for the schema and concurrency model. +- See `docs/concepts/multi-tenancy.md` for resolution strategies (header, host, JWT claim). +- See `docs/adapters/postgresql.md` for production tuning (pool sizes, timeouts). diff --git a/samples/02-MultiTenant-WithPostgres/docker-compose.yml b/samples/02-MultiTenant-WithPostgres/docker-compose.yml new file mode 100644 index 0000000..5be660d --- /dev/null +++ b/samples/02-MultiTenant-WithPostgres/docker-compose.yml @@ -0,0 +1,20 @@ +services: + postgres: + image: postgres:16-alpine + container_name: compendium-sample-pg + environment: + POSTGRES_DB: compendium_sample + POSTGRES_USER: compendium + POSTGRES_PASSWORD: compendium + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U compendium -d compendium_sample"] + interval: 2s + timeout: 3s + retries: 20 + volumes: + - compendium-sample-pg:/var/lib/postgresql/data + +volumes: + compendium-sample-pg: diff --git a/samples/03-AI-WithOpenRouter/AI.WithOpenRouter.csproj b/samples/03-AI-WithOpenRouter/AI.WithOpenRouter.csproj new file mode 100644 index 0000000..b841398 --- /dev/null +++ b/samples/03-AI-WithOpenRouter/AI.WithOpenRouter.csproj @@ -0,0 +1,21 @@ + + + + Exe + AI.WithOpenRouter + AI.WithOpenRouter + + + + + + + + + + + + + + + diff --git a/samples/03-AI-WithOpenRouter/Program.cs b/samples/03-AI-WithOpenRouter/Program.cs new file mode 100644 index 0000000..49184d3 --- /dev/null +++ b/samples/03-AI-WithOpenRouter/Program.cs @@ -0,0 +1,129 @@ +// AI sample using the Compendium OpenRouter adapter. +// +// - Reads OPENROUTER_API_KEY from the environment. +// - Falls back to an offline stub if the key is missing, so `dotnet run` always +// produces useful output (and CI can still build the sample). +// +// Run: +// export OPENROUTER_API_KEY=sk-or-... +// dotnet run + +using Compendium.Abstractions.AI; +using Compendium.Abstractions.AI.Models; +using Compendium.Adapters.OpenRouter.DependencyInjection; +using Compendium.Core.Results; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace AI.WithOpenRouter; + +public static class Program +{ + private const string DefaultModel = "anthropic/claude-3.5-haiku"; + + public static async Task Main() + { + Console.WriteLine("=== Compendium AI: OpenRouter chat completion ===\n"); + + var apiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY"); + var services = new ServiceCollection(); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); + + if (!string.IsNullOrWhiteSpace(apiKey)) + { + Console.WriteLine($" ✓ OPENROUTER_API_KEY found — calling {DefaultModel} live.\n"); + services.AddOpenRouter(o => + { + o.ApiKey = apiKey; + o.DefaultModel = DefaultModel; + }); + } + else + { + Console.WriteLine(" ⚠ OPENROUTER_API_KEY not set — running in offline demo mode."); + Console.WriteLine(" Set the env var to make a real API call:"); + Console.WriteLine(" export OPENROUTER_API_KEY=sk-or-...\n"); + services.AddSingleton(); + } + + await using var provider = services.BuildServiceProvider(); + var ai = provider.GetRequiredService(); + + var request = new CompletionRequest + { + Model = DefaultModel, + SystemPrompt = "You are a concise assistant. Reply in one sentence.", + Messages = new[] + { + Message.User("In one sentence, what does the Compendium framework provide for .NET developers?"), + }, + Temperature = 0.2f, + MaxTokens = 200, + }; + + var result = await ai.CompleteAsync(request); + if (result.IsFailure) + { + Console.Error.WriteLine($"✗ Completion failed: {result.Error.Code} - {result.Error.Message}"); + return 1; + } + + var response = result.Value!; + Console.WriteLine($"Provider: {ai.ProviderId}"); + Console.WriteLine($"Model: {response.Model}"); + Console.WriteLine($"Finish: {response.FinishReason}"); + Console.WriteLine($"Tokens: {response.Usage.PromptTokens} in / {response.Usage.CompletionTokens} out"); + Console.WriteLine(); + Console.WriteLine("Reply:"); + Console.WriteLine($" {response.Content}"); + Console.WriteLine("\nDone."); + return 0; + } +} + +/// +/// A tiny stub used when no API key is configured. +/// Lets the sample run end-to-end without network access — useful in CI, +/// at conferences, or on flights. +/// +internal sealed class OfflineDemoProvider : IAIProvider +{ + public string ProviderId => "offline-demo"; + + public Task> CompleteAsync(CompletionRequest request, CancellationToken cancellationToken = default) + { + var response = new CompletionResponse + { + Id = Guid.NewGuid().ToString(), + Model = request.Model, + Content = "Compendium is a modular .NET framework for DDD, CQRS, event sourcing, and multi-tenancy with ready-to-use adapters. (offline demo response)", + FinishReason = FinishReason.Stop, + Usage = new UsageStats { PromptTokens = 32, CompletionTokens = 28 }, + }; + return Task.FromResult(Result.Success(response)); + } + + public async IAsyncEnumerable> StreamCompleteAsync( + CompletionRequest request, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return Result.Success(new CompletionChunk + { + Id = Guid.NewGuid().ToString(), + ContentDelta = "(offline demo)", + IsFinal = true, + FinishReason = FinishReason.Stop, + }); + } + + public Task> EmbedAsync(EmbeddingRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(Result.Failure( + AIErrors.InvalidRequest("Embeddings are not supported in offline demo mode."))); + + public Task>> ListModelsAsync(CancellationToken cancellationToken = default) + => Task.FromResult(Result.Success>(Array.Empty())); + + public Task HealthCheckAsync(CancellationToken cancellationToken = default) + => Task.FromResult(Result.Success()); +} diff --git a/samples/03-AI-WithOpenRouter/README.md b/samples/03-AI-WithOpenRouter/README.md new file mode 100644 index 0000000..a82e29f --- /dev/null +++ b/samples/03-AI-WithOpenRouter/README.md @@ -0,0 +1,37 @@ +# 03 — AI with OpenRouter + +A minimal chat completion using `Compendium.Adapters.OpenRouter`, which implements the provider-agnostic `IAIProvider` from `Compendium.Abstractions.AI`. + +## What it shows + +- `AddOpenRouter(o => o.ApiKey = ...)` registers `IAIProvider` against OpenRouter +- `IAIProvider.CompleteAsync(...)` for a single-shot response +- `Result` for success / failure, including token counts and finish reason +- A pluggable offline fallback (`OfflineDemoProvider`) so the sample runs without internet or an API key + +## Run it + +### Live mode (real API call) + +```bash +export OPENROUTER_API_KEY=sk-or-... +dotnet run -c Release +``` + +Get a free OpenRouter key at . The sample defaults to `anthropic/claude-3.5-haiku` (cheap and fast); change `DefaultModel` in `Program.cs` to try anything from . + +### Offline mode (no network) + +Just don't set the env var: + +```bash +unset OPENROUTER_API_KEY +dotnet run -c Release +``` + +You'll see `running in offline demo mode` and a hardcoded response. Useful for demos, CI, and laptops without network. + +## Going further + +- See `src/Abstractions/Compendium.Abstractions.AI/` for the provider contract — swap OpenRouter for an in-house `IAIProvider` without touching call sites. +- See `docs/adapters/openrouter.md` for streaming, model fallbacks, and cost tracking. diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..aa85734 --- /dev/null +++ b/samples/README.md @@ -0,0 +1,23 @@ +# Compendium samples + +Three runnable projects that map to the three milestones of a typical Compendium adoption: define an aggregate, persist its events, then plug in a third-party adapter. + +| Sample | What it shows | How to run | Required setup | +| --- | --- | --- | --- | +| [`01-QuickStart-OrderAggregate`](01-QuickStart-OrderAggregate/) | `AggregateRoot`, `IDomainEvent`, in-memory event log + projection, `ICommandDispatcher` / `IQueryDispatcher` wiring | `dotnet run` | None — pure in-memory | +| [`02-MultiTenant-WithPostgres`](02-MultiTenant-WithPostgres/) | `Compendium.Multitenancy` + `Compendium.Adapters.PostgreSQL` — two tenants share an event store; row-level isolation by `tenant_id` | `docker compose up -d` then `dotnet run` | Docker (PostgreSQL 16 on port 5433) | +| [`03-AI-WithOpenRouter`](03-AI-WithOpenRouter/) | `Compendium.Adapters.OpenRouter` implementing `IAIProvider`; a single chat completion with offline fallback | `dotnet run` | Optional: `OPENROUTER_API_KEY` env var (otherwise an offline stub is used) | + +## Build them all + +```bash +dotnet build samples/ -c Release +``` + +CI builds every sample on every PR; running them is left to humans because samples 02 and 03 need real services. + +## Adding your own sample + +1. Create `samples/NN-YourSample/YourSample.csproj` with `Exe`. +2. The repo's [`samples/Directory.Build.props`](Directory.Build.props) already opts samples out of NuGet packing and disables doc generation. +3. Add the project to `Compendium.sln` (`dotnet sln add ...`) and update the table above. diff --git a/samples/Samples.sln b/samples/Samples.sln new file mode 100644 index 0000000..e95896d --- /dev/null +++ b/samples/Samples.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickStart.OrderAggregate", "01-QuickStart-OrderAggregate\QuickStart.OrderAggregate.csproj", "{E0C9A6A6-63AB-47F2-88C4-400916653468}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiTenant.WithPostgres", "02-MultiTenant-WithPostgres\MultiTenant.WithPostgres.csproj", "{D21D5A6A-990F-409C-8D93-722A83BC4541}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AI.WithOpenRouter", "03-AI-WithOpenRouter\AI.WithOpenRouter.csproj", "{5F1A6F3B-8A39-4386-9610-02F9DA9913AB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E0C9A6A6-63AB-47F2-88C4-400916653468}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0C9A6A6-63AB-47F2-88C4-400916653468}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0C9A6A6-63AB-47F2-88C4-400916653468}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0C9A6A6-63AB-47F2-88C4-400916653468}.Release|Any CPU.Build.0 = Release|Any CPU + {D21D5A6A-990F-409C-8D93-722A83BC4541}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D21D5A6A-990F-409C-8D93-722A83BC4541}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D21D5A6A-990F-409C-8D93-722A83BC4541}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D21D5A6A-990F-409C-8D93-722A83BC4541}.Release|Any CPU.Build.0 = Release|Any CPU + {5F1A6F3B-8A39-4386-9610-02F9DA9913AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F1A6F3B-8A39-4386-9610-02F9DA9913AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F1A6F3B-8A39-4386-9610-02F9DA9913AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F1A6F3B-8A39-4386-9610-02F9DA9913AB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal From 0cae799137ed3c7cd657d2375960711f4685688b Mon Sep 17 00:00:00 2001 From: sacha Date: Sat, 25 Apr 2026 10:28:02 +0200 Subject: [PATCH 3/4] docs: write getting-started guide Replaces the placeholder docs/getting-started.md with a 10-min walkthrough: prerequisites, NuGet install, define an Order aggregate, wire DI for ICommandDispatcher / IQueryDispatcher, dispatch a command, read a projection, plus links into concepts/, adapters/, and the runnable samples. The snippets match samples/01-QuickStart-OrderAggregate verbatim so readers can switch between page and code without translation. --- docs/getting-started.md | 215 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 207 insertions(+), 8 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index a8d3b83..af5ac70 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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. -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` (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 +{ + 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 Place(Guid id, string customerId, decimal totalAmount) + { + if (string.IsNullOrWhiteSpace(customerId)) + return Result.Failure(Error.Validation("Order.CustomerId.Empty", "CustomerId required.")); + if (totalAmount <= 0m) + return Result.Failure(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`. See [ADR 0001](adr/0001-result-pattern.md). +2. `AddDomainEvent` and `IncrementVersion` are `protected` on `AggregateRoot`; 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; + +public sealed record GetOrderSummaryQuery(Guid OrderId) : IQuery; +public sealed record OrderSummary(Guid OrderId, string CustomerId, decimal TotalAmount, string Status); + +public sealed class PlaceOrderHandler(OrderSummaryProjection projection) + : ICommandHandler +{ + public Task> HandleAsync(PlaceOrderCommand cmd, CancellationToken ct = default) + { + var result = Order.Place(cmd.OrderId, cmd.CustomerId, cmd.TotalAmount); + if (result.IsFailure) return Task.FromResult(Result.Failure(result.Error)); + + projection.Apply(result.Value!.GetUncommittedEvents()); + return Task.FromResult(Result.Success(result.Value!.Id)); + } +} + +public sealed class GetOrderSummaryHandler(OrderSummaryProjection projection) + : IQueryHandler +{ + public Task> HandleAsync(GetOrderSummaryQuery q, CancellationToken ct = default) + { + var s = projection.Get(q.OrderId); + return Task.FromResult(s is null + ? Result.Failure(Error.NotFound("Order.NotFound", $"Order {q.OrderId} not found.")) + : Result.Success(s)); + } +} +``` + +The `OrderSummaryProjection` is just a `Dictionary` 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(); +services.AddSingleton(); + +// Your projection + handlers +services.AddSingleton(); +services.AddSingleton, PlaceOrderHandler>(); +services.AddSingleton, 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(); + +var orderId = Guid.NewGuid(); +var result = await commands.DispatchAsync( + 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` (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(); + +var summary = await queries.DispatchAsync( + 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. From b14e3fecbe7fe09211d8696ec9fb14821b9da826 Mon Sep 17 00:00:00 2001 From: sacha Date: Sat, 25 Apr 2026 10:28:06 +0200 Subject: [PATCH 4/4] ci: build samples on every PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Build samples step after the unit-test job so we catch sample breakage before merge. No dotnet run in CI — sample 02 needs Postgres and sample 03 may need an OpenRouter key. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 141f25f..689cb41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: --results-directory TestResults/ \ --collect:"XPlat Code Coverage" + - name: Build samples + run: dotnet build samples/ -c Release + - name: Upload test results if: always() uses: actions/upload-artifact@v7