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
104 changes: 102 additions & 2 deletions docs/concepts/event-sourcing.md
Original file line number Diff line number Diff line change
@@ -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<TId>`

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<TId> : Entity<TId>, IDisposable
where TId : notnull
{
private readonly List<IDomainEvent> _domainEvents = [];

public long Version { get; private set; }
public IReadOnlyCollection<IDomainEvent> 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
108 changes: 106 additions & 2 deletions docs/concepts/hexagonal-architecture.md
Original file line number Diff line number Diff line change
@@ -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<TId>`](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<T>`](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<PlaceOrderCommand>
{
public async Task<Result> 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
104 changes: 102 additions & 2 deletions docs/concepts/multi-tenancy.md
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading