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.