---
title: Registry
description: 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](/docs/fabriq/(concepts)/dynamic-entities), declared from a runtime descriptor.

## EntitySpec

```go
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](/docs/fabriq/(data-planes)/documents) 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:

```go
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

```go
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:

```go
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](/docs/fabriq/(concepts)/subscriptions) for how scopes resolve to channels at subscribe time.

### CRDTSpec

```go
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`:

```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:

```go
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

```go
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.

<Callout type="info">
`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.
</Callout>

## 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](/docs/fabriq/(concepts)/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](/docs/fabriq/(operations)/migrations) for the full schema-discipline workflow.

The one fenced exception is [dynamic entities](/docs/fabriq/(concepts)/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

<Cards>
  <Card title="Dynamic Entities" href="/docs/fabriq/(concepts)/dynamic-entities">Runtime-defined entities with fabriq-managed DDL.</Card>
  <Card title="Commands and Events" href="/docs/fabriq/(concepts)/commands-and-events">How aggregate specs drive validation and the single write path.</Card>
  <Card title="Subscriptions" href="/docs/fabriq/(concepts)/subscriptions">How Subscribe scopes resolve to channels server-side.</Card>
  <Card title="Migrations" href="/docs/fabriq/(operations)/migrations">grove migrations as the schema authority and the conformance bridge.</Card>
</Cards>
