An ent extension that generates a pure Go domain layer from your ent schema — with zero ORM dependency in the domain package.
When using ent in a clean architecture project, the generated types (ent.User, ent.UserCreate, etc.) carry DB-layer concerns and cannot be used directly as domain entities. entdomain solves this by generating:
internal/domain/{entity}.go— Pure Go structs with no ent importsent/domain.go—ToDomain()andApplyDomain()mapping methods on ent types
The domain package stays in sync with your ent schema automatically — no manual drift.
go get github.com/danhtran94/entdomainRegister the extension in your ent/entc.go:
//go:build ignore package main import ( "log" "entgo.io/ent/entc" "entgo.io/ent/entc/gen" "github.com/danhtran94/entdomain" ) func main() { ex, err := entdomain.NewExtension( entdomain.WithPackagePath("internal/domain"), // output dir (relative to module root) entdomain.WithPackageName("domain"), // generated package name // Disable bulk generation for specific entities: entdomain.WithNoBulk("Post", "Order"), // Or disable bulk generation for all entities: // entdomain.WithNoBulk(), ) if err != nil { log.Fatalf("creating entdomain extension: %v", err) } if err := entc.Generate("./schema", &gen.Config{ // Optional: enable ent upsert support — entdomain auto-detects this // and generates ApplyDomain on *EntityUpsertOne / *EntityUpsertBulk. Features: []gen.Feature{gen.FeatureUpsert}, }, entc.Extensions(ex), ); err != nil { log.Fatalf("running ent codegen: %v", err) } }WithPackagePath and WithProtoDir are resolved relative to the module root (the directory containing go.mod), not relative to the ent directory. This means you can place ent anywhere in your project tree:
myproject/ ← go.mod here (module root) ├── repo/ │ ├── schema/ │ └── ent/ ← ent output └── internal/ └── domain/ ← WithPackagePath("internal/domain") resolves here ✓ entdomain.WithPackagePath("internal/domain"), // relative to go.mod, not to ent dirOne caveat: when the schema directory is outside the ent directory, ent derives the generated package name from the schema's parent directory rather than from Target. Set gen.Config.Package explicitly to get the correct import path:
if err := entc.Generate("../schema", &gen.Config{ Target: ".", Package: "github.com/myorg/myproject/repo/ent", // required when schema is outside ent dir }, entc.Extensions(ex), ); err != nil { ... }See examples/custom/ for a working example of this layout.
Opt in per entity and per edge. Entities without entdomain.Entity() are skipped entirely.
func (User) Annotations() []schema.Annotation { return []schema.Annotation{ entdomain.Entity(), // basic — scalar fields only // with virtual fields: entdomain.Entity( entdomain.VirtualField("full_name", entdomain.String), entdomain.VirtualField("is_premium", entdomain.Bool), entdomain.VirtualField("metadata", entdomain.GoType("map[string]any")), ), } }func (User) Edges() []ent.Edge { return []ent.Edge{ edge.To("posts", Post.Type). Annotations( entdomain.Edge(entdomain.IDs()), // → PostIDs []int // entdomain.Edge(entdomain.Nest()), // → Posts []Post // entdomain.Edge(entdomain.IDs(), entdomain.Nest()), // → both ), edge.To("profile", Profile.Type).Unique(). Annotations( entdomain.Edge(entdomain.IDs()), // → ProfileID int ), } }| Annotation | Domain field | ToDomain() | ApplyDomain (create/update) |
|---|---|---|---|
IDs() | PostIDs []int | from Edges.Posts | AddPostIDs (if len > 0) / replace by default |
Nest() | Posts []Post | from Edges.Posts | skipped |
IDs(), Nest() | both | from Edges.Posts | AddPostIDs only (if len > 0) |
Virtual fields appear in the domain struct but have no corresponding ent schema field. They are set to their zero value by ToDomain() — the caller (or a Transformer) is responsible for hydrating them.
entdomain.VirtualField("full_name", entdomain.String) // → string entdomain.VirtualField("is_premium", entdomain.Bool) // → bool entdomain.VirtualField("count", entdomain.Int) // → int entdomain.VirtualField("ratio", entdomain.Float64) // → float64 entdomain.VirtualField("amount", entdomain.GoType("Money")) // → Money entdomain.VirtualField("tags", entdomain.GoType("[]string")) // → []string entdomain.VirtualField("metadata", entdomain.GoType("map[string]any")) // → map[string]any entdomain.VirtualField("price", entdomain.GoType("Decimal", "github.com/shopspring/decimal")) // → decimal.Decimal entdomain.VirtualField("price2", entdomain.GoType("*Decimal", "github.com/shopspring/decimal")) // → *decimal.Decimal entdomain.VirtualField("ext_id", entdomain.GoType("UUID", "github.com/google/uuid")) // → uuid.UUID entdomain.VirtualField("opt_ref", entdomain.GoType("*Money")) // → *MoneyNo ent imports. Optional fields become pointers. Enum types are re-declared with the entity name as prefix to avoid cross-entity collisions.
package domain import "time" type UserStatus string const ( UserStatusActive UserStatus = "active" UserStatusInactive UserStatus = "inactive" ) type User struct { ID int Name string Bio *string // optional → pointer Status UserStatus CreatedAt time.Time PostIDs []int // IDs edge Posts PostList // Nest edge (plural) PinnedPost *Post // Nest edge (singular) → pointer FullName string // virtual field IsPremium bool // virtual field Metadata map[string]any // virtual field } // UserList is generated unless WithNoBulk is set for the entity. type UserList []*User// Read: ent → domain func (e *User) ToDomain() *domain.User // Create: domain → ent builder func (c *UserCreate) ApplyDomain(d *domain.User, opts ...entdomain.ApplyOption) *UserCreate // Update by ID: domain → ent builder func (u *UserUpdateOne) ApplyDomain(d *domain.User, opts ...entdomain.ApplyOption) *UserUpdateOne // Update by WHERE condition: domain → ent builder, chain .Where(...) after func (u *UserUpdate) ApplyDomain(d *domain.User, opts ...entdomain.ApplyOption) *UserUpdate // Upsert (generated only when gen.FeatureUpsert is enabled in gen.Config) func (u *UserUpsertOne) ApplyDomain(d *domain.User, opts ...entdomain.ApplyOption) *UserUpsertOne func (u *UserUpsertBulk) ApplyDomain(d *domain.User, opts ...entdomain.ApplyOption) *UserUpsertBulk // absent when NoBulk// Slice mapper func (es Users) ToDomain() domain.UserList // Create bulk func (c *UserClient) CreateBulkDomain(ds domain.UserList, opts ...entdomain.ApplyOption) *UserCreateBulk // Update bulk by ID — mirrors UserCreateBulk API func (c *UserClient) UpdateBulkDomain(ds domain.UserList, opts ...entdomain.ApplyOption) *UserUpdateOneBulk func (b *UserUpdateOneBulk) Save(ctx context.Context) (domain.UserList, error) func (b *UserUpdateOneBulk) SaveX(ctx context.Context) domain.UserList func (b *UserUpdateOneBulk) Exec(ctx context.Context) error func (b *UserUpdateOneBulk) ExecX(ctx context.Context)const ( UserDomainFieldName UserDomainField = "name" UserDomainFieldStatus UserDomainField = "status" UserDomainFieldPostIDs UserDomainField = "post_ids" // ... )When ent's gen.FeatureUpsert is enabled in gen.Config.Features, entdomain automatically detects it and generates ApplyDomain on the *EntityUpsertOne and *EntityUpsertBulk builders — no additional annotation is required.
// Single upsert — conflict on "email", apply domain fields on conflict: client.User.Create(). ApplyDomain(d). OnConflict(sql.ConflictColumns("email")). ApplyDomain(d). // ← on *UserUpsertOne Exec(ctx) // Bulk upsert — uniform conflict resolution across all rows: client.User.CreateBulkDomain(ds). OnConflict(sql.ConflictColumns("email")). ApplyDomain(d). // ← on *UserUpsertBulk (absent when NoBulk is set) Exec(ctx)*EntityUpsert has SetX / ClearX but no SetNillableX. The upsert methods handle each field category as follows:
| Field type | Upsert behaviour |
|---|---|
| Non-nillable scalar / enum | uu.SetX(val) — same as UpdateOne |
| Nillable scalar | if d.X != nil { uu.SetX(*d.X) } — nil means leave unchanged |
| Nillable enum | if d.X != nil { uu.SetX(EntType(*d.X)) } |
| Immutable field | skipped — same as UpdateOne |
| Edge IDs | skipped — ent upsert does not support edge mutations |
| Virtual fields | skipped — no corresponding *EntityUpsert setter exists |
*EntityUpsertBulk.ApplyDomain is suppressed for entities that have WithNoBulk set — consistent with the existing bulk generation policy.
entdomain generates a typed FIQL filter entry point per entity. FIQL expressions are URI-safe without percent-encoding — ideal for GET query parameters.
Fields opt in explicitly. No field is filterable unless annotated — sensitive fields are never accidentally exposed.
func (User) Fields() []ent.Field { return []ent.Field{ field.String("name"). Annotations(entdomain.Field( entdomain.FIQL(entdomain.EQ, entdomain.NEQ, entdomain.Contains), )), field.Int("score"). Annotations(entdomain.Field( entdomain.FIQL(entdomain.EQ, entdomain.GT, entdomain.LT, entdomain.GTE, entdomain.LTE), )), field.Enum("status").Values("active", "inactive"). Annotations(entdomain.Field( entdomain.FIQL(entdomain.EQ, entdomain.NEQ), )), field.Time("created_at"). Annotations(entdomain.Field( entdomain.FIQL(entdomain.GTE, entdomain.LTE), )), field.String("password_hash"), // no FIQL → never filterable } }| Constant | FIQL syntax | Valid for |
|---|---|---|
EQ | == | all types |
NEQ | != | all types |
GT | =gt= | int, float, time |
LT | =lt= | int, float, time |
GTE | =ge= | int, float, time |
LTE | =le= | int, float, time |
Contains | =like= | string |
HasPrefix | =prefix= | string |
Logical: ; = AND, , = OR, ( ) = grouping. AND binds tighter than OR (standard FIQL precedence).
// Code generated by entdomain. DO NOT EDIT. var UserFIQLFields = entdomain.FIQLFields[predicate.User]{ "name": entdomain.FIQLString[predicate.User]{EQ: user.NameEQ, NEQ: user.NameNEQ, Contains: user.NameContains}, "score": entdomain.FIQLInt[predicate.User]{EQ: user.ScoreEQ, GT: user.ScoreGT, LT: user.ScoreLT, GTE: user.ScoreGTE, LTE: user.ScoreLTE}, "status": entdomain.FIQLEnum[predicate.User]{ EQ: map[string]predicate.User{"active": user.StatusEQ(user.StatusActive), "inactive": user.StatusEQ(user.StatusInactive)}, NEQ: map[string]predicate.User{"active": user.StatusNEQ(user.StatusActive), "inactive": user.StatusNEQ(user.StatusInactive)}, }, "created_at": entdomain.FIQLTime[predicate.User]{GTE: user.CreatedAtGTE, LTE: user.CreatedAtLTE}, } func UserFIQL(expr string) (predicate.User, error) { return entdomain.ParseFIQL(expr, UserFIQLFields) }func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) { q := h.client.User.Query() if expr := r.URL.Query().Get("filter"); expr != "" { pred, err := ent.UserFIQL(expr) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } q = q.Where(pred) } users, err := q.All(r.Context()) // ... }GET request — no percent-encoding required:
GET /users?filter=name==john;score=gt=25,status==active GET /users?filter=(status==active,status==inactive);created_at=ge=2024-01-01T00:00:00Z unknown field "email" — annotate with entdomain.FIQL(...) to enable operator "=gt=" not allowed on field "name" (String) — allowed: ==, !=, =like= unknown enum value "pending" for field "status" — valid values: active, inactive invalid integer value "abc" for field "score": strconv.Atoi: ... invalid time value "not-a-time" for field "created_at": ... - UUID fields — not supported; ent's UUID predicates require
uuid.UUID, notstring. UUID fields are silently skipped even if annotated. - Time values — must be RFC3339 format (
2006-01-02T15:04:05Z07:00). - Nesting depth — maximum 50 levels; deeper expressions return
"maximum nesting depth exceeded". - Edge fields — cross-entity filtering (e.g.
owner.name==john) is out of scope. - JSON/virtual fields — no DB column mapping; cannot be used as FIQL fields.
Control which fields ApplyDomain writes to the ent builder:
entdomain.OmitZeroVal() // skip fields with zero values entdomain.OmitNil() // skip nil pointer fields entdomain.OmitFields("bio", "score") // skip specific fields entdomain.OnlyFields("name", "status") // allowlist specific fields entdomain.AppendEdge("post_ids") // append edge IDs instead of replacing
OmitZeroValon create can silently skip intentional zero values — use with care.
// Update only name and status, append new posts rather than replace u.UpdateOneID(id). ApplyDomain(d, entdomain.OnlyFields(ent.UserDomainFieldName, ent.UserDomainFieldStatus), entdomain.AppendEdge(ent.UserDomainFieldPostIDs), ). Save(ctx)For virtual fields that require custom logic, wire a per-entity transformer at startup. Only set the functions you need — unset ones are skipped by ToDomain() and ApplyDomain().
// generated in ent/domain.go type UserDomainTransformer struct { GetFullName func(e *User) string SetFullNameOnCreate func(c *UserCreate, val string) SetFullNameOnUpdate func(u *UserUpdateOne, val string) // one Get + two Set functions per virtual field } var UserTransformer *UserDomainTransformer // nil by defaultWire at app startup:
ent.UserTransformer = &ent.UserDomainTransformer{ GetFullName: func(u *ent.User) string { return u.FirstName + " " + u.LastName }, // other functions left nil — skipped automatically }// This layer owns ent — domain package has zero knowledge of it. func (r *UserRepo) GetByID(ctx context.Context, id int) (*domain.User, error) { u, err := r.client.User.Query(). Where(user.ID(id)). WithPosts(). Only(ctx) if err != nil { return nil, err } return u.ToDomain(), nil } func (r *UserRepo) Create(ctx context.Context, d *domain.User) (*domain.User, error) { created, err := r.client.User.Create(). ApplyDomain(d). Save(ctx) if err != nil { return nil, err } return created.ToDomain(), nil } func (r *UserRepo) Update(ctx context.Context, d *domain.User) (*domain.User, error) { updated, err := r.client.User.UpdateOneID(d.ID). ApplyDomain(d, entdomain.OnlyFields(ent.UserDomainFieldName, ent.UserDomainFieldStatus), ). Save(ctx) if err != nil { return nil, err } return updated.ToDomain(), nil } func (r *UserRepo) CreateBulk(ctx context.Context, ds domain.UserList) (domain.UserList, error) { saved, err := r.client.User.CreateBulkDomain(ds).Save(ctx) if err != nil { return nil, err } result := make(domain.UserList, len(saved)) for i, u := range saved { result[i] = u.ToDomain() } return result, nil } func (r *UserRepo) UpdateBulk(ctx context.Context, ds domain.UserList) (domain.UserList, error) { return r.client.User.UpdateBulkDomain(ds).Save(ctx) } func (r *UserRepo) DeactivateAll(ctx context.Context) error { return r.client.User.Update(). ApplyDomain( &domain.User{Status: domain.UserStatusInactive}, entdomain.OnlyFields(ent.UserDomainFieldStatus), ). Where(user.StatusEQ(user.StatusActive)). Exec(ctx) } // Upsert — requires gen.FeatureUpsert in gen.Config.Features func (r *UserRepo) Upsert(ctx context.Context, d *domain.User) error { return r.client.User.Create(). ApplyDomain(d). OnConflict(sql.ConflictColumns("username")). ApplyDomain(d). Exec(ctx) } func (r *UserRepo) UpsertBulk(ctx context.Context, ds domain.UserList) error { return r.client.User.CreateBulkDomain(ds). OnConflict(sql.ConflictColumns("username")). ApplyDomain(ds[0]). // same conflict resolution for all rows Exec(ctx) }entdomain can generate .proto message files and domain↔proto mappers alongside the existing domain layer. This is opt-in and keeps the domain package itself proto-free.
ex, err := entdomain.NewExtension( entdomain.WithPackagePath("internal/domain"), entdomain.WithPackageName("domain"), entdomain.WithProto( entdomain.WithProtoDir("proto"), // output dir, relative to module root entdomain.WithProtoPackageName("entpb"), // proto package name entdomain.WithProtoGoPackage("github.com/myorg/myrepo/proto/entpb;entpb"), ), )proto/entpb/ .entdomain.lock.json ← stable field number registry — commit this file ent_messages.proto ← all entity messages in one file internal/domain/ pbmap/ ← domain ↔ proto mappers (package pbmap) user_proto_gen.go post_proto_gen.go proto_helpers_gen.go ← shared helpers (ToInt64Slice, MapToProtoStruct, etc.) import "github.com/myorg/myrepo/internal/domain/pbmap" // domain → proto p := pbmap.UserToProto(user) ps := pbmap.UserListToProto(users) // proto → domain d := pbmap.UserFromProto(req.User) ds := pbmap.UserListFromProto(req.Users)Any ent field or edge can be excluded from proto output:
func (User) Fields() []ent.Field { return []ent.Field{ field.String("name"), field.String("password_hash"). Annotations(entdomain.Field(entdomain.SkipProto())), } } func (User) Edges() []ent.Edge { return []ent.Edge{ // Break a mutual Nest cycle on one side: edge.From("owner", User.Type).Ref("posts").Unique(). Annotations( entdomain.Edge(entdomain.IDs(), entdomain.Nest()), entdomain.Field(entdomain.SkipProto()), ), } }ProtoType() works with both VirtualField() and Field(), making it usable in two contexts:
Fields with a GoType that has no well-known mapping are excluded by default. Supply an explicit type to include them:
entdomain.VirtualField("amount", entdomain.GoType("Decimal", "github.com/shopspring/decimal"), entdomain.ProtoType("google.type.Money", "google/type/money.proto"), )Typed JSON fields are excluded by default (except map[string]any which auto-maps to google.protobuf.Struct). Use Field(ProtoType(...)) to opt any JSON field into proto:
// Typed map → Struct (would otherwise be excluded) field.JSON("labels", map[string]string{}). Annotations(entdomain.Field( entdomain.ProtoType("google.protobuf.Struct", "google/protobuf/struct.proto"), )) // Slice → repeated scalar (IsRepeated is auto-inferred from the [] prefix) field.JSON("tag_names", []string{}). Annotations(entdomain.Field(entdomain.ProtoType("string"))) // Custom struct → hand-defined proto message // WithConversion provides the Go conversion expressions (%s = source expression). // The actual converter functions must be hand-written in the pbmap package. field.JSON("metadata", domain.UserMetadata{}). Annotations(entdomain.Field( entdomain.ProtoType("UserMetadata", "entpb/user_metadata.proto"). WithConversion("UserMetadataToProto(%s)", "UserMetadataFromProto(%s)"), ))The generated proto_helpers_gen.go file in the pbmap package includes shared conversion helpers used across all entity mappers:
func ToInt64Slice(ids []int) []int64 func FromInt64Slice(ids []int64) []int func ToInt64Ptr(v *int) *int64 func FromInt64Ptr(v *int64) *int func MapToProtoStruct(m map[string]any) *structpb.Struct // for map[string]any fields func ProtoStructToMap(s *structpb.Struct) map[string]any // inverse of MapToProtoStruct // ... and moreMapToProtoStruct and ProtoStructToMap are used automatically for field.JSON("x", map[string]any{}) fields. When using Field(ProtoType(...)).WithConversion(...) on other JSON fields, the hand-written conversion functions in the pbmap package are called instead.
| entdomain / ent type | Proto type | Import |
|---|---|---|
entdomain.String | string | — |
entdomain.Bool | bool | — |
entdomain.Int | int64 | — |
entdomain.Float64 | double | — |
GoType("Time", "time") | google.protobuf.Timestamp | google/protobuf/timestamp.proto |
GoType("Duration", "time") | google.protobuf.Duration | google/protobuf/duration.proto |
GoType("UUID", "github.com/google/uuid") | string | — |
field.Time(...) | google.protobuf.Timestamp | google/protobuf/timestamp.proto |
field.UUID(...) | string | — |
field.Enum(...) | top-level enum | — |
field.JSON("x", map[string]any{}) | google.protobuf.Struct | google/protobuf/struct.proto |
field.JSON("x", []T{}) + Field(ProtoType("T")) | repeated T | depends on T |
field.JSON("x", OtherType{}) | excluded (use Field(ProtoType(...)) to opt in) | — |
Nest edges are included in the proto output by default, generating embedded messages:
message User { repeated int64 post_ids = 8; // IDs edge repeated Post posts = 9; // Nest edge }Proto3 supports circular message types at the wire level (e.g. User nests Tag and Tag nests User). Use SkipProto() on one side to break the cycle in the generated proto if desired.
Proto field numbers are tracked in .entdomain.lock.json. Commit this file — it ensures wire compatibility across schema changes. Removed fields are permanently reserved and never reused.
- Nested edge mutations (creating/updating child entities) are intentionally not generated — manage them in the repository layer
- Virtual fields are always zero in
ToDomain()unless a Transformer is wired; each transformer function field is nil-checked individually before calling - Immutable ent fields are excluded from
UpdateOne.ApplyDomain,Update.ApplyDomain, andUpsertOne/Bulk.ApplyDomain - Nested edges (
Nest()) are excluded fromApplyDomainentirely; onlyIDs()edges are written - Edge IDs are additionally excluded from upsert
ApplyDomain— ent's*EntityUpserttype does not support edge mutations - Virtual field transformer hooks are excluded from upsert
ApplyDomain— transformer setters are typed to*UserCreate/*UserUpdateOneonly - Upsert nillable fields use an explicit
d.X != nilguard with dereference (uu.SetX(*d.X)) instead ofSetNillableX—*EntityUpserthas noSetNillable*methods (*UserUpdate).ApplyDomaindoes not call virtual field transformer hooks — transformer setters are typed to*UserUpdateOneOptional()fields without.Nillable()are stored as base types in ent (string,int, …) but mapped to pointers in the domain struct by taking their address —&e.Bio. The zero value is never nil in this case; use.Nillable()in the schema if you need nil-distinguishable optionalsWithNoBulkis configured at extension level, not per schema — keeps schema annotations focused on domain shape, not generation policy; it also suppresses*EntityUpsertBulk.ApplyDomain- Upsert generation is auto-detected from
gen.Config.Features— no entdomain annotation or config option is needed
See adr/001-entdomain-extension.md for the full design rationale.