40.5 ent: An Entity Framework with Code Generation
Right, so you’ve met sqlc (the meticulous librarian) and GORM (the fast-talking used car salesman). Now let’s talk about ent—the architect. This isn’t just another ORM; it’s a full-blown entity framework that treats your database schema as the single source of truth and then generates a ridiculously type-safe, idiomatic Go API from it. It’s a bit more upfront work, but the payoff is a querying interface so clean and safe it’ll make you weep with joy. I’m not kidding.
The core philosophy here is “if it compiles, it probably works.” By defining your schema as Go code, ent uses code generation to create a client that knows exactly what your data looks like. Try to query a field that doesn’t exist? Compiler error. Forget to handle a required field? Compiler error. It’s like having a pedantic, all-knowing pair programmer who never sleeps.
Defining Your Schema: It’s All Go Code
You don’t write SQL migrations first with ent; you start by defining your entities (models) in a Go DSL. Let’s say we’re building a profoundly simple system for pets and their owners. This is how you’d start.
// ent/schema/user.go
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/edge"
)
// User holds the schema definition for the User entity.
type User struct {
ent.Schema
}
// Fields of the User.
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("name").
Default("Unknown"),
field.Int("age").
Positive(),
field.String("email").
Unique(),
}
}
// Edges of the User.
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("pets", Pet.Type),
}
}
// ent/schema/pet.go
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/edge"
)
// Pet holds the schema definition for the Pet entity.
type Pet struct {
ent.Schema
}
// Fields of the Pet.
func (Pet) Fields() []ent.Field {
return []ent.Field{
field.String("name"),
field.Enum("animal_type").
Values("dog", "cat", "ferret"),
}
}
// Edges of the Pet.
func (Pet) Edges() []ent.Edge {
return []ent.Edge{
edge.From("owner", User.Type).
Ref("pets").
Unique().
Required(), // A pet must have exactly one owner
}
}
See what we did there? We defined fields with types, defaults, and validations (Positive). More importantly, we defined a relationship (an “edge” in ent-speak) from a User to their Pets, and a反向 edge from a Pet back to its Owner. This is the magic. The generator will now create methods like QueryPets() on the user client and QueryOwner() on the pet client.
Generating the Glorious Client
Once your schema is defined, you run the ent code generator. This is the part where the framework does about 80% of the work for you.
go run entgo.io/ent/cmd/ent generate ./ent/schema
Boom. The ./ent directory now explodes with generated code: a client, all your entity-specific clients, predicates for filtering, and every constant you could possibly need. It’s a lot of code, but you will never have to touch it. It’s your generated API.
Using the Client: Type-Safe Nirvana
Now for the fun part: using the generated client. The difference from GORM is immediately obvious. Everything is discoverable through your IDE’s autocomplete.
package main
import (
"context"
"log"
"<yourproject>/ent"
"<yourproject>/ent/user"
_ "github.com/mattn/go-sqlite3"
)
func main() {
client, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
log.Fatal(err)
}
defer client.Close()
ctx := context.Background()
// Run the auto-migration tool. This creates the tables based on our schema.
if err := client.Schema.Create(ctx); err != nil {
log.Fatal(err)
}
// Create a user and a pet in a transaction.
err = client.WithTx(ctx, func(tx *ent.Tx) error {
// Create a User. The `.Save()` method is now available *only* with the correct fields.
u, err := tx.User.
Create().
SetName("Alice").
SetAge(30).
SetEmail("alice@example.com").
Save(ctx)
if err != nil {
return err
}
log.Println("User:", u)
// Create a Pet and associate it with the user we just created.
// Note how we use the `Owner` edge to link them.
p, err := tx.Pet.
Create().
SetName("Rex").
SetAnimalType("dog").
SetOwner(u). // This is the crucial link
Save(ctx)
if err != nil {
return err
}
log.Println("Pet:", p)
return nil
})
if err != nil {
log.Fatal(err)
}
// Now, let's query it all back in a beautifully type-safe way.
// Get all users who have a pet named "Rex".
usersWithRex, err := client.User.
Query().
Where(
user.HasPetsWith( // Autocompletes this because we defined the edge
pet.Name("Rex"), // `pet` package is generated, so this is a compile-time constant
),
).
All(ctx)
if err != nil {
log.Fatal(err)
}
log.Printf("Users with a pet named Rex: %v", usersWithRex)
}
The beauty is in the details. user.HasPetsWith(...) exists only because you defined the pets edge. The pet.Name field is a generated constant, so you can’t misspell it. This is the “if it compiles, it works” promise in action.
The Rough Edges and Pitfalls
It’s not all rainbows. The initial learning curve is steeper. You’re learning a new DSL, not just Go struct tags. The generated code can be verbose, and the number of files it creates is, frankly, a bit shocking the first time you see it.
The most common pitfall is trying to use it like GORM. You can’t just pass a map[string]interface{} to an update function. You must use the type-safe builders. This feels restrictive at first until you realize that restriction is the entire point—it’s preventing an entire class of runtime bugs.
Another gotcha: the migration system is good, but for complex production deployments with tons of existing data, you might still want to pair it with a dedicated migration tool like goose or atlas (which, funnily enough, is from the same folks as ent) for more explicit control. Using client.Schema.Create is fantastic for development and testing, but you might want more nuance in production.
Ultimately, ent is the tool you choose when you value long-term integrity and developer velocity over initial setup speed. It’s a heavyweight, but in the right project, it’s an undisputed champion.