๐ Library Sample
June 11, 2026 ยท View on GitHub
This sample shows a full Library system built on the entire Cratis Stack across two independent microservices โ Lending and Members โ composed via an Aspire AppHost with the complete local infrastructure.
๐ Quick Start
cd Library
./run-mongodb.sh
Once everything is up, open the Aspire dashboard at http://localhost:15888 to watch all services start. Then visit:
- Lending app โ http://localhost:7000
- Members app โ http://localhost:7001
- Chronicle Workbench โ http://localhost:8080 โ browse the event log, projections, and read models
Log in at the Keycloak prompt or use development headers to skip authentication (see Identity & Multi-Tenancy below).
๐ Identity & Multi-Tenancy
Both Lending and Members implement the Cratis Arc identity system, which gives you two ways to work as a specific user:
- Keycloak (full auth flow) โ go through the Keycloak login page.
- Development headers (no auth required) โ set HTTP headers to impersonate any user directly. This works when accessing the backends or frontends directly (ports 5000/5001 and 9000/9001), bypassing the AuthProxy.
Multi-tenancy is also showcased: each service exposes two library branches as tenants (central and westside), selectable without any external infrastructure when working in development mode.
Logging in via Keycloak
When you open the app through the AuthProxy (ports 7000/7001), Keycloak handles authentication. Use these pre-configured accounts:
Lending realm (http://localhost:8090/realms/lending)
| Username | Password | Role |
|---|---|---|
librarian | librarian | Head Librarian |
borrower | borrower | Regular Borrower |
Members realm (http://localhost:8091/realms/members)
| Username | Password | Name |
|---|---|---|
alice | alice | Alice Smith |
bob | bob | Bob Jones |
Development Identity โ No Auth Required
When running without the AuthProxy (direct access on ports 5000/5001 and 9000/9001), the Arc identity system reads the Microsoft Identity Platform headers instead of a real JWT. Set these three headers on every request to impersonate a user:
| Header | Purpose |
|---|---|
X-MS-CLIENT-PRINCIPAL-ID | User's unique identifier (GUID) |
X-MS-CLIENT-PRINCIPAL-NAME | User's display name |
X-MS-CLIENT-PRINCIPAL | Base64-encoded JSON claims payload |
Members users
Alice Smith
X-MS-CLIENT-PRINCIPAL-ID: 00000000-1000-0000-0000-000000000001
X-MS-CLIENT-PRINCIPAL-NAME: Alice Smith
X-MS-CLIENT-PRINCIPAL: eyJhdXRoX3R5cCI6ImFhZCIsImNsYWltcyI6W3sidHlwIjoiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZWlkZW50aWZpZXIiLCJ2YWwiOiIwMDAwMDAwMC0xMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDEifSx7InR5cCI6Im5hbWUiLCJ2YWwiOiJBbGljZSBTbWl0aCJ9LHsidHlwIjoicHJlZmVycmVkX3VzZXJuYW1lIiwidmFsIjoiYWxpY2VAbWVtYmVycy5sb2NhbCJ9XSwibmFtZV90eXAiOiJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIiwicm9sZV90eXAiOiJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUifQ==
Bob Jones
X-MS-CLIENT-PRINCIPAL-ID: 00000000-1000-0000-0000-000000000002
X-MS-CLIENT-PRINCIPAL-NAME: Bob Jones
X-MS-CLIENT-PRINCIPAL: eyJhdXRoX3R5cCI6ImFhZCIsImNsYWltcyI6W3sidHlwIjoiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZWlkZW50aWZpZXIiLCJ2YWwiOiIwMDAwMDAwMC0xMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDIifSx7InR5cCI6Im5hbWUiLCJ2YWwiOiJCb2IgSm9uZXMifSx7InR5cCI6InByZWZlcnJlZF91c2VybmFtZSIsInZhbCI6ImJvYkBtZW1iZXJzLmxvY2FsIn1dLCJuYW1lX3R5cCI6Imh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiLCJyb2xlX3R5cCI6Imh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvcm9sZSJ9
Lending users
Head Librarian
X-MS-CLIENT-PRINCIPAL-ID: 00000000-2000-0000-0000-000000000001
X-MS-CLIENT-PRINCIPAL-NAME: Head Librarian
X-MS-CLIENT-PRINCIPAL: eyJhdXRoX3R5cCI6ImFhZCIsImNsYWltcyI6W3sidHlwIjoiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZWlkZW50aWZpZXIiLCJ2YWwiOiIwMDAwMDAwMC0yMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDEifSx7InR5cCI6Im5hbWUiLCJ2YWwiOiJIZWFkIExpYnJhcmlhbiJ9LHsidHlwIjoicHJlZmVycmVkX3VzZXJuYW1lIiwidmFsIjoibGlicmFyaWFuQGxpYnJhcnkubG9jYWwifV0sIm5hbWVfdHlwIjoiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZSIsInJvbGVfdHlwIjoiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIn0=
Regular Borrower
X-MS-CLIENT-PRINCIPAL-ID: 00000000-2000-0000-0000-000000000002
X-MS-CLIENT-PRINCIPAL-NAME: Regular Borrower
X-MS-CLIENT-PRINCIPAL: eyJhdXRoX3R5cCI6ImFhZCIsImNsYWltcyI6W3sidHlwIjoiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZWlkZW50aWZpZXIiLCJ2YWwiOiIwMDAwMDAwMC0yMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDIifSx7InR5cCI6Im5hbWUiLCJ2YWwiOiJSZWd1bGFyIEJvcnJvd2VyIn0seyJ0eXAiOiJwcmVmZXJyZWRfdXNlcm5hbWUiLCJ2YWwiOiJib3Jyb3dlckBsaWJyYXJ5LmxvY2FsIn1dLCJuYW1lX3R5cCI6Imh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiLCJyb2xlX3R5cCI6Imh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvcm9sZSJ9
Example: calling the API as Alice
curl -s http://localhost:5001/api/members/profiles \
-H "X-MS-CLIENT-PRINCIPAL-ID: 00000000-1000-0000-0000-000000000001" \
-H "X-MS-CLIENT-PRINCIPAL-NAME: Alice Smith" \
-H "X-MS-CLIENT-PRINCIPAL: eyJhdXRoX3R5cCI6ImFhZCIsImNsYWltcyI6W3sidHlwIjoiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZWlkZW50aWZpZXIiLCJ2YWwiOiIwMDAwMDAwMC0xMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDEifSx7InR5cCI6Im5hbWUiLCJ2YWwiOiJBbGljZSBTbWl0aCJ9LHsidHlwIjoicHJlZmVycmVkX3VzZXJuYW1lIiwidmFsIjoiYWxpY2VAbWVtYmVycy5sb2NhbCJ9XSwibmFtZV90eXAiOiJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIiwicm9sZV90eXAiOiJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUifQ=="
See the Microsoft Identity Platform headers reference for a full explanation of how Arc reads these headers.
Lens โ Switch Users & Tenants in the Browser
Direct access only (ports 9000/9001). Lens injects
X-MS-CLIENT-PRINCIPAL-*headers, which are ignored when going through the AuthProxy (ports 7000/7001) โ those ports authenticate via Keycloak. Use Lens when accessing the Vite dev server directly.
Lens is a browser extension that injects the X-MS-CLIENT-PRINCIPAL-* headers automatically on every request, and lets you switch between pre-defined users and tenants from a toolbar popup. It eliminates the need to manually craft headers when exploring the apps in the browser.
Install Lens, configure the user list for each service (using the IDs and base64 payloads above), and switch identity with a single click โ no Keycloak login required when accessing the frontend apps directly.
Multi-Tenancy
Both services expose two tenants representing branches of the same library system:
| Tenant ID | Display Name |
|---|---|
central | Central Library |
westside | Westside Branch |
To target a specific tenant, set the Tenant-ID header on your request (or select the tenant in Lens):
curl -s http://localhost:5000/api/lending/inventory \
-H "Tenant-ID: westside" \
-H "X-MS-CLIENT-PRINCIPAL-ID: 00000000-2000-0000-0000-000000000001" \
-H "X-MS-CLIENT-PRINCIPAL-NAME: Head Librarian" \
-H "X-MS-CLIENT-PRINCIPAL: eyJhdXRoX3R5cCI6ImFhZCIsImNsYWltcyI6W3sidHlwIjoiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZWlkZW50aWZpZXIiLCJ2YWwiOiIwMDAwMDAwMC0yMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDEifSx7InR5cCI6Im5hbWUiLCJ2YWwiOiJIZWFkIExpYnJhcmlhbiJ9LHsidHlwIjoicHJlZmVycmVkX3VzZXJuYW1lIiwidmFsIjoibGlicmFyaWFuQGxpYnJhcnkubG9jYWwifV0sIm5hbWVfdHlwIjoiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZSIsInJvbGVfdHlwIjoiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIn0="
How It Works โ ICanProvideUsers & ICanProvideTenants
The Arc identity system discovers implementations of ICanProvideUsers and ICanProvideTenants automatically at startup (via IInstancesOf<T>). In debug builds, each service registers its own set:
Members/Development/Users.cs โ provides Alice and Bob to the Arc dev tooling:
#if DEBUG
public class Users : ICanProvideUsers
{
public Task<IEnumerable<User>> Provide() => Task.FromResult<IEnumerable<User>>(
[
CreateUser("00000000-1000-0000-0000-000000000001", "Alice Smith", "alice@members.local"),
CreateUser("00000000-1000-0000-0000-000000000002", "Bob Jones", "bob@members.local"),
]);
}
#endif
Members/Development/Tenants.cs โ provides the two library branches:
#if DEBUG
public class Tenants : ICanProvideTenants
{
public Task<IEnumerable<Tenant>> Provide() => Task.FromResult<IEnumerable<Tenant>>(
[
new Tenant("central", "Central Library"),
new Tenant("westside", "Westside Branch"),
]);
}
#endif
Lending has identical files under Lending/Development/ with its own set of users (librarian and borrower). Because both files are wrapped in #if DEBUG, they are stripped from Release builds automatically.
๐๏ธ Architecture
Library/
โโโ Lending/ โ Book lending domain (catalogue, loans, reservations)
โโโ Members/ โ Member management domain (registration, profiles)
โโโ Composition/ โ Aspire AppHost wiring the full local stack
Infrastructure (Composition)
| Service | Description |
|---|---|
| Chronicle | Event-sourced kernel โ stores all events and drives projections |
| HashiCorp Vault | Dev-mode secret store for Chronicle compliance encryption keys |
| Keycloak (lending) | OIDC provider for the Lending realm (librarian / borrower users) |
| Keycloak (members) | OIDC provider for the Members realm (alice / bob users) |
| AuthProxy (lending) | Authenticates and proxies requests to the Lending backend + frontend |
| AuthProxy (members) | Authenticates and proxies requests to the Members backend + frontend |
๐ Vertical Slices
Both microservices are structured around vertical slices โ each feature folder contains backend C#, TypeScript/React frontend, and automated specs side by side.
๐ Pre-requisites
๐๏ธ Database Backends
Chronicle supports multiple storage backends. The Composition AppHost and docker-compose files both support the same set of profiles. MongoDB is the default when no database type is specified.
| Database | DATABASE_TYPE value | Notes |
|---|---|---|
| MongoDB | mongodb (default) | Uses Chronicle development image (embedded MongoDB) |
| PostgreSQL | postgresql | Starts a postgres:16 container |
| Microsoft SQL Server | mssql | Starts mcr.microsoft.com/mssql/server:2022-latest |
| SQLite | sqlite | File-based; no extra container |
The DATABASE_TYPE environment variable is read by the Aspire AppHost and controls:
- Which database container is started alongside Chronicle
- Which Chronicle projection sink type is injected into the Lending and Members backends
The sink type can also be overridden independently at any time via the Cratis__Chronicle__DefaultSinkTypeId environment variable on each backend process.
๐ Running with Aspire (recommended)
Use the convenience bash scripts from the Library/ folder:
# MongoDB (default)
./run-mongodb.sh
# PostgreSQL
./run-postgresql.sh
# Microsoft SQL Server
./run-mssql.sh
# SQLite
./run-sqlite.sh
Or set DATABASE_TYPE directly:
DATABASE_TYPE=postgresql dotnet run --project Composition/Composition.csproj
The Aspire dashboard opens automatically at http://localhost:15888 (no login token required).
โ ๏ธ Local development only โ The docker-compose files embed hardcoded credentials (PostgreSQL password
chronicle, SQL Server SA passwordChronicle_Str0ng!, Vault root tokenroot, Keycloak adminadmin/admin). These are intentionally weak and must never be used in any environment beyond your local machine.
๐ณ Running with docker compose
Pass the --docker flag to a script, or use docker compose profiles directly:
# MongoDB (default โ profile can be omitted)
docker compose --profile mongodb -f docker-compose.yml up -d
# PostgreSQL
docker compose --profile postgresql -f docker-compose.yml up -d
# Microsoft SQL Server
docker compose --profile mssql -f docker-compose.yml up -d
# SQLite
docker compose --profile sqlite -f docker-compose.yml up -d
After the infrastructure is running, start the backends and frontends manually:
# Lending backend (port 5000)
cd Lending && dotnet run
# Members backend (port 5001)
cd Members && dotnet run
# Lending frontend (port 9000)
cd Lending && yarn dev
# Members frontend (port 9001)
cd Members && yarn dev
๐ Service URLs
Aspire
| Service | URL |
|---|---|
| Lending app (via AuthProxy) | http://localhost:7000 |
| Members app (via AuthProxy) | http://localhost:7001 |
| Lending backend Swagger | http://localhost:5000/swagger |
| Members backend Swagger | http://localhost:5001/swagger |
| Lending frontend (direct) | http://localhost:9000 |
| Members frontend (direct) | http://localhost:9001 |
| Chronicle Workbench | http://localhost:8080 |
| HashiCorp Vault | http://localhost:8200 |
| Keycloak โ Lending | http://localhost:8090 |
| Keycloak โ Members | http://localhost:8091 |
| Aspire Dashboard | http://localhost:15888 |
Note: The backend root (
/) returns 404 because no static files are built in Aspire dev mode โ the frontend is served by the Vite dev servers at 9000/9001. Use the/swaggerpath to verify the backend is alive, or access the full app via AuthProxy.
docker compose
| Service | URL |
|---|---|
| Lending backend | http://localhost:5000 |
| Members backend | http://localhost:5001 |
| Lending frontend | http://localhost:9000 |
| Members frontend | http://localhost:9001 |
| Chronicle Workbench | http://localhost:8080 |
| HashiCorp Vault | http://localhost:8200 |
| Keycloak โ Lending | http://localhost:8090 |
| Keycloak โ Members | http://localhost:8091 |
๐ Inspecting the Data
Chronicle Workbench
The Chronicle Workbench at http://localhost:8080 lets you browse the event log, observe projections, and inspect read models directly โ regardless of which database backend is running. This is the primary tool for understanding what Chronicle has stored.
HashiCorp Vault
Vault stores the compliance encryption keys that Chronicle uses to encrypt personal data fields. In dev mode it runs with a fixed root token of root.
Web UI โ open http://localhost:8200/ui, choose Token authentication, and enter root.
Navigate to Secrets โ secret to browse the KV v2 store. Chronicle writes keys under:
secret/<event-store>/<namespace>/<identifier>/<revision>
For example, a key for the default event store and namespace would appear at:
secret/system/default/<encryption-key-id>/1
CLI โ set the Vault address and token, then list or read secrets:
export VAULT_ADDR=http://localhost:8200
export VAULT_TOKEN=root
# List all secret paths (top-level)
vault kv list secret/
# List keys for a specific event store and namespace
vault kv list secret/system/default/
# Read a specific key
vault kv get secret/system/default/<identifier>/1
MongoDB
Applies when DATABASE_TYPE=mongodb (the default).
MongoDB runs on port 27017 in both Aspire and docker-compose. In docker-compose mode it is embedded inside the Chronicle development image; in Aspire mode it runs as a separate managed container.
Connection string: mongodb://localhost:27017
Recommended tools:
- MongoDB Compass โ GUI, connect with the connection string above
mongoshCLI:
mongosh mongodb://localhost:27017
# Chronicle stores event logs in the chronicle-db database
use chronicle-db
# List collections
show collections
# Inspect event log entries
db.event_log.find().limit(10)
PostgreSQL
Applies when DATABASE_TYPE=postgresql.
| Setting | Value |
|---|---|
| Host | localhost |
| Port | 5432 |
| Database | chronicle |
| Username | chronicle |
| Password | chronicle |
Recommended tools:
- pgAdmin โ full GUI, register a server with the credentials above
- TablePlus / DBeaver โ cross-database GUI clients
psqlCLI:
psql -h localhost -U chronicle -d chronicle
# List all tables
\dt
# Show the event log table
SELECT * FROM event_log LIMIT 10;
# Count events by event store
SELECT event_store_id, COUNT(*) FROM event_log GROUP BY event_store_id;
Microsoft SQL Server
Applies when DATABASE_TYPE=mssql.
| Setting | Value |
|---|---|
| Host | localhost |
| Port | 1433 |
| Database | chronicle |
| Username | sa |
| Password | Chronicle_Str0ng! |
Recommended tools:
- Azure Data Studio โ free, cross-platform GUI
- DBeaver โ cross-database GUI client
sqlcmdCLI:
sqlcmd -S "localhost,1433" -U sa -P "Chronicle_Str0ng!" -d chronicle
-- List tables
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE';
GO
-- Sample event log rows
SELECT TOP 10 * FROM event_log;
GO
SQLite
Applies when DATABASE_TYPE=sqlite. The database file lives inside the Chronicle container at /data/chronicle.db. It is persisted in a named Docker volume (chronicle-sqlite-data).
Copy the file out of the running container:
# Find the container name (look for chronicle)
docker ps --format '{{.Names}}' | grep chronicle
# Copy the database file to the current directory
docker cp <container-name>:/data/chronicle.db ./chronicle.db
Or open it directly with an interactive shell:
docker exec -it <container-name> sh
sqlite3 /data/chronicle.db
-- List tables
.tables
-- Sample event log rows
SELECT * FROM event_log LIMIT 10;
Recommended desktop tool: DB Browser for SQLite โ open the copied chronicle.db file directly.
๐งช Running specifications
# From the Library root
dotnet test Library.slnx
๐๏ธ Cratis Stack
| Project | Description |
|---|---|
| Specifications | BDD Specification by Example for xUnit |
| Application Model (Arc) | Cratis application model |
| Chronicle | Event sourcing kernel |
| AuthProxy | Authentication reverse proxy |
For more details see cratis.io.