Skip to content

danhtran94/entdomain

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

60 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

entdomain

CI Security OpenSSF Scorecard Go Reference Go Report Card License

An ent extension that generates a pure Go domain layer from your ent schema — with zero ORM dependency in the domain package.

Overview

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 imports
  • ent/domain.goToDomain() and ApplyDomain() mapping methods on ent types

The domain package stays in sync with your ent schema automatically — no manual drift.

Installation

go get github.com/danhtran94/entdomain

Setup

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

Custom Layout

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 dir

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

Schema Annotations

Opt in per entity and per edge. Entities without entdomain.Entity() are skipped entirely.

Entity

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

Edges

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

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"))                                              // → *Money

Generated Output

Domain struct (internal/domain/user.go)

No 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

Mapping methods (ent/domain.go)

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

Bulk methods (ent/domain.go, unless WithNoBulk is set)

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

Typed field constants

const (
    UserDomainFieldName    UserDomainField = "name"
    UserDomainFieldStatus  UserDomainField = "status"
    UserDomainFieldPostIDs UserDomainField = "post_ids"
    // ...
)

Upsert Support

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)

Field Handling in Upsert

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

FIQL Filtering

entdomain generates a typed FIQL filter entry point per entity. FIQL expressions are URI-safe without percent-encoding — ideal for GET query parameters.

Schema Annotation

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

Operator Constants

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

Generated Code (ent/fiql.go)

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

HTTP Handler Usage

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

Error Handling

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": ...

Known Limitations

  • UUID fields — not supported; ent's UUID predicates require uuid.UUID, not string. 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.

Apply Options

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

OmitZeroVal on create can silently skip intentional zero values — use with care.

Example

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

Transformer (Virtual Fields)

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 default

Wire at app startup:

ent.UserTransformer = &ent.UserDomainTransformer{
    GetFullName: func(u *ent.User) string {
        return u.FirstName + " " + u.LastName
    },
    // other functions left nil — skipped automatically
}

Repository Adapter Example

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

Proto Generation

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.

Enable in entc.go

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

Generated Output

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

Mapper Usage

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)

Field Opt-out (SkipProto)

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

Custom Proto Type

ProtoType() works with both VirtualField() and Field(), making it usable in two contexts:

Virtual fields

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

Ent JSON fields (Field(ProtoType(...)))

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

Proto Helper Functions

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 more

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

Built-in Auto-Mappings

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 in Proto

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.

Field Number Stability

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.


Design Notes

  • 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, and UpsertOne/Bulk.ApplyDomain
  • Nested edges (Nest()) are excluded from ApplyDomain entirely; only IDs() edges are written
  • Edge IDs are additionally excluded from upsert ApplyDomain — ent's *EntityUpsert type does not support edge mutations
  • Virtual field transformer hooks are excluded from upsert ApplyDomain — transformer setters are typed to *UserCreate / *UserUpdateOne only
  • Upsert nillable fields use an explicit d.X != nil guard with dereference (uu.SetX(*d.X)) instead of SetNillableX*EntityUpsert has no SetNillable* methods
  • (*UserUpdate).ApplyDomain does not call virtual field transformer hooks — transformer setters are typed to *UserUpdateOne
  • Optional() 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 optionals
  • WithNoBulk is 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.

About

Generates a pure Go domain package and mapping helpers from ent schema definitions, controlled via schema annotations.

Resources

License

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors