Hexagonal Architecture
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.
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. For why Compendium.Core has zero NuGet dependencies, see ADR 0003 — Zero-dependency Core.
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>— write-side base classValueObject— equality by componentResult<T>— typed success/failureIDomainEvent— 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 contractsCompendium.Abstractions.Billing— billing-provider contractsCompendium.Abstractions.Email— email-provider contractsCompendium.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 for the per-adapter how-tos.
A worked example
Suppose you have a billing flow. The Application layer code looks roughly like:
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 — the return type of all those handlers
- Event Sourcing — what
IEventStoreactually persists - Multi-tenancy — how tenant scope crosses all layers
- ADR 0002 and ADR 0003 — the original decisions