Skip to content

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.TenantID is 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(), usually tenancy.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_id column and filter with WHERE 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

  1. The gateway resolves the tenant from an API key, subdomain, or a JWT merchant_id, then calls tenancy.WithTenant(ctx, tenantID). The authenticated principal goes into Meta.UserID. Never authorize on a client-supplied user id alone.
  2. CommandService.Execute runs, for example, CapturePayment. Inside the write transaction: tenant-scoped AuthZ, then the handler applies events on the Payment and Wallet aggregates (payloads may include Circle payment_id or transfer_id), then the event store appends with idempotency (tenant_id, key) and unique keys such as tenancy.UniqueScope(tenantID, "merchant_order"), then the ledger projection updates ... WHERE tenant_id = $1 through SyncInTxn. The caller can read its own write.
  3. 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 SyncInTxn projectors.

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

  1. One platform API key. The host holds a single Circle credential, and each call tags Circle metadata or client references with the merchant_id or TenantID. This fits platform settlement and collect-on-behalf.
  2. Per-merchant Circle credentials. Secrets are indexed by TenantID in a KMS or secrets table, and outbound calls look up credentials from the command’s TenantID. This fits merchants with their own Circle accounts.

Neither pattern changes “tenant = merchant”. Only the ownership of credentials changes.

Docs track the go-sequent library. When APIs drift, the source wins.