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
26 changes: 26 additions & 0 deletions docs/adr/0000-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# {ADR Number}. {Title}

* Status: Proposed | Accepted | Rejected | Deprecated | Superseded by [####](####.md)
* Date: YYYY-MM-DD
* Deciders: @<github-handle>, ...

## Context

(What problem are we solving? What constraints/forces?)

## Decision

(What did we decide? Be concrete.)

## Consequences

### Positive
- ...

### Negative / Trade-offs
- ...

## Alternatives considered

- **Alternative A**: ... (why rejected)
- **Alternative B**: ... (why rejected)
46 changes: 46 additions & 0 deletions docs/adr/0001-result-pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 0001. Result pattern over exceptions for business errors

* Status: Accepted
* Date: 2026-04-25
* Deciders: @sassy-solutions/maintainers

## Context

The .NET ecosystem traditionally signals failure with exceptions. For a framework powering long-lived multi-tenant SaaS, throwing on every business rule violation has three concrete drawbacks we care about:

1. **CPU cost on the hot path.** Exception construction captures a stack trace; under load (CQRS handlers running tens of thousands of validations per second) this is measurable and avoidable.
2. **Flow readability.** Business errors expressed as `throw` are invisible to the type system. A reader of a handler cannot tell, without reading every called method, which failures are recoverable and which are bugs.
3. **Typing of business errors.** We need rich, structured errors (code, message, kind) that can be serialised at the API boundary, mapped to ProblemDetails, and asserted on in tests. Exceptions push us toward stringly-typed `Message` parsing or proliferating exception subclasses.

We also need a clear separation between *expected business outcomes* (validation failed, not found, conflict) and *unexpected situations* (database is down, OOM, programmer mistake) so observability and retry policies can treat them differently.

## Decision

Every fallible business operation returns `Result<T>` (or `Result` for void). Errors are represented as `Error` records with a stable `Code`, a human-readable `Message`, and a kind discriminator (validation, not-found, conflict, unauthorized, …).

- `Compendium.Core.Results.Result<T>` and `Error` live in the zero-dependency Core (see [ADR 0003](0003-zero-dep-core.md)).
- Command and query handlers, domain factories, and adapter operations all return `Result<T>`.
- Exceptions are reserved for: programmer bugs (assertion failures, never-meant-to-happen branches), infrastructure that genuinely cannot be a `Result` (cancellation, OOM), and a thin translation layer at the HTTP edge.
- `Result.Failure(...)` and `Result.Success(...)` are the only sanctioned construction paths. No `null` for "absent value" — use `Result.Failure(Error.NotFound(...))`.
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

This claims Result.Success(...) / Result.Failure(...) are the only sanctioned construction paths, but the current Result API also provides implicit conversions (e.g. TValue -> Result<TValue> and Error -> Result<T>). Either document these implicit conversions as sanctioned, or tighten the API if you want to enforce only factory usage.

Suggested change
- `Result.Failure(...)` and `Result.Success(...)` are the only sanctioned construction paths. No `null` for "absent value" — use `Result.Failure(Error.NotFound(...))`.
- Prefer `Result.Failure(...)` and `Result.Success(...)` as the explicit construction paths. Where the API provides implicit conversions (for example `TValue -> Result<TValue>` and `Error -> Result<T>`), those are also sanctioned convenience forms. No `null` for "absent value" — use `Result.Failure(Error.NotFound(...))`.

Copilot uses AI. Check for mistakes.

## Consequences

### Positive
- Errors are part of the type signature → handler contracts are self-describing.
- Cheaper than `throw` on validation-heavy hot paths.
- Trivial to map at the boundary: one `Result.Match` → ProblemDetails or HTTP status.
- Tests assert on `Error.Code`, not on string-matched exception messages.
- Forces authors to think about each failure mode at the call site.

### Negative / Trade-offs
- More verbose than throwing — every call site decides to bubble up, recover, or transform.
- Learning curve for .NET developers used to exceptions; PR review must enforce the pattern.
- Mixing `Result` and `try/catch` in the same method requires discipline; we accept the boilerplate.
- No language-level `?` operator like Rust — composition relies on extension methods (`Map`, `Bind`, `Match`).

## Alternatives considered

- **Exceptions everywhere.** Rejected: hides failure modes from the type system, expensive on hot paths, conflates bugs with business outcomes.
- **LanguageExt `Either<L, R>`.** Rejected: large dependency surface, opinionated FP idioms, and pulls a non-trivial transitive graph into Core — incompatible with [ADR 0003](0003-zero-dep-core.md).
- **OneOf discriminated unions.** Rejected: flexible but unopinionated; we want a single canonical `Error` shape across the framework, not per-operation union types.
- **Nullable reference types as the "failure" signal.** Rejected: can express absence but not *why* — and silent `null`s are exactly what we want to eliminate.
50 changes: 50 additions & 0 deletions docs/adr/0002-hexagonal-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 0002. Hexagonal architecture (strict)

* Status: Accepted
* Date: 2026-04-25
* Deciders: @sassy-solutions/maintainers

## Context

Compendium powers an event-sourced, multi-tenant SaaS (Nexus). The codebase has to remain testable and replaceable across a long horizon: identity providers change (Zitadel today, something else tomorrow), billing providers change, persistence may evolve, and consumers integrate with a long tail of external systems.

Two forces dominate:

1. **Domain stability.** The event-sourced domain — aggregates, invariants, events — should be immune to churn from any specific HTTP framework, ORM, or third-party SDK.
2. **Adapter swappability.** We ship multiple adapters for the same port (PostgreSQL today, others foreseeable; Listmonk, LemonSqueezy, OpenRouter, …). Consumers must be able to pick a subset, swap one out, or stub one in tests without touching domain code.

A naive "layered" architecture where `Application` references EF Core, or where `Core` knows about HTTP, would couple us to those choices for the lifetime of the framework.

## Decision

Strict hexagonal (ports & adapters):

- **Core** (`Compendium.Core`) — domain primitives, aggregates, value objects, `Result<T>`, `Error`, domain events. Zero NuGet dependencies (see [ADR 0003](0003-zero-dep-core.md)).
- **Abstractions** (`Compendium.Abstractions.*`) — ports as interfaces only. One assembly per concern: `Identity`, `Billing`, `Email`, `AI`. No implementations, no transitive SDK dependencies.
- **Application** (`Compendium.Application`) — orchestration: CQRS dispatchers, handlers, pipelines. Depends on Core + Abstractions, never on a concrete adapter.
- **Infrastructure** (`Compendium.Infrastructure`) — generic infrastructure building blocks (projections, outbox, caching primitives) that aren't tied to a specific vendor.
- **Adapters** (`Compendium.Adapters.*`) — concrete implementations: PostgreSQL event store, Redis cache, Zitadel OIDC, ASP.NET Core middleware, Listmonk, LemonSqueezy, OpenRouter. Each adapter implements ports from `Abstractions.*`.
- **Multitenancy** (`Compendium.Multitenancy`) — cross-cutting tenant resolution and scoping (see [ADR 0004](0004-multi-tenancy-strategy.md)).

Direction of dependency is enforced: **Adapters → Application → Abstractions → Core**. Core never knows that adapters exist. The dependency rule is checked by the architecture tests in `tests/Architecture`.

## Consequences

### Positive
- Unit tests for handlers and aggregates run in-memory with fakes — no database, no HTTP, no Docker.
- Replacing an adapter (e.g. swapping email provider) is a one-line registration change; nothing in `Core`, `Abstractions`, or `Application` moves.
- Clear extension story: a consumer ships its own `Compendium.Adapters.*` package and registers it.
- The boundary forces designers to name each port explicitly, which surfaces missing abstractions early.

### Negative / Trade-offs
- More boilerplate than a "just call EF Core directly" style: DI registration, mapping between domain and persistence shapes, port definitions before any code runs.
- More projects in the solution → slower cold builds, more `csproj` files to maintain.
- Newcomers need to learn "where does this go?" (port vs. adapter vs. application service). Documented in `CONTRIBUTING.md`.
- Some integrations are awkward to fit into a port (e.g. webhooks initiated by the third party); we accept adapter-specific surface there.

## Alternatives considered

- **Onion architecture.** Rejected — semantically close, but the canonical layering is fuzzier about whether infrastructure depends on application or vice versa. We want the boundary explicit, not "concentric".
- **Clean Architecture (Uncle Bob).** Rejected — adds a `UseCases` layer between application and entities that, for a framework (not an application), duplicates what handlers already do.
- **Layered "N-tier" (Web → Service → Data).** Rejected — couples the domain to a persistence shape and bakes the HTTP framework into the dependency graph.
- **No architectural boundary; one assembly.** Rejected — fine for a single app, fatal for a framework whose users will pick adapters à la carte.
55 changes: 55 additions & 0 deletions docs/adr/0003-zero-dep-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 0003. Zero-dependency Core

* Status: Accepted
* Date: 2026-04-25
* Deciders: @sassy-solutions/maintainers

## Context

`Compendium.Core` is referenced (directly or transitively) by every other Compendium package and by every consumer application. Anything we add to `Core`'s dependency graph propagates to:

- Every CQRS handler in every consumer.
- Every test project that touches a domain primitive.
- Every adapter, regardless of which third-party SDK it actually wraps.

Three risks follow from that fan-out:

1. **Version conflicts.** A `Microsoft.Extensions.*` minor bump in Core can collide with a consumer pinned to a different version, especially in older `netstandard2.0` consumers.
2. **Surface coupling.** Pulling Newtonsoft.Json into Core means every consumer ships Newtonsoft, even those standardised on `System.Text.Json`.
3. **Supply-chain blast radius.** A compromised package in Core's graph reaches every Compendium user. Reducing the graph reduces the attack surface.

A framework's core domain primitives — aggregates, value objects, `Result<T>`, `Error` — don't *need* anything beyond the .NET BCL. Allowing dependencies "because it's convenient" is a one-way door.

## Decision

`Compendium.Core` has **zero NuGet package references**. Only types from the .NET BCL (the runtime that ships with the target TFM) are allowed.

Concretely, the rule covers:

- No `Microsoft.Extensions.*` (logging, DI, options, configuration). Core types take primitives, not `ILogger<T>`.
- No JSON libraries (`Newtonsoft.Json`, `System.Text.Json` is BCL — but Core does no JSON).
- No FP libraries (`LanguageExt`, `OneOf`, …) — we ship our own `Result<T>` / `Error` (see [ADR 0001](0001-result-pattern.md)).
- No reflection-heavy mappers, no source generators consumed by Core itself.
- The `Compendium.Core.csproj` is the source of truth: it has an `<!-- No external dependencies for Core -->` marker and contains no `<PackageReference>` entries other than `InternalsVisibleTo` plumbing.

Logging, telemetry, and DI plumbing live in `Compendium.Application`, `Compendium.Infrastructure`, or the adapters — never in `Core`.

## Consequences

### Positive
- `Compendium.Core` works on every TFM we target with zero version-conflict risk.
- The supply-chain attack surface for the most-imported package is the .NET BCL itself.
- Pure Core forces domain code to be expressed in domain terms — if you reach for a logger inside an aggregate, you've made a design mistake.
- Cold-start cost is minimal; Core can be loaded into trim-aggressive contexts (AOT, tiny self-contained apps).

### Negative / Trade-offs
- We re-implement small utilities that already exist in popular libraries (`Result<T>`, `Error`, simple guards). Accepted — they're small, stable, and tested.
- Domain code can't log or emit metrics directly; it must surface state and let the orchestration layer handle observability. We consider this a feature, not a bug.
- Contributors must occasionally be told "no, that NuGet package can't go in Core". Codified in `CONTRIBUTING.md` and enforced in PR review and architecture tests.

## Alternatives considered

- **Allow `Microsoft.Extensions.*` in Core.** Rejected — couples Core to the ASP.NET Core release cadence and ecosystem, even for consumers who don't use ASP.NET Core. The ergonomic win (built-in `ILogger<T>`) is small; the lock-in is large.
- **Allow `System.Text.Json` for serialisation helpers.** Rejected — Core doesn't need to serialise. Serialisation is an adapter concern (event store, transport).
- **Allow a tiny FP helper library (e.g. LanguageExt.Core).** Rejected — see [ADR 0001](0001-result-pattern.md). The dependency cost outweighs the convenience for the four or five types we'd use.
- **No formal rule, just discipline.** Rejected — without an enforced rule, "just one small dep" accumulates and the property is silently lost.
51 changes: 51 additions & 0 deletions docs/adr/0004-multi-tenancy-strategy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 0004. Multi-tenancy strategy

* Status: Accepted
* Date: 2026-04-25
* Deciders: @sassy-solutions/maintainers

## Context

Compendium is SaaS-first: every domain operation must know which tenant it acts on, and a request from tenant A must never read or write data belonging to tenant B. The tenant identifier shows up in several places that a real request can carry simultaneously:

- An explicit header (`X-Tenant-ID`) set by an API gateway or SDK.
- A subdomain (`acme.example.com`) used for branded login and marketing routes.
- One or more JWT claims — Zitadel exposes `urn:zitadel:iam:org:id`; generic OIDC providers use `org_id` or `tenant_id`.

A real request can carry several of these *at once* (e.g. JWT + subdomain on a tenant-branded portal). If they disagree, the request is either misconfigured or actively malicious — and we want a default that fails closed.

The decision lives at the framework layer because every consumer needs the same guarantees. Doing it per-app would mean each consumer re-invents tenant resolution (and at least one would get it subtly wrong).

## Decision

The framework provides `Compendium.Multitenancy` with these moving parts:

- **`TenantContext`** — a small immutable record carrying `TenantId` plus its provenance (which source produced it). Registered as a scoped service; one per request.
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

This describes TenantContext as an immutable record with provenance and as a scoped "one per request" service. In the current codebase, TenantContext is a mutable class backed by AsyncLocal, and DI registers tenant context/accessor as singletons (per-async-flow isolation rather than per-request scope). Please update the ADR to match the actual types/lifetimes (or adjust the implementation to match the ADR).

Copilot uses AI. Check for mistakes.
- **`TenantResolver` / `JwtClaimTenantResolver`** — pluggable resolvers that read the tenant from a specific source (header, subdomain, JWT). Multiple resolvers run per request.
- **`TenantConsistencyValidator`** (`ITenantConsistencyValidator`) — runs after resolution and rejects the request if two resolvers produced different non-empty tenant IDs. Default policy: **fail closed** — any disagreement → reject.
- **`TenantContextAccessor`** — the only sanctioned read path for downstream code (handlers, repositories, adapters).
- **`TenantPropagatingDelegatingHandler`** — propagates the resolved tenant on outbound HTTP calls, so adapter calls (Stripe, Listmonk, …) keep tenant context.
- **`TenantIsolation`** — guard rails used by the PostgreSQL adapter to scope every query to the resolved tenant.

The framework does **not** prescribe a physical isolation model (shared DB vs. DB-per-tenant vs. RLS). It enforces *logical* tenant scoping at every layer it owns; consumers choose physical isolation when they wire the adapter.

## Consequences

### Positive
- Cross-tenant access is impossible by default: any code path that forgets to scope a query reads from `TenantContext`, and `TenantContext` is request-scoped and validated.
- Multi-source consistency catches misconfigured gateways (e.g. JWT for tenant A but `X-Tenant-ID` for tenant B) and trivial token-replay attempts.
Comment on lines +34 to +36
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

These bullets imply the tenant-scoping guarantees come from TenantContext being request-scoped. Given TenantContextAccessor/ITenantContext are registered as singletons and rely on AsyncLocal, the guarantee is different (async-context scoping). The ADR should describe the actual isolation mechanism so readers don't assume ASP.NET Core DI scoping is the enforcement point.

Copilot uses AI. Check for mistakes.
- Tenant context is propagated to outbound calls, so audit logs and third-party requests carry the correct identity.
- Consumers stay free to pick the physical isolation model that fits their compliance posture.

### Negative / Trade-offs
- **Detection-side attack surface.** The validator itself is now a security-critical component: a bug that returns "consistent" when sources disagree silently breaks isolation. Mitigated with focused unit tests and contract tests in `tests/Architecture`.
- **Failure mode is loud.** A configuration drift between gateway and IdP rejects every request from that path. We consider this correct behaviour, but it means careful rollout for the multi-source policy.
- **Resolver order matters subtly.** When only one source is present, that source wins; this is by design but documented because it surprises newcomers.
- Header-based resolvers must only be trusted when set by an authenticated gateway — explicit warning in `CONTRIBUTING.md`.

## Alternatives considered

- **Single source only (header).** Rejected — fine for trusted-gateway deployments, but doesn't catch the "JWT and gateway disagree" class of bugs, and rules out branded-subdomain login flows.
- **Database-per-tenant as the only model.** Rejected — strong isolation but operationally heavy (migrations × tenants), and the framework should not force that cost on small consumers.
- **Postgres Row-Level Security as the only mechanism.** Rejected as the *sole* mechanism — RLS is an excellent backstop and consumers can use it, but framework-level scoping is needed for non-Postgres adapters (Redis, third-party APIs).
- **No framework support; let consumers solve it.** Rejected — guarantees inconsistency across consumers and recreates the very class of bugs the framework exists to prevent.
Loading
Loading