Fabriq's request path, projection consumers, and live subscriptions already scale horizontally. The one component that does not is the source of truth: Postgres is a single linearizable anchor, and everything else is a derived, rebuildable read model. Sharding distributes that anchor — by tenant — for when a single Postgres runs out of write throughput.
The model: routing, not consensus01
A tenant's entire aggregate history, event log, and outbox live on exactly one shard. That single fact collapses the hard parts of distribution:
No scatter-gather reads. Every port operation carries a tenant on
ctx, and there are no cross-tenant queries — so a read routes to one shard, full stop.No distributed transactions. A command never spans tenants (
ExecBatchis one tenant's commands), so a write is a route to one shard's local transaction. The aggregate row, its event, and the outbox row still commit atomically in a single Postgres transaction, exactly as in the unsharded case.
Sharding is therefore a routing adapter behind the existing ports plus a per-shard worker loop. core/, the Fabric facade, and every call site (f.Exec, f.Relational().Get, …) are unchanged — they never learn that shards exist. The Store, Relational, Vector, Timeseries, and Spatial ports resolve the ctx tenant to a shard, then delegate unmodified.
Configuration02
Set Config.Shards to a list of shards; each tenant is routed to one of them. A single postgres block with no shards is the degenerate one-shard case, so existing deployments are unaffected.
# config.yaml — multi-shard
shards:
- id: "shard-a"
dsn: "postgres://fabriq_app:***@pg-a:5432/fabriq?sslmode=require"
pool_size: 16
- id: "shard-b"
dsn: "postgres://fabriq_app:***@pg-b:5432/fabriq?sslmode=require"
pool_size: 16When shards is set, the top-level postgres.dsn is ignored. The shard with the lowest id is the primary — it backs health checks, the migrations CLI, and the document plane.
Multi-shard can only be configured through a mounted config.yaml. The shards list is a list of structs (and shardPins a map), which the FABRIQ_* environment overlay cannot express — FABRIQ_POSTGRES_DSN configures the single-shard case only.
The directory03
The tenant → shard mapping is a deterministic hash of the tenant id over the configured shard ids (memoized in-process for 30s). It needs no coordination: every replica computes the same placement from the same shard list.
Pinning tenants to shards
shardPins overrides hash placement for specific tenants — for data-residency or high-value tenants that must live on a known shard, without dropping down to fabriq.New and a custom Directory:
# config.yaml — pin acme-corp onto shard-b, everyone else keeps hashing
shards:
- id: "shard-a"
dsn: "postgres://fabriq_app:***@pg-a:5432/fabriq?sslmode=require"
- id: "shard-b"
dsn: "postgres://fabriq_app:***@pg-b:5432/fabriq?sslmode=require"
shardPins:
acme-corp: "shard-b"An exact tenant-id match routes to the pinned shard; every other tenant falls back to the hash. A pin naming a shard id not in shards is rejected at Open, before anything is dialed. Library users get the same behaviour from shard.PinnedDirectory(pins, fallback).
Pinning places, it does not move. A pin only controls routing — pinning an existing tenant whose data was hash-placed elsewhere routes it to a shard that lacks its data. Pin a tenant before its first write, or move its rows yourself first. And like hash placement, the document plane is unaffected: CRDT documents stay on the primary shard regardless of pins.
Shard count is fixed at deploy time. Because placement is a pure hash of the tenant id, adding or removing a shard re-hashes existing tenants onto different shards — and Fabriq performs no data move, so a re-placed tenant would route to a database that lacks its data. Choose the shard count up front. Online rebalancing arrives with the catalog-backed directory (see Status).
The worker plane04
Advisory-lock leadership is scoped per database, so the singleton runners shard for free:
Outbox relay — one leader per shard. Each shard runs its own relay under advisory lock
1001on its own database, so relay throughput scales linearly with shard count. The single-global-relay bottleneck disappears.Reconciler and document plane — on the primary. These elect a single leader on the primary shard, but their work is tenant-routed internally: the reconciler unions tenants across every shard and routes each tenant's repair to the owning shard's outbox.
Projection consumers — unchanged. The relay still publishes to one shared Redis stream, events still carry
tenant_id, and consumer groups still scale by replica. Hydration from Postgres routes per-tenant transparently.
Migrations05
fabriq migrate operates on a single DSN and is not shard-aware. With N shards, run it once per shard, and gate your app rollout on every shard reaching the target version:
for dsn in "$SHARD_A_DSN" "$SHARD_B_DSN"; do
fabriq migrate up --dsn "$dsn"
doneAll shards carry the same schema; there is no separate catalog database to migrate.
Status06
Implemented: the tenant→shard routing layer behind the ports, multi-shard Open, the hash directory, config-pinned tenant→shard overrides (shardPins), the per-shard outbox relay — and catalog mode with two isolation levels: a dedicated database per tenant (ADR 0011, Database per tenant) or a dedicated schema per tenant sharing a consolidation database, routed by search_path (ADR 0012, Schema per tenant). Both provide explicit provisioning, a fleet migration roller (fabriq tenant migrate-all), a per-tenant document plane, and a sweeping worker plane. Not yet: online tenant moves between clusters/schemas, and hash-mode document-plane sharding (single-shard on the primary in shards mode).