Fabriq

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
}
  • Name is the registry name used everywhere a string identifies the entity: commands (command.Command{Entity: "asset"}), the relational port, subscription scopes, derived event types.
  • Kind classifies how the entity is written. KindAggregate entities are written exclusively through the command plane (one transactional write, one versioned outbox event). KindDocument entities are collaborative CRDT documents whose updates land in the document plane and are periodically materialized into one ordinary versioned event.
  • Model is 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.
  • GraphNode is the graph label this entity projects to. Empty means the entity is not projected to the graph.
  • Edges map foreign-key columns to graph relationships.
  • Search maps the entity into the search projection.
  • Subscribe declares the subscription scopes clients may request.
  • CRDT configures the document plane and is required for KindDocument entities.

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: &registry.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, and Search declarations. See Projections.
  • Channel names. Subscription channels are always changes:{tenant}:{scope}:{id}, derived from the entity's Subscribe scopes and the context tenant — clients never name a channel.
  • Event types. EventType(entity, verb) mints types like asset.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

On this page