Skip to content
advdvPublic

About

Defining domain-driven design (DDD) aggregates using Ent (entgo.io)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

entaggr

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:

  1. Annotation types (Root, Child, ChildrenEdge, ParentEdge, Reference, AmbientEdge) that ent schemas and edges attach so the data model declares its aggregate structure explicitly.
  2. An entc.Extension (NewExtension) that validates those annotations at ent generate time and emits the resolved topology under the entc target directory as two co-located files: aggregates/map.go for code in the same Go module, and aggregates/map.json for tools that run in their own process and load it via aggmap.Parse.
  3. 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 consumes map.json via a buf plugin option and runs out-of-process so it never imports your generated Go code. See cmd/buf-plugin-entaggr and proto/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.

Background: what is an aggregate, and why model it on top of ent?

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 generate time, 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.

Installation

go get github.com/advdv/entaggr

Requires ent v0.14+ and Go 1.26+.

Usage

1. Register the extension

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).

2. Annotate a Root and its Children

// 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 reference

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.

Declaring the RPC surface

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 at ent generate time otherwise. Use entaggr.CRUD for the conventional shape, entaggr.ReadOnly for system-created aggregates, or construct Operations inline 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 Describe arm 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_query on 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 of DomainCommands / DomainQueries / DomainListQueries is rejected — every verb is classified as exactly one kind.
  • Ambient roots MUST leave Operations nil. Ambient roots of either flavor — AmbientFromContext (e.g. Tenant) and AmbientFromRequest (e.g. Workspace) — have no aggregate service surface; setting Operations on 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.

Helpers: CRUD, ReadOnly

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.

Validation rules

The extension fails ent generate (with all problems joined into one error) when:

  • A schema is annotated as both Root and Child, or as neither.
  • An owning edge (edge.To) on an annotated schema is missing a ChildrenEdge / Reference / AmbientEdge annotation, or has more than one.
  • A ChildrenEdge targets something that is not a Child, or its paired ParentEdge is missing / does not Ref() it.
  • A Child has zero or more than one ParentEdge.
  • A Reference targets a Child or a non-aggregate schema (the "Reference Other Aggregates by Identity" rule).
  • An AmbientEdge targets a root that is not ambient (a root with an empty AmbientSource).
  • Two roots or children share the same TypeID prefix.
  • Cross-root Reference edges form a cycle (the inter-root graph must be a DAG).
  • A Root or Child does not have a single string primary 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 uniform string id = 1 slot on the wire. Treat this as a documented limitation until a concrete production use case forces the design.
  • A non-ambient Root leaves Operations nil, an ambient Root sets Operations, or an Operations value declares no arms and no domain verbs (an aggregate that exposes nothing is rejected).
  • Root.Operations is invalid: a StandardCommands / StandardQueries entry 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 of DomainCommands / DomainQueries / DomainListQueries.

Generated output

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

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 catalogue

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.

Standard CRUD wire shape

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 as string <edge>_id fields plus owned child collections as repeated <Child>Item <children_edge> fields. The <children_edge> name comes from the ChildrenEdge field of the matching aggmap.Child entry, which mirrors the ent edge the Root declares to its Children. Returned by Describe.
  • <Root>Item is the cheap summary: a narrow projection used for list rows and for Create responses. It always carries string id = 1 so clients can follow up with Describe<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.

v2 List contract

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 DomainQuery on the root for genuine union queries.
  • NOT / arbitrary nested boolean trees — same as above.
  • total_size / has_more / similar — LIST_RESPONSE_SHAPE forbids any field beyond items and page. Declare a DomainQuery like Count on the root if a count is genuinely needed.
  • Direction enum on the wire — PageResponse carries two distinct opaque tokens (next_page_token, prev_page_token); each encodes its own scan direction internally.

Runtime helpers

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); exposes MaxPageSize / ClampPageSize and a FilterMatches helper 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. The BindRange callback receives (gte, lt any) bounds — use AsTimestamps, AsInt64s, AsInt32s, or AsFloat64s to pull out typed values for TimestampRange, Int64Range, Int32Range, and Float64Range respectively.
  • entaggr/entlist — the per-aggregate List orchestrator that composes the other two end-to-end. A handler declares a Spec (filter mapper, sortable FieldDefs, a row → item projection) and calls Plan / Render around its own ent query; the library owns sort-key resolution from the <Root>Sort message, the (filter, mask) hash binding, the keyset tuple predicate, page-size clamping, and prev/next token minting. Beyond the static FIELD_* enum path, two hooks serve sort keys that are not root columns: Request.SortKeys accepts dynamically-named keys (resolved strictly — unknown, empty, or duplicate names error instead of being skipped) and FieldDef.Order replaces the default ORDER BY <Column> term with an expression, receiving the effective per-key direction (backward scans included). Paired with a KindCustom codec for null-aware comparisons and cursor values, this is the recipe for ordering by values that live on another table — see internal/testschema/entlist_dynamicsort_test.go for a worked example sorting uploads by a correlated subquery over their files, missing values last.

Use it from your project's buf.yaml

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.

Annotate your .proto files

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).

Tailoring the RPC surface per aggregate

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.

Pure CRUD aggregate

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))},
    }
}

Remove a standard arm — system-created aggregates

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.

Read-only with a custom command

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))}

Append-only / no Remove

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,
        },
    },
}

Describe-only / singleton

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},
    },
}

Add a domain verb (CQRS split)

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).

Behavior matrix

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.

Ambient roots: context vs. request

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.

Declaring a request-ambient root and a scoped aggregate

// 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{}),
    }
}

Binding the selector on the wire

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_ROOT checks the referenced root exists, is request-ambient, and the field is a non-repeated string.
  • ENTAGGR_SCOPE_BINDING_REQUIRED checks 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.

Non-goals

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.

Development

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.)

About

Defining domain-driven design (DDD) aggregates using Ent (entgo.io)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

⌂ 换网址