Registry
How an entity is declared once as an EntitySpec and the projection mappings, channel names, and tenant-scoped store names are all derived from it.
The registry is fabriq's declarative schema. Each entity is described once as an EntitySpec — its relational shape via a grove-tagged model, plus the fabric-only concerns layered on top (graph mapping, search mapping, subscription scopes, the CRDT plane) — and everything else is derived: projection mappings, channel names, tenant-scoped store names, conformance checks. For static (Go-struct) entities the registry never generates DDL; the one fenced exception is dynamic entities, declared from a runtime descriptor.
EntitySpec
type EntitySpec struct {
Name string
Kind Kind
Model any
GraphNode string // graph label; empty = not projected to the graph
Edges []EdgeSpec
Search SearchSpec
Subscribe []Scope
CRDT *CRDTSpec
}Nameis the registry name used everywhere a string identifies the entity: commands (command.Command{Entity: "asset"}), the relational port, subscription scopes, derived event types.Kindclassifies how the entity is written.KindAggregateentities are written exclusively through the command plane (one transactional write, one versioned outbox event).KindDocumententities are collaborative CRDT documents whose updates land in the document plane and are periodically materialized into one ordinary versioned event.Modelis a grove-tagged struct pointer such as(*domain.Asset)(nil). Its table and columns are bound at registration; the registry reads them, it does not declare them.GraphNodeis the graph label this entity projects to. Empty means the entity is not projected to the graph.Edgesmap foreign-key columns to graph relationships.Searchmaps the entity into the search projection.Subscribedeclares the subscription scopes clients may request.CRDTconfigures the document plane and is required forKindDocumententities.
EdgeSpec
Each EdgeSpec maps an FK column on this entity's table to a relationship type pointing at another registered entity:
type EdgeSpec struct {
Field string // FK column on this entity's table
Rel string // relationship type, e.g. "LOCATED_AT"
Target string // registry name of the target entity
}SearchSpec
type SearchSpec struct {
Index string // logical index base name; tenant routing is derived
Fields []string // columns included in the indexed document
}The zero value (empty Index) means the entity is not indexed. Fields must be real columns of the bound table — registration rejects a field that is not.
Subscribe scopes
A Scope names a subscription dimension. There are two builtins and one constructor:
var ByID = Scope{Name: "id"} // changes:{tenant}:id:{aggID}
var ByTenant = Scope{Name: "tenant"} // everything in the tenant
func ByField(name, field string) Scope // e.g. ByField("site", "site_id")ByID scopes deltas to a single aggregate. ByTenant scopes them to everything in the tenant — its channel id is always the context tenant, never client input. ByField(name, field) declares a containing scope whose channel id comes from the named column: ByField("site", "site_id") lets a client subscribe to all assets at one site, with the channel id taken from each asset's site_id. See Subscriptions for how scopes resolve to channels at subscribe time.
CRDTSpec
type CRDTSpec struct {
Engine string // engine reference, e.g. "grove-crdt"
SnapshotEvery int // compact after this many updates
QuietWindow time.Duration // idle window before materialization
}The merge engine itself comes from grove's CRDT packages — referenced, not reimplemented. SnapshotEvery governs log compaction cadence; QuietWindow is the idle period after which the merged state materializes into one ordinary domain event.
A real spec
The example domain pack registers four entities. Here is the aggregate asset and the document page, verbatim from domain/register.go:
{
Name: "asset",
Kind: registry.KindAggregate,
Model: (*Asset)(nil),
GraphNode: "Asset",
Edges: []registry.EdgeSpec{
{Field: "site_id", Rel: "LOCATED_AT", Target: "site"},
{Field: "parent_id", Rel: "CHILD_OF", Target: "asset"},
},
Search: registry.SearchSpec{Index: "assets", Fields: []string{"name", "kind", "serial"}},
Subscribe: []registry.Scope{registry.ByID, registry.ByField("site", "site_id"), registry.ByTenant},
},
{
Name: "page",
Kind: registry.KindDocument,
Model: (*Page)(nil),
CRDT: ®istry.CRDTSpec{
Engine: "grove-crdt",
SnapshotEvery: 64,
QuietWindow: 2 * time.Second,
},
Subscribe: []registry.Scope{registry.ByID, registry.ByTenant},
},The model carries the relational shape through grove tags; the registry binds it:
type Asset struct {
grove.BaseModel `grove:"table:assets"`
ID string `grove:"id,pk" json:"id"`
TenantID string `grove:"tenant_id,notnull" json:"tenant_id"`
Version int64 `grove:"version,notnull" json:"version"`
Name string `grove:"name,notnull" json:"name"`
Kind string `grove:"kind" json:"kind"`
Serial string `grove:"serial" json:"serial"`
SiteID string `grove:"site_id" json:"site_id"`
ParentID string `grove:"parent_id" json:"parent_id"`
}Every fabriq-managed table must carry three structural columns — id (primary key), tenant_id, and version — because they are what make the invariants enforceable: tenancy is a column (RLS keys on it) and optimistic concurrency is a column. Binding fails if any is missing.
Registering and validating
reg := registry.New()
if err := domain.RegisterAll(reg); err != nil {
log.Fatal(err)
}
if err := reg.Validate(); err != nil {
log.Fatal(err)
}registry.New() returns an empty registry. Register(spec) compiles and validates one spec: it binds the model, checks that every edge field, search field, and scope field is a real column, and rejects duplicate names or duplicate model types. MustRegister is the panicking variant for static wiring in domain packs.
Validate() runs the cross-entity checks once all entities are registered: every edge Target must itself be a registered entity, and that target must declare a GraphNode. fabriq.New and fabriq.Open both call Validate() for you.
RegisterAll registers the example domain pack (site, asset, tag, page). To model your own domain, build the same shape of EntitySpec set against a fresh registry.New(). The kernel and adapters are unchanged.
What is derived from specs
The registry is the single place names and mappings are minted, so they are consistent by construction:
- Projection mappings. Appliers translate events into engine-neutral mutations using the entity's
GraphNode,Edges, andSearchdeclarations. See Projections. - Channel names. Subscription channels are always
changes:{tenant}:{scope}:{id}, derived from the entity'sSubscribescopes and the context tenant — clients never name a channel. - Event types.
EventType(entity, verb)mints types likeasset.updated; it is the only way type strings are formed, so consumers can rely on the shape. - Tenant-scoped store names. Per-tenant graph names (
tenant_{id}), search index aliases (fabriq_{tenant}_{base}), and their blue-green versioned variants all derive from the tenant id and the spec, in one place.
The registry never generates DDL
This is a hard boundary for static entities. grove migrations are the schema authority — they create and alter tables. The registry only reads the grove-tagged model to learn the relational shape it must conform to. The bridge between the two is the registry-conformance integration test, which asserts that every registered entity's bound columns match what the migrations actually created. If a migration and a spec disagree, that test fails. See Migrations for the full schema-discipline workflow.
The one fenced exception is dynamic entities — entities declared from a runtime Schema descriptor instead of a Go Model. For those, fabriq does manage DDL (CREATE/additive ALTER), because there is no compile-time migration to author. It is additive and opt-in, and leaves the static lane's discipline above completely intact.
Where to go next
Architecture
The facade, the engine-agnostic kernel, the adapter dialects, and the capability-port principle that ties them together.
Dynamic Entities
Runtime-defined entities — register a schema descriptor instead of a Go struct, and fabriq runs the whole data plane (managed DDL, writes, reads, projections) over real typed Postgres columns.