Tenant-scoped checklists

August 23, 2026 · View on GitHub

Tenant-scoped checklists

One Arc API · one Chronicle store · one namespace per tenant

Header tenancy · Model-bound CQRS · Typed event sources · MongoDB read models


This backend-only sample shows the smallest useful Arc + Chronicle multi-tenant workflow: add a checklist item, then read its projected view. The same ItemId can exist in tenant A, tenant B, and Default without sharing events or read models.

Architecture

flowchart LR
    A[Tenant A client] -->|x-cratis-tenant-id: tenant-a| Arc[Arc generated API]
    B[Tenant B client] -->|x-cratis-tenant-id: tenant-b| Arc
    D[No tenant header] -->|Default| Arc

    Arc --> Resolver[Arc tenant resolver]
    Resolver --> Namespace[TenantNamespaceResolver]

    Namespace --> NA[(Chronicle / tenant-a)]
    Namespace --> NB[(Chronicle / tenant-b)]
    Namespace --> ND[(Chronicle / Default)]

    NA --> RMA[(Item read model)]
    NB --> RMB[(Item read model)]
    ND --> RMD[(Item read model)]

options.UseHeaderTenancy() reads Arc's standard x-cratis-tenant-id header. Arc's Chronicle integration installs TenantNamespaceResolver, which maps the current tenant to a Chronicle namespace; an absent header and the explicit tenant name Default both select Chronicle's default namespace. The tenant id is therefore not duplicated on events.

Run it

Prerequisites: the repository's .NET 10 SDK, Docker, and curl.

From the repository root, start the Chronicle development container. It exposes Chronicle on 35000 and its embedded MongoDB on 27017:

docker run --rm --name chronicle-multitenancy-sample \
  -p 27017:27017 \
  -p 35000:35000 \
  cratis/chronicle:latest-development

In another terminal:

dotnet run --project Chronicle/MultiTenancy/MultiTenancy.csproj

The sample listens on http://localhost:5097 through its launch profile.

Try two tenants

Use one stable event-source identifier in every request:

ITEM_ID=6d88cc61-4b5a-4c29-a9cd-12d659b1671e

Add the item for tenant A:

curl --request POST http://localhost:5097/api/items/adding/add-item \
  --header 'Content-Type: application/json' \
  --header 'x-cratis-tenant-id: tenant-a' \
  --data "{\"itemId\":\"${ITEM_ID}\",\"text\":\"Prepare tenant A release\"}"

Add a different fact at the same ItemId for tenant B:

curl --request POST http://localhost:5097/api/items/adding/add-item \
  --header 'Content-Type: application/json' \
  --header 'x-cratis-tenant-id: tenant-b' \
  --data "{\"itemId\":\"${ITEM_ID}\",\"text\":\"Review tenant B metrics\"}"

Read each tenant's projected item through the model-bound query:

curl --get http://localhost:5097/api/items/listing/item-by-id \
  --header 'x-cratis-tenant-id: tenant-a' \
  --data-urlencode "id=${ITEM_ID}"

curl --get http://localhost:5097/api/items/listing/item-by-id \
  --header 'x-cratis-tenant-id: tenant-b' \
  --data-urlencode "id=${ITEM_ID}"

The first response contains Prepare tenant A release; the second contains Review tenant B metrics. Chronicle projections are asynchronous, so repeat a read if it races the first projection update.

To use the third isolation boundary, omit the header. This writes and reads the same identifier in Default:

curl --request POST http://localhost:5097/api/items/adding/add-item \
  --header 'Content-Type: application/json' \
  --data "{\"itemId\":\"${ITEM_ID}\",\"text\":\"Check the default workspace\"}"

curl --get http://localhost:5097/api/items/listing/item-by-id \
  --data-urlencode "id=${ITEM_ID}"

Code tour

FilePurpose
Program.csSelects Arc header tenancy, wires Arc to Chronicle, and configures tenant-aware MongoDB read models.
Items/ItemId.csDefines the stream identity as EventSourceId<Guid>.
Items/ItemText.csDefines the domain value as ConceptAs<string>.
Items/Adding/Adding.csContains the model-bound AddItem command and immutable ItemAdded event.
Items/Listing/Listing.csProjects ItemAdded into the model-bound Item read model and exposes ItemById.
MultiTenancy.Specs/Covers event construction, projection, and the same id in tenant A, tenant B, and Default.

Build and verify

Build it directly while exploring, or as part of the root Samples solution:

dotnet build Chronicle/MultiTenancy/MultiTenancy.csproj
dotnet test Chronicle/MultiTenancy/MultiTenancy.Specs/MultiTenancy.Specs.csproj

The focused namespace-isolation spec uses Chronicle's in-process event scenario, so the test suite needs no container.

Ideas to try

  • Send x-cratis-tenant-id: Default and confirm it addresses the same namespace as no header.
  • Add an item that exists in only one tenant, then query that id from another tenant.
  • Inspect MongoDB and compare the default read-model database with tenant-suffixed databases.
  • Add a second event, such as ItemCompleted, and watch each tenant's projection evolve independently.

Intentional limits

This is a learning sample, not a production tenancy policy. It has no authentication, tenant allow-list, authorization, idempotency, or production connection configuration. A caller that can choose any header can choose any namespace; real systems must derive or validate tenant access at a trusted boundary. There is no React client, cross-store messaging, or tenant id property on the event.