Reference
Tenancy
Reference for go-sequent multi-tenancy: shared schema with tenant_id, tenancy.WithTenant and Middleware, UniqueScope, partition keys, and a Circle multi-merchant mapping example.
Tenancy
Package: tenancy
go-sequent uses a shared schema with a tenant_id. There is one set of tables. Commands, events, idempotency keys and projections carry the tenant, and queries filter on it. There is no first-class schema-per-tenant or database-per-tenant mode.
Setting the tenant
Your gateway or application layer resolves the tenant from an API key, a subdomain or a verified token, then puts it in the context:
ctx = tenancy.WithTenant(ctx, tenantID)
err := app.Service.Execute(ctx, cmd)
tenancy.Middleware copies it into Meta.TenantID:
- If
Meta.TenantIDis empty, it is set from the context. - If both are set and they differ, the command is rejected:
tenancy: command tenant ... != context tenant .... - If the context has no tenant, the command passes through unchanged.
Install it first in the chain, so later middleware sees the tenant:
app.Service.Middleware = append(
[]command.Middleware{tenancy.Middleware},
app.Service.Middleware...,
)
Other helpers: tenancy.FromContext(ctx) and tenancy.Require(ctx), which returns an error when no tenant is set.
Helpers
| Function | Purpose |
|---|---|
PartitionKey(tenantID) |
The tenant id string, or empty for the nil UUID. |
PartitionKeyHashed(tenantID, shard) |
tenantID:shard for finer partitioning, still one logical tenant. |
UniqueScope(tenantID, scope) |
tenant:{uuid}:scope, for unique keys. |
MatchAggregateTenant(cmdTenant, partitionKey) |
Error if an aggregate’s partition does not belong to the command’s tenant. |
tenancy.UniqueScope(tenantID, "email")
tenancy.PartitionKeyHashed(tenantID, "shard-a")
tenancy.MatchAggregateTenant(cmdTenant, agg.PartitionKey())
MatchAggregateTenant is defense in depth. Call it in a handler after Load when an aggregate must belong to the calling tenant.
What carries the tenant
- Commands.
Meta.TenantID, stored with the command record. - Aggregates.
PartitionKey(), usuallytenancy.PartitionKey(tenantID), set when the aggregate is created. - Unique keys. Include the tenant in the scope with
UniqueScope. - Idempotency. The key space is
(TenantID, IdempotencyKey). - Projections. Add a
tenant_idcolumn and filter withWHERE tenant_id = $1. - Outbox. Messages carry
TenantID.
Partitioning
The events table stores partition_key. If it grows large, you can partition it by tenant or by tenant plus hash, still within the shared schema. See Partitioning.
Row-level security
Row-level security on tenant_id is optional and is not provided by the library. If you add it, keep the tenant checks in the repository and in handlers as well.
Business example: Circle multi-merchant
When you integrate the Circle API for a multi-merchant payments or wallet product, the tenant boundary is the merchant, not the end buyer.
| Business concept | go-sequent mapping |
|---|---|
| Merchant | Tenant: Meta.TenantID, row tenant_id |
| Buyer, operator, back-office actor | User: Meta.UserID (authenticate first, then AuthZ) |
| Payment, Wallet, Settlement | Aggregate, with OCC per stream |
| Merchant balance, reconciliation, withdrawable amount | Ledger projection through SyncInTxn, filtered by tenant_id |
Do not make each end user a tenant. End users can appear as a buyer_id in event payloads, or as UserID when they issue commands. Isolation and billing stay at merchant level.
Application-level mapping:
| Column | Role |
|---|---|
merchant_id |
External business key |
tenant_id (UUID) |
Written to Meta.TenantID and used in projection filters |
| Circle-side ids | Depends on the configuration pattern below |
Payment happy path
- The gateway resolves the tenant from an API key, subdomain, or a JWT
merchant_id, then callstenancy.WithTenant(ctx, tenantID). The authenticated principal goes intoMeta.UserID. Never authorize on a client-supplied user id alone. CommandService.Executeruns, for example,CapturePayment. Inside the write transaction: tenant-scoped AuthZ, then the handler applies events on the Payment and Wallet aggregates (payloads may include Circlepayment_idortransfer_id), then the event store appends with idempotency(tenant_id, key)and unique keys such astenancy.UniqueScope(tenantID, "merchant_order"), then the ledger projection updates... WHERE tenant_id = $1throughSyncInTxn. The caller can read its own write.- For Circle HTTP calls, prefer a workflow with an AfterCommit hook, or the outbox, after the commit. External latency and retries then stay out of the ledger transaction. If a handler must call Circle, keep it to short “create intent, get external id” calls. Balance facts stay in
SyncInTxnprojectors.
Anti-pattern: one tenant per buyer
Giving each buyer a TenantID splits idempotency, unique keys, AuthZ and projection filters into tiny pieces. It also does not match the billing boundary of an onboarded merchant.
Two Circle credential patterns
- One platform API key. The host holds a single Circle credential, and each call tags Circle metadata or client references with the
merchant_idorTenantID. This fits platform settlement and collect-on-behalf. - Per-merchant Circle credentials. Secrets are indexed by
TenantIDin a KMS or secrets table, and outbound calls look up credentials from the command’sTenantID. This fits merchants with their own Circle accounts.
Neither pattern changes “tenant = merchant”. Only the ownership of credentials changes.
Related
Docs track the go-sequent library. When APIs drift, the source wins.