diff --git a/docs/concepts/event-sourcing.md b/docs/concepts/event-sourcing.md index d00bd3f..fd536de 100644 --- a/docs/concepts/event-sourcing.md +++ b/docs/concepts/event-sourcing.md @@ -1,5 +1,105 @@ # Event Sourcing -> Coming soon — tracked in [POM-183](https://github.com/sassy-solutions/compendium/issues?q=is%3Aissue+POM-183). +Compendium models domain state as an **append-only log of events** rather than a mutable row in a database. Every change is recorded as a fact that happened, never lost, never overwritten. -Why event sourcing, append-only stores, aggregates vs projections, and snapshots in Compendium. See also [ADR 0005](../adr/0005-event-sourcing-vs-state.md). +## Why event sourcing? + +Most CRUD systems store the *current state* and lose the history of how that state came to be. Event sourcing flips that: state is *derived* from a sequence of events, and the events themselves are the source of truth. + +The trade-off is real — there is more upfront machinery (events, aggregates, projections) — but it pays back in: + +- **Audit by construction**: you get a tamper-evident timeline for free, which is gold for SaaS, billing, identity, and anywhere compliance matters. +- **Time travel**: replay the log up to any point to debug, reproduce a customer issue, or back-test a new business rule. +- **Multiple read models**: the same events can feed many projections (search index, dashboard, ML feature store) without polluting the write model. +- **Better domain modeling**: events name what *happened in the business* (e.g. `OrderPlaced`, `LicenseRevoked`) rather than what was set in a column. + +For when *not* to use it, see [ADR 0005 — Event sourcing over state-stored](../adr/0005-event-sourcing-vs-state.md). + +## The shape of an event-sourced system + +``` + ┌──────────────┐ + Command ───▶ │ Aggregate │ ──▶ produces ──▶ IDomainEvent[] + │ (write model)│ + └──────┬───────┘ + │ persisted to + ▼ + ┌──────────────┐ + │ Event Store │ (append-only) + └──────┬───────┘ + │ replayed by + ┌──────────┴──────────┐ + ▼ ▼ + ┌─────────────┐ ┌─────────────┐ + │ Projection A│ │ Projection B│ ← read models + └─────────────┘ └─────────────┘ +``` + +The aggregate decides *whether* an action is allowed and emits the events describing what happened. The event store guarantees durable, ordered persistence. Projections rebuild the read shapes the rest of the application needs. + +## The primitives Compendium gives you + +### `IDomainEvent` + +A small, mandatory contract for everything written to the log. From [`src/Core/Compendium.Core/Domain/Events/IDomainEvent.cs`](https://github.com/sassy-solutions/compendium/blob/ca25347/src/Core/Compendium.Core/Domain/Events/IDomainEvent.cs): + +```csharp +public interface IDomainEvent +{ + Guid EventId { get; } + string AggregateId { get; } + string AggregateType { get; } + DateTimeOffset OccurredOn { get; } + long AggregateVersion { get; } + int EventVersion { get; } // schema version — see "Versioning" +} +``` + +Every event carries enough metadata to be replayed deterministically and to resolve concurrency conflicts via `AggregateVersion`. + +### `AggregateRoot` + +The write model base class. From [`src/Core/Compendium.Core/Domain/Primitives/AggregateRoot.cs`](https://github.com/sassy-solutions/compendium/blob/ca25347/src/Core/Compendium.Core/Domain/Primitives/AggregateRoot.cs): + +```csharp +public abstract class AggregateRoot : Entity, IDisposable + where TId : notnull +{ + private readonly List _domainEvents = []; + + public long Version { get; private set; } + public IReadOnlyCollection DomainEvents { get; } + + // Aggregates raise events through AddDomainEvent / RaiseEvent on subclasses. +} +``` + +Aggregates raise events when business rules are satisfied and never write to a database directly. The infrastructure layer is responsible for persisting `DomainEvents` after a successful command. + +### Projections + +Projections are the read side. They consume events from the store and write to whatever shape your queries need (a SQL table, a Redis key, an in-memory dictionary). Compendium ships projection scaffolding under `Compendium.Infrastructure.Projections` — see [`src/Infrastructure/Compendium.Infrastructure/Projections/README.md`](https://github.com/sassy-solutions/compendium/blob/ca25347/src/Infrastructure/Compendium.Infrastructure/Projections/README.md) for details. + +A projection should be **idempotent**: replaying the same event twice must not corrupt the read model. This is what makes safe rebuilds, retries, and disaster recovery possible. + +## Versioning + +Real systems evolve. The `EventVersion` field on `IDomainEvent` lets you ship breaking event-schema changes without rewriting history: + +- New events are written at the current version. +- Old events stay on disk at their original version. +- `IEventUpcaster` implementations transform old versions into the latest shape *at read time*. + +See [`src/Core/Compendium.Core/EventSourcing/`](https://github.com/sassy-solutions/compendium/tree/ca25347/src/Core/Compendium.Core/EventSourcing) for the upcaster contracts. + +## Snapshots + +For aggregates with very long histories, replaying every event on every load is wasteful. Compendium supports periodic snapshots — capture the materialized aggregate state at version *N*, and only replay events after *N* on subsequent loads. This is opt-in: most aggregates do not need it. + +## Where to go next + +- [Result Pattern](result-pattern.md) — how Compendium reports success/failure without exceptions +- [Hexagonal Architecture](hexagonal-architecture.md) — how aggregates stay decoupled from infrastructure +- [Multi-tenancy](multi-tenancy.md) — how events stay scoped to the right tenant +- [ADR 0005 — Event sourcing over state-stored](../adr/0005-event-sourcing-vs-state.md) — the decision and trade-offs +- [`samples/01-QuickStart-OrderAggregate`](https://github.com/sassy-solutions/compendium/tree/main/samples/01-QuickStart-OrderAggregate) — a runnable in-memory example diff --git a/docs/concepts/hexagonal-architecture.md b/docs/concepts/hexagonal-architecture.md index 4b29163..3173c9b 100644 --- a/docs/concepts/hexagonal-architecture.md +++ b/docs/concepts/hexagonal-architecture.md @@ -1,5 +1,109 @@ # Hexagonal Architecture -> Coming soon — tracked in [POM-183](https://github.com/sassy-solutions/compendium/issues?q=is%3Aissue+POM-183). +Compendium is built around a strict hexagonal (also known as *ports and adapters*) architecture. The domain code does not know that PostgreSQL, Stripe, or ASP.NET Core exist — it only knows about *interfaces it needs*. Concrete integrations plug in from the outside. -Core / Application / Adapters, ports vs adapters, why Compendium keeps Core dependency-free. See also [ADR 0002](../adr/0002-hexagonal-architecture.md) and [ADR 0003](../adr/0003-zero-dep-core.md). +## Why hexagonal? + +The first reason is **testability**: a Core that depends on nothing external is trivially unit-testable. No database fixtures, no HTTP mocks, no dependency injection containers needed for a domain test. + +The second reason is **swap-ability**: when you discover that your billing provider needs to change, or that you need a Redis-backed projection store instead of in-memory, the domain stays untouched. You write a new adapter and re-wire DI. + +The third reason is **clarity of dependencies**: when an arrow points the wrong way (e.g. domain code references `Microsoft.Data.SqlClient`), the linter / project reference setup tells you immediately. + +For why we picked strict hexagonal over related styles (Onion, Clean), see [ADR 0002 — Hexagonal architecture](../adr/0002-hexagonal-architecture.md). For why `Compendium.Core` has zero NuGet dependencies, see [ADR 0003 — Zero-dependency Core](../adr/0003-zero-dep-core.md). + +## The layers + +``` + ┌──────────────────────────────────────────────┐ + │ Adapters (outermost) │ + │ Compendium.Adapters.PostgreSQL, .Stripe, │ + │ .Redis, .Zitadel, .AspNetCore, .Listmonk... │ + └────────────────────┬─────────────────────────┘ + │ implement + ▼ + ┌──────────────────────────────────────────────┐ + │ Abstractions (ports) │ + │ IBillingService, IIdentityProvider, │ + │ IEventStore, IEmailSender, IAIProvider... │ + └────────────────────┬─────────────────────────┘ + │ used by + ▼ + ┌──────────────────────────────────────────────┐ + │ Application (orchestration) │ + │ Command/Query handlers, dispatchers, │ + │ policies, pipeline behaviors │ + └────────────────────┬─────────────────────────┘ + │ operates on + ▼ + ┌──────────────────────────────────────────────┐ + │ Core (innermost, zero-dep) │ + │ AggregateRoot, ValueObject, Result, │ + │ Error, IDomainEvent — pure DDD primitives │ + └──────────────────────────────────────────────┘ +``` + +Dependencies only point **inward**. `Core` knows about nothing else; `Application` references `Core` and `Abstractions`; adapters reference `Abstractions` (and possibly `Core` for entity types) but never the other way around. + +## What lives where + +### `Compendium.Core` — Pure domain primitives + +Zero NuGet dependencies. Only the .NET BCL. + +Examples: + +- [`AggregateRoot`](https://github.com/sassy-solutions/compendium/blob/ca25347/src/Core/Compendium.Core/Domain/Primitives/AggregateRoot.cs) — write-side base class +- [`ValueObject`](https://github.com/sassy-solutions/compendium/blob/ca25347/src/Core/Compendium.Core/Domain/Primitives/ValueObject.cs) — equality by component +- [`Result`](https://github.com/sassy-solutions/compendium/blob/ca25347/src/Core/Compendium.Core/Results/Result.cs) — typed success/failure +- [`IDomainEvent`](https://github.com/sassy-solutions/compendium/blob/ca25347/src/Core/Compendium.Core/Domain/Events/IDomainEvent.cs) — event contract + +### `Compendium.Abstractions.*` — Ports + +Interfaces that the domain needs to do its job, but does not implement itself. + +- `Compendium.Abstractions` — generic infrastructure ports (event store, projection store, etc.) +- `Compendium.Abstractions.Identity` — identity-provider contracts +- `Compendium.Abstractions.Billing` — billing-provider contracts +- `Compendium.Abstractions.Email` — email-provider contracts +- `Compendium.Abstractions.AI` — AI-provider contracts + +Adapters implement these. Application code consumes them. The domain typically does not (the domain talks to *the application*, not to ports directly — that boundary keeps the Core pure). + +### `Compendium.Application` — Orchestration + +CQRS dispatchers, command/query handlers, pipeline behaviors. Knows about Core and Abstractions, never about a specific adapter. + +### `Compendium.Adapters.*` — Adapters + +Concrete integrations. Each adapter is an opt-in NuGet package: pick `PostgreSQL` if you want the Postgres event store; pick `Stripe` if you bill through Stripe; pick `Zitadel` for OIDC. See [Adapters](../adapters/aspnetcore.md) for the per-adapter how-tos. + +## A worked example + +Suppose you have a billing flow. The Application layer code looks roughly like: + +```csharp +public sealed class PlaceOrderHandler( + IBillingService billing, // ← port from Abstractions.Billing + IEventStore eventStore) // ← port from Abstractions + : ICommandHandler +{ + public async Task Handle(PlaceOrderCommand cmd, CancellationToken ct) + { + var customerResult = await billing.EnsureCustomerAsync(cmd.Email, ct); + if (customerResult.IsFailure) return customerResult.Error; + + // ... business logic on aggregate ... + // ... persist domain events via eventStore ... + } +} +``` + +Notice what the handler does *not* know: that `billing` is implemented by `StripeBillingService` (or `LemonSqueezyBillingService`), or that `eventStore` is backed by Postgres. Those are wiring decisions made in `Program.cs`. Replacing Stripe with LemonSqueezy is a one-line DI change — the handler does not move. + +## Where to go next + +- [Result Pattern](result-pattern.md) — the return type of all those handlers +- [Event Sourcing](event-sourcing.md) — what `IEventStore` actually persists +- [Multi-tenancy](multi-tenancy.md) — how tenant scope crosses all layers +- [ADR 0002](../adr/0002-hexagonal-architecture.md) and [ADR 0003](../adr/0003-zero-dep-core.md) — the original decisions diff --git a/docs/concepts/multi-tenancy.md b/docs/concepts/multi-tenancy.md index bedf6ea..0ece245 100644 --- a/docs/concepts/multi-tenancy.md +++ b/docs/concepts/multi-tenancy.md @@ -1,5 +1,105 @@ # Multi-tenancy -> Coming soon — tracked in [POM-183](https://github.com/sassy-solutions/compendium/issues?q=is%3Aissue+POM-183). +Compendium treats multi-tenancy as a first-class concern, not a bolt-on. Every operation flows through a `TenantContext` that is set at the request boundary, validated for consistency, and propagated to every adapter that needs it (event store, projections, billing, identity). -`TenantContext`, multi-source resolution (header / subdomain / JWT), and consistency validation. See also [ADR 0004](../adr/0004-multi-tenancy-strategy.md). +The goal: cross-tenant data leaks are *impossible by construction*, not merely "policed by code reviews." + +For the design rationale, see [ADR 0004 — Multi-tenancy strategy](../adr/0004-multi-tenancy-strategy.md). + +## Tenant identity comes from multiple sources + +In a real SaaS, the tenant identifier can arrive via several paths in the same request: + +- An explicit `X-Tenant-ID` header (machine-to-machine APIs) +- A subdomain (`acme.example.com` → tenant `acme`) +- A claim in the JWT (`tenant_id`, `org_id`, or in our case `urn:zitadel:iam:org:id`) + +When more than one source is present, Compendium **requires them to agree**. A request with `X-Tenant-ID: acme` and a JWT for tenant `globex` is rejected outright — that combination usually means a misconfigured proxy or a confused-deputy attack. + +The middleware that enforces this lives in `Compendium.Adapters.AspNetCore`. From [`TenantValidationMiddleware.cs`](https://github.com/sassy-solutions/compendium/blob/ca25347/src/Adapters/Compendium.Adapters.AspNetCore/Security/TenantValidationMiddleware.cs): + +```csharp +// Extract tenant identifiers from all sources +var sources = ExtractTenantSources(context); + +// Validate consistency across sources +var validationResult = validator.Validate(sources); + +if (validationResult.IsFailure) +{ + _logger.LogWarning( + "Tenant validation failed: {Error}. Path: {Path}", + validationResult.Error.Message, + SanitizeForLog(context.Request.Path)); + await WriteErrorResponse(context, validationResult.Error.Message, + StatusCodes.Status403Forbidden); + return; +} +``` + +The configurable bits (which header, which JWT claims, which paths to skip) live on [`TenantValidationMiddlewareOptions`](https://github.com/sassy-solutions/compendium/blob/ca25347/src/Adapters/Compendium.Adapters.AspNetCore/Security/TenantValidationMiddleware.cs#L226). + +## TenantContext is per-request, scoped DI + +Once validated, the resolved tenant lives on a scoped `TenantContext`: + +``` +HTTP Request + │ + ▼ +[TenantValidationMiddleware] + │ extracts header / subdomain / JWT + │ validates consistency + │ loads Tenant from ITenantStore + │ sets tenantContext.SetTenant(tenant) + │ + ▼ +[Endpoint / Command Handler] + │ receives TenantContext via DI + │ passes Tenant.Id to adapters + │ + ▼ +[Adapters: PostgreSQL, Stripe, Listmonk, ...] + └─ scope queries / API calls by tenant +``` + +Adapters that touch persistence read the tenant from `TenantContext` and scope every query accordingly. The contract is: **if you forgot to scope, the operation should fail loudly**, not silently return data from another tenant. + +## Isolation strategies + +Compendium does not force you into one isolation model. Three are common: + +1. **Schema-per-tenant** in a shared database. Cheap, easy to operate, decent isolation. Default for `Compendium.Adapters.PostgreSQL` setups. +2. **Database-per-tenant**. Strongest isolation, more operational overhead. Compendium supports it by switching the connection string per `TenantContext`. +3. **Row-level security (RLS)**. All tenants share tables; Postgres RLS enforces isolation. Cheapest at scale but harder to debug. + +The choice is made at infrastructure setup time, not in the domain. The domain code is identical across the three. + +## Excluded paths + +Some paths legitimately need to run without a tenant: health checks, OpenAPI specs, login endpoints. The middleware accepts an explicit allow-list: + +```csharp +public string[] ExcludedPaths { get; set; } = new[] +{ + "/health", "/healthz", "/ready", "/live", + "/metrics", + "/.well-known", + "/swagger", "/api-docs" +}; +``` + +Anything else without a resolvable tenant is rejected. + +## Pitfalls to avoid + +- **Trusting only one source**. If you read just the header and ignore the JWT, an attacker with a valid token for tenant A can pass `X-Tenant-ID: B` and you have a leak. Compendium rejects the mismatch. +- **Logging the tenant ID and user email together** without thinking about retention. See the GDPR-driven `PiiMasking` helper in `Compendium.Adapters.Shared` and the related work in POM-178 / [ADR 0004](../adr/0004-multi-tenancy-strategy.md). +- **Forgetting to scope a new query**. Make it a code-review checklist item: every new repository method must take a `TenantId` (or read it from `TenantContext`). Compendium's existing adapters set the precedent — follow it. + +## Where to go next + +- [Hexagonal Architecture](hexagonal-architecture.md) — `TenantContext` is itself a port, with concrete implementations in adapters +- [Event Sourcing](event-sourcing.md) — events carry `AggregateId`; tenancy is enforced by the store, not by the event +- [ADR 0004](../adr/0004-multi-tenancy-strategy.md) — the decision and trade-offs +- [`samples/02-MultiTenant-WithPostgres`](https://github.com/sassy-solutions/compendium/tree/main/samples/02-MultiTenant-WithPostgres) — runnable two-tenant example diff --git a/docs/concepts/result-pattern.md b/docs/concepts/result-pattern.md index b60a868..920f1bb 100644 --- a/docs/concepts/result-pattern.md +++ b/docs/concepts/result-pattern.md @@ -1,5 +1,95 @@ # Result Pattern -> Coming soon — tracked in [POM-183](https://github.com/sassy-solutions/compendium/issues?q=is%3Aissue+POM-183). +Compendium does not throw exceptions for control flow. Operations that can fail return a `Result` (or `Result` for void) carrying a typed `Error`. Exceptions are reserved for situations that are genuinely exceptional — bugs, infrastructure crashes, things you do not expect to recover from. -`Result`, `Error`, and why Compendium does not use exceptions for control flow. See also [ADR 0001](../adr/0001-result-pattern.md). +## Why a Result type? + +Three reasons: + +1. **Exceptions are invisible at the call site**. `await SaveOrder(...)` looks identical whether it can fail with `OrderNotFoundException`, `OptimisticConcurrencyException`, or nothing at all. `Result` makes the *possibility of failure* part of the type, which the compiler can enforce. + +2. **Errors carry structure, not just text**. A `404 NotFound` is not the same as a `409 Conflict` is not the same as a `validation failure`. Compendium's `Error` records that distinction explicitly so adapters (e.g. ASP.NET Core problem-details middleware) can map it to the right HTTP status without parsing strings. + +3. **Performance**. Throwing exceptions is expensive on .NET — stack capture, type lookup, finally blocks. For hot paths (event replay, command dispatching), explicit returns are dramatically cheaper. + +The trade-off is verbosity: every fallible call now has an explicit branch. We accept that. For the long discussion, see [ADR 0001 — Result pattern over exceptions](../adr/0001-result-pattern.md). + +## The shape + +From [`src/Core/Compendium.Core/Results/Result.cs`](https://github.com/sassy-solutions/compendium/blob/ca25347/src/Core/Compendium.Core/Results/Result.cs): + +```csharp +public class Result +{ + public bool IsSuccess { get; } + public bool IsFailure => !IsSuccess; + public Error Error { get; } // Error.None on success + + public static Result Success(); + public static Result Success(T value); + public static Result Failure(Error error); + // ... +} +``` + +`Result` adds `Value` (only safe to read when `IsSuccess`). + +## Errors are values + +From [`src/Core/Compendium.Core/Results/Error.cs`](https://github.com/sassy-solutions/compendium/blob/ca25347/src/Core/Compendium.Core/Results/Error.cs): + +```csharp +public sealed class Error : ValueObject +{ + public static readonly Error None = new(string.Empty, string.Empty, ErrorType.None); + + public string Code { get; } // e.g. "Order.NotFound" + public string Message { get; } // human-readable + public ErrorType Type { get; } // NotFound | Validation | Conflict | Failure | ... + public IReadOnlyDictionary Metadata { get; } +} +``` + +`ErrorType` lets generic infrastructure (HTTP layer, logging, retries) make decisions without knowing the specific business domain. + +## Typical usage + +```csharp +public async Task> GetOrderAsync(OrderId id, CancellationToken ct) +{ + var order = await _repository.FindAsync(id, ct); + if (order is null) + return Error.NotFound("Order.NotFound", $"Order {id} not found"); + + return order; // implicit conversion to Result.Success(order) +} +``` + +Composing several fallible calls without nested ifs: + +```csharp +var customerResult = await EnsureCustomer(email, ct); +if (customerResult.IsFailure) return customerResult.Error; + +var orderResult = await PlaceOrder(customerResult.Value, items, ct); +if (orderResult.IsFailure) return orderResult.Error; + +return Result.Success(); +``` + +For richer composition (`Map`, `Bind`, `Tap`, etc.) see [`Result.Extensions.cs`](https://github.com/sassy-solutions/compendium/blob/ca25347/src/Core/Compendium.Core/Results/) — but use them sparingly. Loud explicit branching is usually clearer than a chain of monadic operators. + +## Anti-patterns + +A few mistakes to avoid: + +- **Wrapping arbitrary exceptions in `Result.Failure`**. If an exception is genuinely unexpected (out of disk, null reference in your code), let it bubble. Catching everything to "return a clean Result" hides bugs. +- **Reading `result.Value` without checking `IsSuccess`**. `Result.Value` on a failed result throws. Always guard. +- **Returning `Result`**. If the answer is just "did this succeed?", use `Result` (no value). `Result.Success(false)` is almost never what you want — that is a *successful* operation that returned false, which is rarely the correct semantic. +- **Generic `Error.Failure("Something went wrong")`**. Pick a specific code. Future-you, or your HTTP layer, will need it. + +## Where to go next + +- [Hexagonal Architecture](hexagonal-architecture.md) — Result types flow through every layer +- [Event Sourcing](event-sourcing.md) — aggregates return `Result` when business rules reject a command +- [ADR 0001](../adr/0001-result-pattern.md) — the rationale and alternatives considered