A DDD-aggregate metadata layer for ent schemas.
entaggr lets an ent schema declare its Domain-Driven Design aggregate
topology — which schemas are roots, which are children inside another
root's consistency boundary, which edges cross aggregate boundaries —
and turns that declaration into a typed, importable Go map that
downstream tooling (RPC codegen, lint plugins, authorization middleware,
…) can consume as a single source of truth.
It ships three co-located pieces:
- Annotation types (
Root,Child,ChildrenEdge,ParentEdge,Reference,AmbientEdge) that ent schemas and edges attach so the data model declares its aggregate structure explicitly. - An
entc.Extension(NewExtension) that validates those annotations atent generatetime and emits the resolved topology under the entc target directory as two co-located files:aggregates/map.gofor code in the same Go module, andaggregates/map.jsonfor tools that run in their own process and load it viaaggmap.Parse. - A buf-lint plugin (
buf-plugin-entaggr) and the proto annotations it reads (entaggr.v1.aggregate_service,entaggr.v1.aggregate_op,entaggr.v1.filter_field,entaggr.v1.scope_binding) that enforce the project-wide CRUD shape of every aggregate's RPC surface. The plugin consumesmap.jsonvia a buf plugin option and runs out-of-process so it never imports your generated Go code. Seecmd/buf-plugin-entaggrandproto/entaggr/v1.
The root entaggr Go package and the ent extension deliberately
know nothing about RPC, buf, or any wire shape — their only job is
to describe the aggregate topology of the data model. The
buf-plugin lives in this repo for distribution convenience, but it
is a downstream consumer of the topology, not part of its
definition.
In Domain-Driven Design (Evans, Vernon) an aggregate is a cluster of entities and value objects treated as a single consistency boundary. One entity in the cluster is the Root; everything else is a Child reachable only through the Root. Mutations are committed atomically per aggregate, and other aggregates refer to it only by the Root's identity — Vernon's "Reference Other Aggregates by Identity" rule.
That boundary is not just a modelling preference; it pins down several concrete properties of a system:
- Transactional scope. One aggregate is one transaction is one set of invariants. Updates that span aggregates are eventually consistent, not atomic.
- Authorization scope. Permissions naturally attach to the Root; children inherit reachability through it.
- Wire / RPC shape. CRUD endpoints are designed per aggregate,
not per table. References across boundaries appear on the wire as
<other>_id, never as nested objects. - Deletion semantics. Cascade lives inside the aggregate; references across aggregates never cascade.
ent already gives you nodes, edges, cardinality, and cascade hints, but it has no built-in notion of "this edge stays inside a consistency boundary" versus "this edge crosses one". Without that distinction every downstream concern — proto generation, lint rules, authorization checks, tenant scoping — has to either guess the topology from edge shapes, or re-declare it in a parallel place (proto annotations, hand-written config, code comments). Both options drift.
Putting the metadata on the ent schema specifically is the obvious fix: the ent schema is already the single source of truth for the data model, and it is the earliest place a developer touches when adding a new entity. Co-locating aggregate intent with the field and edge definitions means:
- Drift is impossible. You cannot add an edge without making — and recording — an aggregate decision about it; the build refuses to generate otherwise.
- The decision is checked early. Validation runs at
ent generatetime, before any code that depends on the topology can compile. - One pass, many consumers. A single generator emits a typed, importable map; every downstream tool consumes the same artifact instead of re-deriving the topology in its own way.
entaggr is the vocabulary and the validator that make this work: a
small set of annotations precise enough to distinguish
inside-boundary edges from cross-boundary identity references
from ambient edges — whether context-ambient (a tenant
discriminator, never on the wire) or request-ambient (a workspace
selector, supplied per request) — plus a build-time check that the
topology a developer declared actually satisfies the structural
rules an aggregate must obey.
go get github.com/advdv/entaggrRequires ent v0.14+ and Go 1.26+.
In your entc bootstrap (typically ent/entc.go):
//go:build ignore
package main
import (
"log"
"entgo.io/ent/entc"
"entgo.io/ent/entc/gen"
"github.com/advdv/entaggr"
)
func main() {
err := entc.Generate("./schema", &gen.Config{
Target: "./internal/ent",
Package: "example.com/project/internal/ent",
}, entc.Extensions(entaggr.NewExtension()))
if err != nil {
log.Fatalf("running ent codegen: %v", err)
}
}The extension validates the aggregate annotations on every schema and
edge, then — if validation passes — emits the resolved map at
<Target>/aggregates/map.go (typed Go) and
<Target>/aggregates/map.json (the same topology serialized for
out-of-process consumers).
// schema/upload.go
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema"
"entgo.io/ent/schema/edge"
"entgo.io/ent/dialect/entsql"
"github.com/advdv/entaggr"
)
type Upload struct{ ent.Schema }
func (Upload) Annotations() []schema.Annotation {
return []schema.Annotation{
// Required: declare the exact RPC surface. CRUD here =
// {Create, Remove, Describe, List} and no domain verbs.
entaggr.Root{Operations: new(entaggr.CRUD(nil, nil, nil))},
}
}
func (Upload) Edges() []ent.Edge {
return []ent.Edge{
edge.To("tenant", Tenant.Type).
Unique().Required().Immutable().
Annotations(entaggr.AmbientEdge{}),
edge.To("files", UploadFile.Type).
Annotations(entsql.OnDelete(entsql.Cascade), entaggr.ChildrenEdge{}),
}
}
type UploadFile struct{ ent.Schema }
func (UploadFile) Annotations() []schema.Annotation {
return []schema.Annotation{entaggr.Child{}}
}
func (UploadFile) Edges() []ent.Edge {
return []ent.Edge{
edge.From("upload", Upload.Type).
Ref("files").Field("upload_id").
Required().Immutable().Unique().
Annotations(entaggr.ParentEdge{}),
}
}After go generate ./... your project will contain
internal/ent/aggregates/map.go and internal/ent/aggregates/map.json.
In-process Go consumers (CRUD scaffolders, authorization middleware, etc.) import the typed Go map:
import "example.com/project/internal/ent/aggregates"
func init() {
for name, root := range aggregates.Map.Roots {
// ... use root.Children, root.References, root.AmbientEdges
}
}Out-of-process consumers (e.g. a buf-lint plugin) load the JSON
sibling through github.com/advdv/entaggr/aggmap:
import (
"os"
"github.com/advdv/entaggr/aggmap"
)
raw, err := os.ReadFile("internal/ent/aggregates/map.json")
if err != nil { /* … */ }
m, err := aggmap.Parse(raw)
if err != nil { /* schema_version mismatch or malformed input */ }
for name, root := range m.Roots {
// same shape as aggregates.Map.Roots above
}aggmap.Parse asserts aggmap.SchemaVersion against the
schema_version written into the JSON, so a producer/consumer
mismatch fails loudly instead of being silently misinterpreted.
| Annotation | Goes on | Meaning |
|---|---|---|
Root{AmbientSource, Operations} |
schema | This schema is an aggregate root. A non-empty AmbientSource marks an ambient root: AmbientFromContext for identities resolved from request context (e.g. Tenant, never on the wire) and AmbientFromRequest for identities chosen per request (e.g. Workspace, required on the wire via scope_binding). Operations is required for every non-ambient root — see Declaring the RPC surface. |
Child{} |
schema | This schema lives inside another aggregate's consistency boundary. |
ChildrenEdge{} |
a Root's edge.To a Child |
The owning side of a parent → children relationship inside one aggregate. |
ParentEdge{} |
a Child's edge.From parent |
The child's up-pointing edge toward its immediate parent (a Root or another Child). |
Reference{} |
edge.To another Root |
A cross-aggregate identity reference — appears on the wire as <other>_id. |
AmbientEdge{} |
edge.To an Ambient Root |
An edge to an ambient root. If the target is AmbientFromContext the value comes from request context and never appears on the wire; if AmbientFromRequest the value is chosen per request and MUST appear on the wire (bound with scope_binding). |
Wire-level filter operators on List requests are declared with the
(entaggr.v1.filter_field) proto field option — see
List endpoints / v2 List contract below.
Every aggregate root has an RPC surface — the exact set of methods its proto service may expose. The surface is split along CQRS lines into five buckets:
| Bucket | What goes here |
|---|---|
StandardCommands |
Structural write arms drawn from a fixed enum: Create, Remove. |
StandardQueries |
Structural read arms drawn from a fixed enum: Describe, List. |
DomainCommands |
Free-form, schema-declared state-changing verbs (e.g. Rotate, Accept, Archive). |
DomainQueries |
Free-form, schema-declared read-only domain verbs (e.g. Preview, Suggest). |
DomainListQueries |
Free-form, schema-declared read-only verbs over the root's COLLECTION (e.g. Search) — List-shaped responses, free-form requests. |
Root.Operations is the single declaration site for that
surface and the buf-lint plugin reads it directly. The model is
strictly explicit: there are no defaults, no implied arms, no
fallbacks. Every arm and every domain verb that may appear on
the wire must be listed.
- Every non-ambient root MUST set
Operations. The build fails atent generatetime otherwise. Useentaggr.CRUDfor the conventional shape,entaggr.ReadOnlyfor system-created aggregates, or constructOperationsinline for everything else. - Operations must declare at least one arm or verb. An aggregate that exposes nothing at all is a build error — a callable aggregate has to be callable.
- Nil and empty are equivalent for each of the five slices;
both mean "this bucket exposes nothing". The
Describearm is no longer implied: if you want it, list it. - Domain verbs are an allow-list. An
aggregate_op.domain_command/domain_query/domain_list_queryon the proto side fails lint unless its verb is listed in the matching schema slice. Verbs must be UpperCamelCase, must not collide with the reserved standard arm names (Create,Describe,Remove,List), and must be unique within their slice. A verb declared in more than one ofDomainCommands/DomainQueries/DomainListQueriesis rejected — every verb is classified as exactly one kind. - Ambient roots MUST leave
Operationsnil. Ambient roots of either flavor —AmbientFromContext(e.g.Tenant) andAmbientFromRequest(e.g.Workspace) — have no aggregate service surface; settingOperationson one is a build error. A request-ambient root that is user-creatable is exposed via a free-form service, not the standard CRUD contract.
A worked example for a system-created aggregate that callers can read but cannot create or remove:
type Bundle struct{ ent.Schema }
func (Bundle) Annotations() []schema.Annotation {
return []schema.Annotation{
entaggr.Root{
Operations: &entaggr.Operations{
StandardCommands: []entaggr.StandardCommand{}, // pipeline-owned
StandardQueries: []entaggr.StandardQuery{
entaggr.QueryDescribe,
entaggr.QueryList,
},
DomainCommands: []string{"Publish"},
},
},
}
}See Tailoring the RPC surface per aggregate for the recipes that go with this declaration and the helpers that cover the common shapes.
The same package ships two named constructors so the
overwhelmingly common shapes do not require ceremony at every
call site. Both return a fresh entaggr.Operations value the
caller owns.
| Helper | What it declares |
|---|---|
CRUD(domainCommands, domainQueries, domainListQueries) |
{Create, Remove} commands, {Describe, List} queries, plus the caller's domain verbs (pass nil for none). |
ReadOnly(domainCommands, domainQueries, domainListQueries) |
Zero standard commands, {Describe, List} queries, plus the caller's domain verbs (pass nil for none). |
The helpers are values — pair them with Go 1.26's new(expr)
to set Root.Operations:
// Vanilla CRUD aggregate.
entaggr.Root{Operations: new(entaggr.CRUD(nil, nil, nil))}
// CRUD + a domain command, a domain query, and a domain list
// query (a free-form collection read like SearchUploads).
entaggr.Root{Operations: new(entaggr.CRUD(
[]string{"Rotate"},
[]string{"Preview"},
[]string{"Search"},
))}
// System-created aggregate (read-only) with a domain command
// for state changes the client *is* allowed to initiate.
entaggr.Root{Operations: new(entaggr.ReadOnly([]string{"MarkSeen"}, nil, nil))}Whenever the helpers do not fit (e.g. append-only aggregates
that expose Create but not Remove), construct
entaggr.Operations inline — the four slice fields are
independent.
The extension fails ent generate (with all problems joined into one
error) when:
- A schema is annotated as both
RootandChild, or as neither. - An owning edge (
edge.To) on an annotated schema is missing aChildrenEdge/Reference/AmbientEdgeannotation, or has more than one. - A
ChildrenEdgetargets something that is not aChild, or its pairedParentEdgeis missing / does notRef()it. - A
Childhas zero or more than oneParentEdge. - A
Referencetargets aChildor a non-aggregate schema (the "Reference Other Aggregates by Identity" rule). - An
AmbientEdgetargets a root that is not ambient (a root with an emptyAmbientSource). - Two roots or children share the same TypeID prefix.
- Cross-root
Referenceedges form a cycle (the inter-root graph must be a DAG). - A
RootorChilddoes not have a singlestringprimary key. Composite primary keys (field.ID("a", "b", …)) and non-string scalar primary keys (int,uuid, …) are intentionally unsupported: the aggregate map, cursor pagination, and the downstream buf-lint rules all assume a uniformstring id = 1slot on the wire. Treat this as a documented limitation until a concrete production use case forces the design. - A non-ambient
RootleavesOperationsnil, an ambientRootsetsOperations, or anOperationsvalue declares no arms and no domain verbs (an aggregate that exposes nothing is rejected). Root.Operationsis invalid: aStandardCommands/StandardQueriesentry is outside the fixed enum; a domain verb collides with a reserved standard arm name, is not UpperCamelCase, repeats inside the slice, or appears in more than one ofDomainCommands/DomainQueries/DomainListQueries.
The extension writes two co-located files under
<Target>/aggregates/:
| File | For | How to consume |
|---|---|---|
map.go |
Code in the same Go module as the ent schema. | import ".../aggregates" (typed Map) |
map.json |
Tooling that runs in a separate process / module (buf-lint plugins, CLIs, etc.). | aggmap.Parse(...) |
Both files carry the same shape; aggmap is the single source of
truth for it:
// from github.com/advdv/entaggr/aggmap
type AggregateMap struct {
SchemaVersion int `json:"schema_version"`
Roots map[string]*Root `json:"roots"`
}
type Root struct {
Name string `json:"name"`
TypeIDPrefix string `json:"type_id_prefix,omitempty"`
AmbientSource AmbientSource `json:"ambient_source,omitempty"` // "" | "context" | "request"
SourceFile string `json:"source_file,omitempty"`
Children []*Child `json:"children,omitempty"`
References []*Reference `json:"references,omitempty"`
AmbientEdges []*Reference `json:"ambient_edges,omitempty"`
Operations *Operations `json:"operations,omitempty"`
}
// Operations is the resolved RPC surface declaration. Always
// non-nil for non-ambient roots; nil for ambient roots (which
// have no service). Nil and empty are equivalent for each
// slice — there are no implicit arms.
type Operations struct {
StandardCommands []StandardCommand `json:"standard_commands,omitempty"`
StandardQueries []StandardQuery `json:"standard_queries,omitempty"`
DomainCommands []string `json:"domain_commands,omitempty"`
DomainQueries []string `json:"domain_queries,omitempty"`
DomainListQueries []string `json:"domain_list_queries,omitempty"`
}
type Child struct {
Name string `json:"name"`
TypeIDPrefix string `json:"type_id_prefix,omitempty"`
ParentRoot string `json:"parent_root"`
Parent string `json:"parent"`
ParentEdge string `json:"parent_edge"`
ChildrenEdge string `json:"children_edge,omitempty"`
SourceFile string `json:"source_file,omitempty"`
References []*Reference `json:"references,omitempty"`
AmbientEdges []*Reference `json:"ambient_edges,omitempty"`
}
type Reference struct {
EdgeName string `json:"edge_name"`
TargetRoot string `json:"target_root"`
Cardinality string `json:"cardinality"` // "one" or "many"
Required bool `json:"required"`
InverseName string `json:"inverse_name,omitempty"`
}SchemaVersion is stamped on every emitted map and is asserted by
aggmap.Parse; bumping aggmap.SchemaVersion forces a coordinated
update across consumers built against an older shape.
buf-plugin-entaggr is a bufplugin
lint plugin that enforces the project-wide CRUD shape of every
aggregate's RPC surface by combining proto annotations
(entaggr.v1.aggregate_service,
entaggr.v1.aggregate_op,
entaggr.v1.filter_field,
entaggr.v1.scope_binding)
with the topology emitted by the ent extension at
<ent-target>/aggregates/map.json.
| Rule | What it enforces |
|---|---|
ENTAGGR_ROOT_UNKNOWN |
aggregate_service.root must be a key in the aggregate map. |
ENTAGGR_AMBIGUOUS_AMBIENT |
Ambient roots of either flavor (context-ambient Tenant, request-ambient Workspace) must not be exposed via aggregate_service. |
ENTAGGR_SERVICE_NAME |
Service is named <Root>Service and exactly one service names each root. |
ENTAGGR_REQUIRED_ARMS |
Every standard arm the schema declares in Root.Operations appears exactly once on the service. |
ENTAGGR_ARM_NOT_DECLARED |
Inverse of the above: a standard arm present on the service must be declared in Root.Operations.StandardCommands / StandardQueries. |
ENTAGGR_DOMAIN_VERB_UNDECLARED |
Every domain_command / domain_query / domain_list_query verb must appear in the matching Root.Operations slice. Default surface allow-lists nothing. |
ENTAGGR_DOMAIN_VERB_MISSING |
Every domain verb declared in Root.Operations must have a matching RPC on the service — catches schema drift in the other direction. |
ENTAGGR_METHOD_NAMES |
Create<Root>, Describe<Root>, Remove<Root>, List<Plural>, <Verb><Root> for domain_command / domain_query verbs, <Verb><Plural> for domain_list_query verbs. |
ENTAGGR_PLURAL_REQUIRES_LIST |
aggregate_service.plural is only meaningful when the root exposes a List-shaped arm (the standard List or a domain_list_query); otherwise the override has no effect. |
ENTAGGR_REQUEST_HAS_ROOT_ID |
Describe / Remove / domain_command / domain_query request must have string id = 1. domain_list_query is exempt — it targets the collection. |
ENTAGGR_CREATE_RESPONSE_SHAPE |
Create response must have <Root>Item item = 1 (inner carries string id = 1) and MAY add one free-form extra field (any type) for one-time data. |
ENTAGGR_DESCRIBE_RESPONSE_SHAPE |
Describe response must have exactly <Root> item = 1, and the inner <Root> carries string id = 1. |
ENTAGGR_NO_TENANT_ID_ON_WIRE |
No request/response field on an aggregate service is named tenant_id (context-ambient Tenant stays off the wire). Literal-name check only; alternative names (tenant, org, etc.) are not detected. |
ENTAGGR_OP_REQUIRES_SERVICE_ANNOTATION |
A method carrying aggregate_op must be on a service that carries aggregate_service. |
ENTAGGR_FOREIGN_ID_REFERENCE |
Cross-aggregate <other>_id / <other>_ids wire fields must match a declared aggmap.Reference. Fields carrying (scope_binding) and fields whose stem resolves to an ambient root are exempt (owned by NO_TENANT_ID_ON_WIRE / the SCOPE_BINDING rules). |
ENTAGGR_OUTBOUND_EDGE_ON_DESCRIBE |
Inner <Root> payload on a Describe response carries <EdgeName>_id for every cardinality-"one" Reference. |
ENTAGGR_DESCRIBE_PAYLOAD_NESTS_CHILDREN |
Inner <Root> payload on a Describe response carries repeated <Child>Item <ChildrenEdge> for every Child of the root (field name from the aggmap). |
ENTAGGR_LIST_REQUEST_SHAPE |
List request has entaggr.v1.PageRequest page, <Root>Filter filter, google.protobuf.FieldMask filter_mask, and (optionally) <Root>Sort sort. No other fields, except (scope_binding) selectors for request-ambient roots. |
ENTAGGR_DOMAIN_LIST_QUERY_REQUEST_SHAPE |
domain_list_query request embeds entaggr.v1.PageRequest page and carries no field named id (reserved for root ids). Everything else is free-form — that is the point of the arm. |
ENTAGGR_LIST_RESPONSE_SHAPE |
Every List-shaped response (List and domain_list_query) has exactly repeated <Root>Item items (the canonical <Root>Item, by name) and entaggr.v1.PageResponse page — no extra fields (forbids total_size, has_more, etc.). |
ENTAGGR_LIST_ITEM_TYPE_DISTINCT |
List-shaped items element type must differ from the Describe inner payload type (forces <Root>Item ≠ <Root>). |
ENTAGGR_LIST_ITEM_HAS_ROOT_ID |
List-shaped items element type carries string id = 1 so list → describe round-trips. |
ENTAGGR_FILTER_FIELD_REQUIRES_ANNOTATION |
Every field on <Root>Filter carries (entaggr.v1.filter_field).op = OP_EQ|OP_IN|OP_RANGE|OP_PREFIX. |
ENTAGGR_FILTER_RANGE_FIELD_TYPE |
(filter_field).op = OP_RANGE ⇒ field type is one of entaggr.v1.TimestampRange, entaggr.v1.Int64Range, entaggr.v1.Int32Range, or entaggr.v1.Float64Range (non-repeated). |
ENTAGGR_FILTER_IN_FIELD_REPEATED |
(filter_field).op = OP_IN ⇒ field is repeated. |
ENTAGGR_FILTER_EQ_PREFIX_FIELD_SCALAR |
(filter_field).op = OP_EQ or OP_PREFIX ⇒ field is a non-repeated scalar (OP_PREFIX additionally requires string). |
ENTAGGR_SORT_FIELD_IS_FILTERABLE |
Every value of <Root>Sort.Field (except FIELD_ID) corresponds to a non-OP_PREFIX field on <Root>Filter — sort keys are a subset of filterable columns. The FIELD_ prefix is stripped before matching. |
ENTAGGR_SORT_ENUM_HAS_ID |
<Root>Sort.Field enum contains FIELD_ID (canonical keyset-pagination tiebreaker; the FIELD_ prefix aligns with Buf STANDARD). |
ENTAGGR_SORT_KEYS_FIELD |
<Root>Sort contains repeated <Root>Sort.Key keys — the wire-level list of sort columns. |
ENTAGGR_SORT_KEY_MESSAGE |
<Root>Sort contains a nested Key message with Field field = 1 referencing the sibling <Root>Sort.Field enum, and entaggr.v1.SortDirection direction = 2. |
ENTAGGR_INBOUND_EDGE_FILTER_ON_LIST |
Every cross-aggregate Reference on the root appears as string <EdgeName>_id (op OP_EQ) or repeated string <EdgeName>_ids (op OP_IN) on <Root>Filter. |
ENTAGGR_SCOPE_BINDING_ROOT |
A field carrying (entaggr.v1.scope_binding) must reference a root that exists in the map AND is request-ambient (AmbientFromRequest), and the field must be a non-repeated string. Binding to a context-ambient or normal root is rejected. |
ENTAGGR_SCOPE_BINDING_REQUIRED |
A workspace-scoped aggregate (one with an AmbientEdge to a request-ambient root) must carry exactly one (scope_binding){root: <target>} field on every request message, for each request-ambient edge. |
A service without (entaggr.v1.aggregate_service) is invisible to
every rule — opt-in by annotation, so the plugin can be introduced
incrementally.
Every aggregate exposes the same two payload types and the same
single-field envelope across Create, Describe, and List:
<Root>is the full aggregate: root scalars plus cross-aggregate identity references asstring <edge>_idfields plus owned child collections asrepeated <Child>Item <children_edge>fields. The<children_edge>name comes from theChildrenEdgefield of the matchingaggmap.Childentry, which mirrors the ent edge the Root declares to its Children. Returned byDescribe.<Root>Itemis the cheap summary: a narrow projection used for list rows and forCreateresponses. It always carriesstring id = 1so clients can follow up withDescribe<Root>to load the full aggregate.
The envelope itself is uniform — every response has a single
item field at field number 1 — with one deliberate exception:
Create responses MAY add a second field named extra for
information the server needs to hand back at create time but
that has no place in a subsequent Describe. Plaintext API
keys, signed upload URLs, one-time invitation tokens, and similar
"show once" payloads belong here. The rule only checks the field
name; the payload type is left to the service so each aggregate
can shape its own one-time hand-off.
message DescribeTeamResponse {
Team item = 1; // full aggregate
}
message CreateTeamResponse {
TeamItem item = 1; // cheap summary
// (optional) `extra` companion field — see ApiKey for an example.
}
message ListTeamsResponse {
repeated TeamItem items = 1;
entaggr.v1.PageResponse page = 2;
}
message Team {
string id = 1;
string organization_id = 2;
string name = 3;
repeated TeamMemberItem members = 4; // children nested as items
}
message TeamItem {
string id = 1;
string organization_id = 2;
string name = 3;
}
message TeamMemberItem {
string id = 1;
}The extra companion only applies to Create — Describe
responses remain strictly single-field, because anything a client
needs more than once on a given aggregate should be reachable
through Describe. A representative use of extra:
message CreateApiKeyResponse {
ApiKeyItem item = 1; // canonical summary
CreateApiKeyExtra extra = 2; // one-time hand-off
}
message CreateApiKeyExtra {
// Plaintext API key shown exactly once. Never returned by
// Describe — that is exactly what the `extra` field exists
// for.
string plaintext_key = 1;
}Both <Root> and <Root>Item are always emitted, even when they
happen to be structurally identical (e.g. for aggregates with no
children and only scalar payloads). The two type names encode two
different concepts — the complete aggregate vs the cheap summary
— so generic clients can rely on <Root>Item always being the
right type for list rows and create responses regardless of
whether the underlying aggregate has children.
Create<Root>Request is intentionally not constrained by this
contract: it stays a free-form request shape that captures
whatever inputs the create operation needs. Only the response
side is standardized.
Every aggregate's List arm follows a single structural recipe.
The shared envelope types live in entaggr.v1; the per-aggregate
<Root>Filter, <Root>Sort, and <Root>Item types are
hand-written in the project's own proto package.
import "entaggr/v1/annotations.proto";
import "google/protobuf/field_mask.proto";
message ListUploadsRequest {
entaggr.v1.PageRequest page = 1;
UploadFilter filter = 2;
google.protobuf.FieldMask filter_mask = 3; // which filter fields apply
UploadSort sort = 4;
}
message ListUploadsResponse {
repeated UploadItem items = 1; // narrow per-row shape
entaggr.v1.PageResponse page = 2;
}
// Flat, AND-of-fields. Each present field maps 1:1 to an ent
// predicate. Equality and set-membership on the same column are
// declared as separate fields (`id` + `id_in`) because each field
// carries its own operator — this is intentional, not duplication.
message UploadFilter {
string id = 1 [(entaggr.v1.filter_field).op = OP_EQ];
repeated string id_in = 2 [(entaggr.v1.filter_field).op = OP_IN];
string status = 3 [(entaggr.v1.filter_field).op = OP_EQ];
entaggr.v1.TimestampRange created_at = 4 [(entaggr.v1.filter_field).op = OP_RANGE];
string name_prefix = 5 [(entaggr.v1.filter_field).op = OP_PREFIX];
}
// Sort keys are a subset of filter columns + FIELD_ID tiebreaker.
// `Field` enum values carry the Buf-STANDARD `FIELD_` prefix so
// STANDARD lint can be enabled alongside ENTAGGR_*. The
// `direction` field references the shared
// `entaggr.v1.SortDirection` enum — no per-root `Direction`
// declaration; ASC/DESC is universal.
message UploadSort {
repeated Key keys = 1;
enum Field {
FIELD_UNSPECIFIED = 0;
FIELD_ID = 1;
FIELD_CREATED_AT = 2;
}
message Key {
Field field = 1;
entaggr.v1.SortDirection direction = 2;
}
}
// Narrow per-row view, also returned by CreateUpload. Must carry
// `string id = 1` so clients can follow up with DescribeUpload to
// load the full aggregate.
message UploadItem {
string id = 1;
string status = 2;
}The contract intentionally does NOT support:
- OR across whole expressions — every present filter field is ANDed.
Declare a
DomainQueryon the root for genuine union queries. - NOT / arbitrary nested boolean trees — same as above.
total_size/has_more/ similar —LIST_RESPONSE_SHAPEforbids any field beyonditemsandpage. Declare aDomainQuerylikeCounton the root if a count is genuinely needed.Directionenum on the wire —PageResponsecarries two distinct opaque tokens (next_page_token,prev_page_token); each encodes its own scan direction internally.
Three small Go packages in this repo cover the server-side half of the contract so projects do not re-implement the same code:
entaggr/entcursor— opaque cursor codec. HMAC-SHA256 signed; encodes(Direction, FilterHash, SortKeys, LastValues, PageSize); exposesMaxPageSize/ClampPageSizeand aFilterMatcheshelper for rejecting cursors minted against a different filter.entaggr/entfilter— generic ent-predicate builder. Implementers register field-name → predicate constructor pairs once per Root (BindEQ,BindIn,BindRange,BindPrefix);Mapper.Apply(filter, filter_mask)walks the proto and returns a single ANDed predicate. TheBindRangecallback receives(gte, lt any)bounds — useAsTimestamps,AsInt64s,AsInt32s, orAsFloat64sto pull out typed values forTimestampRange,Int64Range,Int32Range, andFloat64Rangerespectively.entaggr/entlist— the per-aggregate List orchestrator that composes the other two end-to-end. A handler declares aSpec(filter mapper, sortableFieldDefs, a row → item projection) and callsPlan/Renderaround its own ent query; the library owns sort-key resolution from the<Root>Sortmessage, the (filter, mask) hash binding, the keyset tuple predicate, page-size clamping, and prev/next token minting. Beyond the staticFIELD_*enum path, two hooks serve sort keys that are not root columns:Request.SortKeysaccepts dynamically-named keys (resolved strictly — unknown, empty, or duplicate names error instead of being skipped) andFieldDef.Orderreplaces the defaultORDER BY <Column>term with an expression, receiving the effective per-key direction (backward scans included). Paired with aKindCustomcodec for null-aware comparisons and cursor values, this is the recipe for ordering by values that live on another table — seeinternal/testschema/entlist_dynamicsort_test.gofor a worked example sorting uploads by a correlated subquery over their files, missing values last.
version: v2
plugins:
- plugin: ["go", "run", "github.com/advdv/entaggr/cmd/buf-plugin-entaggr"]
options:
map: ./internal/ent/aggregates/map.json
lint:
use:
- STANDARD
- ENTAGGR_REQUIRED_ARMS
- ENTAGGR_METHOD_NAMES
# …or just `ENTAGGR` to enable the whole category once buf
# supports it via the plugin's category metadata.go run resolves the plugin from your project's go.mod (the
binary is just a package main under cmd/buf-plugin-entaggr),
so version pinning works through normal Go module pinning. For CI
speed, switch to go install once and reference the installed
binary by name.
edition = "2023";
package myproj.upload.v1;
import "entaggr/v1/annotations.proto";
service UploadService {
option (entaggr.v1.aggregate_service) = { root: "Upload" };
rpc CreateUpload(CreateUploadRequest) returns (CreateUploadResponse) {
option (entaggr.v1.aggregate_op) = { create {} };
}
// …Describe, Remove, List, plus any <Verb>Upload methods tagged
// `aggregate_op = { domain_command { verb: "..." } }` (or
// `domain_query`), and any <Verb>Uploads methods tagged
// `domain_list_query`, for verbs declared in
// Root.Operations.DomainCommands / DomainQueries /
// DomainListQueries.
}Consumers vendor the .proto via a buf workspace dependency on
this repo's proto/ module (or pull it from a published BSR
module).
Not every aggregate fits the conventional CRUD shape. A Bundle
materialized by an internal pipeline must not accept external
Create calls; an audit log is append-only and must not be
removed; a domain such as Invitation is built around a
domain_command (Accept) more than around Update. Because
Root.Operations is required and there are no implicit
defaults, the schema spells out exactly which arms and verbs
the proto service may expose — the lint plugin enforces it both
ways: missing arms fail, undeclared arms fail too.
The recipes below cover the cases that come up in practice; the
synthetic fixture under
internal/testschema/schema/
ships a working example of every one of them.
Reach for the CRUD helper. Pass nil for both domain slices
when there are no domain verbs:
type Calendar struct{ ent.Schema }
func (Calendar) Annotations() []schema.Annotation {
return []schema.Annotation{
entaggr.Root{Operations: new(entaggr.CRUD(nil, nil, nil))},
}
}To forbid Create (and/or Remove) for an aggregate that is
populated by an internal process, list the exact set of standard
commands the service is allowed to expose. The ReadOnly helper
covers the most common shape (no commands, both queries):
type Comment struct{ ent.Schema }
func (Comment) Annotations() []schema.Annotation {
return []schema.Annotation{
// Comments are aggregated by an internal pipeline —
// clients can read but never write.
entaggr.Root{Operations: new(entaggr.ReadOnly(nil, nil, nil))},
}
}A proto service for Comment that omits CreateComment and
RemoveComment lints clean. If a developer later sneaks a
CreateComment RPC tagged aggregate_op = { create {} } into
the service, ENTAGGR_ARM_NOT_DECLARED fires:
CreateComment exposes CREATE, but root "Comment" does not declare
Create in Operations.StandardCommands
Renaming the RPC to InitComment is not a workaround: that
method has no aggregate_op, so
ENTAGGR_OP_REQUIRES_SERVICE_ANNOTATION fires on it instead.
The only way to legitimately introduce state-changing surface
beyond {Create, Remove} is to declare a DomainCommand on
the schema.
Stack a domain command on top of ReadOnly when the read-only
aggregate still has one legitimate client-initiated mutation —
e.g. an inbox the client can mark as seen but neither create
nor delete:
entaggr.Root{Operations: new(entaggr.ReadOnly([]string{"MarkSeen"}, nil, nil))}When neither helper fits, construct Operations inline. An
append-only aggregate (Create + Describe + List, no Remove):
entaggr.Root{
Operations: &entaggr.Operations{
StandardCommands: []entaggr.StandardCommand{entaggr.CommandCreate},
StandardQueries: []entaggr.StandardQuery{
entaggr.QueryDescribe,
entaggr.QueryList,
},
},
}A singleton that omits List is fine, but the service must
also drop the plural override — otherwise
ENTAGGR_PLURAL_REQUIRES_LIST fires:
entaggr.Root{
Operations: &entaggr.Operations{
StandardQueries: []entaggr.StandardQuery{entaggr.QueryDescribe},
},
}Pick the right bucket: DomainCommands for state-changing
verbs, DomainQueries for read-only ones, DomainListQueries
for read-only reads over the root's collection. The lint plugin
uses the split to enforce per-arm rules (e.g. domain commands
and queries must accept string id = 1 like Describe/Remove,
while domain list queries must not and carry the List response
contract instead), and the split makes the schema
self-documenting.
type ApiKey struct{ ent.Schema }
func (ApiKey) Annotations() []schema.Annotation {
return []schema.Annotation{
entaggr.Root{
// CRUD + two domain commands + a domain query.
Operations: new(entaggr.CRUD(
[]string{"Rotate", "Revoke"},
[]string{"Preview"},
nil,
)),
},
}
}service ApiKeyService {
option (entaggr.v1.aggregate_service) = { root: "ApiKey" };
// …Create, Describe, Remove, List omitted for brevity.
rpc RotateApiKey(RotateApiKeyRequest) returns (RotateApiKeyResponse) {
option (entaggr.v1.aggregate_op) = { domain_command { verb: "Rotate" } };
}
rpc RevokeApiKey(RevokeApiKeyRequest) returns (RevokeApiKeyResponse) {
option (entaggr.v1.aggregate_op) = { domain_command { verb: "Revoke" } };
}
rpc PreviewApiKey(PreviewApiKeyRequest) returns (PreviewApiKeyResponse) {
option (entaggr.v1.aggregate_op) = { domain_query { verb: "Preview" } };
}
}Domain verbs are not promoted to a wildcard "Other" arm: every
verb must be in the allow-list, and every declared verb must
have a matching RPC, or the build fails
(ENTAGGR_DOMAIN_VERB_UNDECLARED /
ENTAGGR_DOMAIN_VERB_MISSING).
| Slice value | Meaning for that bucket |
|---|---|
nil |
This bucket exposes nothing. Equivalent to an empty slice. |
[]T{} (explicit) |
This bucket exposes nothing. Equivalent to a nil slice. |
[]T{...} |
This bucket exposes exactly the listed arms / verbs. |
The whole-Operations zero value (no arms, no verbs in any of
the four slices) is rejected at ent generate time — a callable
aggregate must expose at least one arm or verb.
An ambient root is a root whose identity is supplied from
outside an aggregate's own payload. entaggr distinguishes two
flavors via Root.AmbientSource, and the difference is entirely
about whether the identity travels on the wire:
AmbientSource |
Identity comes from | On the wire? | Example |
|---|---|---|---|
AmbientFromContext |
request context (JWT, …) | never (NO_TENANT_ID) |
Tenant |
AmbientFromRequest |
chosen per request | required (scope_binding) |
Workspace |
Both flavors are service-less: an ambient root has no
aggregate_service and leaves Operations nil. A request-ambient
root that users can create is exposed through a free-form
service (e.g. WorkspaceService) that does not carry the
aggregate_service option, so it is invisible to the CRUD
contract.
// schema/workspace.go — the request-ambient root.
func (Workspace) Annotations() []schema.Annotation {
return []schema.Annotation{
entaggr.Root{AmbientSource: entaggr.AmbientFromRequest},
}
}
// schema/bookmark.go — a workspace-scoped aggregate carrying BOTH
// ambient discriminators.
func (Bookmark) Edges() []ent.Edge {
return []ent.Edge{
edge.To("tenant", Tenant.Type). // context-ambient
Unique().Required().Immutable().Field("tenant_id").
Annotations(entsql.OnDelete(entsql.Restrict), entaggr.AmbientEdge{}),
edge.To("workspace", Workspace.Type). // request-ambient
Unique().Required().Immutable().Field("workspace_id").
Annotations(entsql.OnDelete(entsql.Restrict), entaggr.AmbientEdge{}),
}
}Because the workspace is chosen per request, every request message
of the aggregate's service MUST carry exactly one field bound to
the request-ambient root with (entaggr.v1.scope_binding). The
tenant_id, in contrast, never appears — it is derived from JWT
claims server-side.
import "entaggr/v1/annotations.proto";
message CreateBookmarkRequest {
string workspace_id = 1 [(entaggr.v1.scope_binding) = {root: "Workspace"}];
string url = 2;
}
message DescribeBookmarkRequest {
string id = 1;
string workspace_id = 2 [(entaggr.v1.scope_binding) = {root: "Workspace"}];
}
message ListBookmarksRequest {
entaggr.v1.PageRequest page = 1;
BookmarkFilter filter = 2;
google.protobuf.FieldMask filter_mask = 3;
BookmarkSort sort = 4;
string workspace_id = 5
[(entaggr.v1.scope_binding) = {root: "Workspace"}];
}The lint pass enforces this positively rather than by name heuristic:
ENTAGGR_SCOPE_BINDING_ROOTchecks the referenced root exists, is request-ambient, and the field is a non-repeatedstring.ENTAGGR_SCOPE_BINDING_REQUIREDchecks every request message carries the selector, once per request-ambient edge on the root.
Because the binding is a semantic annotation, workspace_id is
allowed on List requests (it's exempt from the otherwise-strict
LIST_REQUEST_SHAPE) and is not mistaken for a missing
cross-aggregate Reference by FOREIGN_ID_REFERENCE.
The root entaggr Go package and the ent extension describe
aggregate topology and nothing else. They are intentionally
ignorant of:
- RPC / proto / buf and any wire-shape rules.
- HTTP transport, request validation, or authorization checks.
- Database dialect specifics beyond what ent already exposes.
Those concerns belong in downstream tools that import the
emitted map. buf-plugin-entaggr is exactly that — a downstream
consumer — and lives in this repo only for distribution
convenience.
go test ./...The extension is exercised end-to-end against the synthetic fixture
under internal/testschema/, and both the emitted Go map and the
emitted JSON map are compared against committed goldens. After an
intentional change to the fixture or the emitter, refresh the
goldens with:
go test . -update(Use . rather than ./... because the -update flag is defined
only by the root package's test binary.)