The Database Layer Dilemma
Choosing the right data access layer is critical for Go applications. Get it wrong, and you’ll spend countless hours debugging runtime errors, dealing with SQL injection vulnerabilities, and wrestling with inconsistent data models.
Two excellent options have emerged in the Go ecosystem:
- Ent - Facebook’s entity framework with code generation and type safety
- Bun - A SQL-first ORM with excellent query building capabilities
This guide explores both, showing you when to use each and how to build reliable data layers.
Ent: The Type-Safe Schema-First Approach
What is Ent?
Ent is an entity framework that uses code generation to provide:
- ✅ Type-safe database operations
- ✅ Schema-first development
- ✅ Automatic migrations
- ✅ Graph traversal capabilities
- ✅ Built-in validation and hooks
When to use Ent:
- Complex domain models with relationships
- Need strong type safety
- Want graph-style queries
- Prefer schema-first development
- Building APIs with complex data requirements
Getting Started with Ent
Installation:
go get entgo.io/ent/cmd/ent
Step 1: Define Your Schema
// ent/schema/user.go
package schema
import (
"time"
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
)
// 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("email").
Unique().
NotEmpty(),
field.String("username").
Unique().
MinLen(3).
MaxLen(50),
field.String("full_name").
NotEmpty(),
field.Enum("status").
Values("active", "inactive", "suspended").
Default("active"),
field.Time("created_at").
Default(time.Now).
Immutable(),
field.Time("updated_at").
Default(time.Now).
UpdateDefault(time.Now),
}
}
// Edges of the User.
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("posts", Post.Type),
edge.To("profile", Profile.Type).
Unique(),
}
}
// Indexes of the User.
func (User) Indexes() []ent.Index {
return []ent.Index{
index.Fields("email"),
index.Fields("username"),
}
}
// ent/schema/post.go
package schema
import (
"time"
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
)
// Post holds the schema definition for the Post entity.
type Post struct {
ent.Schema
}
func (Post) Fields() []ent.Field {
return []ent.Field{
field.String("title").
NotEmpty().
MaxLen(200),
field.Text("content").
NotEmpty(),
field.String("slug").
Unique().
NotEmpty(),
field.Enum("status").
Values("draft", "published", "archived").
Default("draft"),
field.Int("view_count").
Default(0).
NonNegative(),
field.Time("published_at").
Optional().
Nillable(),
field.Time("created_at").
Default(time.Now).
Immutable(),
field.Time("updated_at").
Default(time.Now).
UpdateDefault(time.Now),
}
}
func (Post) Edges() []ent.Edge {
return []ent.Edge{
edge.From("author", User.Type).
Ref("posts").
Unique().
Required(),
edge.To("comments", Comment.Type),
edge.To("tags", Tag.Type),
}
}
func (Post) Indexes() []ent.Index {
return []ent.Index{
index.Fields("slug").Unique(),
index.Fields("status", "published_at"),
}
}
// ent/schema/profile.go
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
)
type Profile struct {
ent.Schema
}
func (Profile) Fields() []ent.Field {
return []ent.Field{
field.String("bio").
Optional().
MaxLen(500),
field.String("avatar_url").
Optional(),
field.String("website").
Optional(),
field.String("location").
Optional(),
}
}
func (Profile) Edges() []ent.Edge {
return []ent.Edge{
edge.From("user", User.Type).
Ref("profile").
Unique().
Required(),
}
}
Step 2: Generate Code
go generate ./ent
This generates all the type-safe code for CRUD operations, queries, and mutations.
Step 3: Implement the Service Layer
// internal/service/user_service.go
package service
import (
"context"
"fmt"
"github.com/example/ent"
"github.com/example/ent/user"
"github.com/example/ent/post"
)
type UserService struct {
client *ent.Client
}
func NewUserService(client *ent.Client) *UserService {
return &UserService{client: client}
}
// CreateUser creates a new user with profile
func (s *UserService) CreateUser(ctx context.Context, email, username, fullName, bio string) (*ent.User, error) {
// Transaction ensures atomicity
tx, err := s.client.Tx(ctx)
if err != nil {
return nil, fmt.Errorf("starting transaction: %w", err)
}
// Create user with profile in one transaction
user, err := tx.User.
Create().
SetEmail(email).
SetUsername(username).
SetFullName(fullName).
SetStatus(user.StatusActive).
Save(ctx)
if err != nil {
tx.Rollback()
return nil, fmt.Errorf("creating user: %w", err)
}
// Create profile
_, err = tx.Profile.
Create().
SetUser(user).
SetBio(bio).
Save(ctx)
if err != nil {
tx.Rollback()
return nil, fmt.Errorf("creating profile: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("committing transaction: %w", err)
}
// Reload user with profile
return s.client.User.
Query().
Where(user.ID(user.ID)).
WithProfile().
Only(ctx)
}
// GetUserWithPosts retrieves user with all their posts
func (s *UserService) GetUserWithPosts(ctx context.Context, id int) (*ent.User, error) {
return s.client.User.
Query().
Where(user.ID(id)).
WithProfile().
WithPosts(func(q *ent.PostQuery) {
q.Where(post.StatusEQ(post.StatusPublished)).
Order(ent.Desc(post.FieldPublishedAt))
}).
Only(ctx)
}
// UpdateUser updates user details
func (s *UserService) UpdateUser(ctx context.Context, id int, fullName, bio string) (*ent.User, error) {
tx, err := s.client.Tx(ctx)
if err != nil {
return nil, err
}
// Update user
user, err := tx.User.
UpdateOneID(id).
SetFullName(fullName).
Save(ctx)
if err != nil {
tx.Rollback()
return nil, err
}
// Update profile
_, err = tx.Profile.
Update().
Where(profile.HasUserWith(user.IDEQ(id))).
SetBio(bio).
Save(ctx)
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.GetUserWithPosts(ctx, id)
}
// ListActiveUsers returns paginated list of active users
func (s *UserService) ListActiveUsers(ctx context.Context, offset, limit int) ([]*ent.User, error) {
return s.client.User.
Query().
Where(user.StatusEQ(user.StatusActive)).
WithProfile().
Order(ent.Desc(user.FieldCreatedAt)).
Limit(limit).
Offset(offset).
All(ctx)
}
// SearchUsersByUsername searches users by username
func (s *UserService) SearchUsersByUsername(ctx context.Context, query string) ([]*ent.User, error) {
return s.client.User.
Query().
Where(user.UsernameContains(query)).
WithProfile().
Limit(20).
All(ctx)
}
// GetUserPostStats returns post statistics for a user
func (s *UserService) GetUserPostStats(ctx context.Context, userID int) (map[string]int, error) {
publishedCount, err := s.client.Post.
Query().
Where(
post.HasAuthorWith(user.IDEQ(userID)),
post.StatusEQ(post.StatusPublished),
).
Count(ctx)
if err != nil {
return nil, err
}
draftCount, err := s.client.Post.
Query().
Where(
post.HasAuthorWith(user.IDEQ(userID)),
post.StatusEQ(post.StatusDraft),
).
Count(ctx)
if err != nil {
return nil, err
}
return map[string]int{
"published": publishedCount,
"draft": draftCount,
}, nil
}
Bun: The SQL-First Flexible Approach
What is Bun?
Bun is a lightweight ORM that:
- ✅ Embraces SQL while providing type safety
- ✅ Has excellent query building capabilities
- ✅ Supports PostgreSQL, MySQL, SQLite, MSSQL
- ✅ Includes migrations and fixtures
- ✅ Provides great performance
When to use Bun:
- Need flexibility in queries
- Want SQL-like syntax
- Existing database you’re working with
- Prefer struct-based models
- Need raw SQL escape hatches
Getting Started with Bun
Installation:
go get github.com/uptrace/bun
go get github.com/uptrace/bun/driver/pgdriver
go get github.com/uptrace/bun/migrate
Step 1: Define Models
// internal/model/user.go
package model
import (
"time"
"github.com/uptrace/bun"
)
type User struct {
bun.BaseModel `bun:"table:users,alias:u"`
ID int64 `bun:"id,pk,autoincrement"`
Email string `bun:"email,notnull,unique"`
Username string `bun:"username,notnull,unique"`
FullName string `bun:"full_name,notnull"`
Status string `bun:"status,notnull,default:'active'"`
CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp"`
UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp"`
// Relations
Profile *Profile `bun:"rel:has-one,join:id=user_id"`
Posts []*Post `bun:"rel:has-many,join:id=author_id"`
}
type Profile struct {
bun.BaseModel `bun:"table:profiles,alias:p"`
ID int64 `bun:"id,pk,autoincrement"`
UserID int64 `bun:"user_id,notnull,unique"`
Bio string `bun:"bio"`
AvatarURL string `bun:"avatar_url"`
Website string `bun:"website"`
Location string `bun:"location"`
// Relations
User *User `bun:"rel:belongs-to,join:user_id=id"`
}
type Post struct {
bun.BaseModel `bun:"table:posts,alias:post"`
ID int64 `bun:"id,pk,autoincrement"`
AuthorID int64 `bun:"author_id,notnull"`
Title string `bun:"title,notnull"`
Content string `bun:"content,notnull"`
Slug string `bun:"slug,notnull,unique"`
Status string `bun:"status,notnull,default:'draft'"`
ViewCount int `bun:"view_count,notnull,default:0"`
PublishedAt *time.Time `bun:"published_at"`
CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp"`
UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp"`
// Relations
Author *User `bun:"rel:belongs-to,join:author_id=id"`
}
Step 2: Setup Database Connection
// internal/database/database.go
package database
import (
"database/sql"
"fmt"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/driver/pgdriver"
"github.com/uptrace/bun/extra/bundebug"
)
type Config struct {
Host string
Port int
User string
Password string
Database string
SSLMode string
}
func NewDB(cfg Config) (*bun.DB, error) {
dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
cfg.User,
cfg.Password,
cfg.Host,
cfg.Port,
cfg.Database,
cfg.SSLMode,
)
sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))
db := bun.NewDB(sqldb, pgdialect.New())
// Add query hook for debugging
db.AddQueryHook(bundebug.NewQueryHook(
bundebug.WithVerbose(true),
))
return db, nil
}
Step 3: Create Migrations
// migrations/20250117000001_create_users.go
package migrations
import (
"context"
"fmt"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
username VARCHAR(50) NOT NULL UNIQUE,
full_name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_status ON users(status);
`)
return err
}, func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS users`)
return err
})
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS profiles (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
bio TEXT,
avatar_url VARCHAR(500),
website VARCHAR(500),
location VARCHAR(255)
);
CREATE INDEX idx_profiles_user_id ON profiles(user_id);
`)
return err
}, func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS profiles`)
return err
})
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS posts (
id BIGSERIAL PRIMARY KEY,
author_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
view_count INT NOT NULL DEFAULT 0,
published_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_slug ON posts(slug);
CREATE INDEX idx_posts_status_published ON posts(status, published_at);
`)
return err
}, func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS posts`)
return err
})
}
Step 4: Implement Repository Pattern
// internal/repository/user_repository.go
package repository
import (
"context"
"database/sql"
"time"
"github.com/example/internal/model"
"github.com/uptrace/bun"
)
type UserRepository struct {
db *bun.DB
}
func NewUserRepository(db *bun.DB) *UserRepository {
return &UserRepository{db: db}
}
// CreateUser creates a new user with profile
func (r *UserRepository) CreateUser(ctx context.Context, email, username, fullName, bio string) (*model.User, error) {
user := &model.User{
Email: email,
Username: username,
FullName: fullName,
Status: "active",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Transaction
err := r.db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error {
// Insert user
if _, err := tx.NewInsert().
Model(user).
Exec(ctx); err != nil {
return err
}
// Insert profile
profile := &model.Profile{
UserID: user.ID,
Bio: bio,
}
if _, err := tx.NewInsert().
Model(profile).
Exec(ctx); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
// Reload with relations
return r.GetUserByID(ctx, user.ID)
}
// GetUserByID retrieves user with profile and posts
func (r *UserRepository) GetUserByID(ctx context.Context, id int64) (*model.User, error) {
user := new(model.User)
err := r.db.NewSelect().
Model(user).
Where("u.id = ?", id).
Relation("Profile").
Relation("Posts", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("status = ?", "published").
Order("published_at DESC")
}).
Scan(ctx)
if err != nil {
return nil, err
}
return user, nil
}
// UpdateUser updates user and profile
func (r *UserRepository) UpdateUser(ctx context.Context, id int64, fullName, bio string) (*model.User, error) {
err := r.db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error {
// Update user
_, err := tx.NewUpdate().
Model(&model.User{}).
Set("full_name = ?", fullName).
Set("updated_at = ?", time.Now()).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return err
}
// Update profile
_, err = tx.NewUpdate().
Model(&model.Profile{}).
Set("bio = ?", bio).
Where("user_id = ?", id).
Exec(ctx)
return err
})
if err != nil {
return nil, err
}
return r.GetUserByID(ctx, id)
}
// ListActiveUsers returns paginated active users
func (r *UserRepository) ListActiveUsers(ctx context.Context, offset, limit int) ([]*model.User, error) {
var users []*model.User
err := r.db.NewSelect().
Model(&users).
Where("status = ?", "active").
Relation("Profile").
Order("created_at DESC").
Limit(limit).
Offset(offset).
Scan(ctx)
return users, err
}
// SearchUsers searches users by username
func (r *UserRepository) SearchUsers(ctx context.Context, query string) ([]*model.User, error) {
var users []*model.User
err := r.db.NewSelect().
Model(&users).
Where("username ILIKE ?", "%"+query+"%").
Relation("Profile").
Limit(20).
Scan(ctx)
return users, err
}
// GetUserStats returns statistics about user's posts
func (r *UserRepository) GetUserStats(ctx context.Context, userID int64) (map[string]int, error) {
var result struct {
Published int `bun:"published"`
Draft int `bun:"draft"`
}
err := r.db.NewSelect().
TableExpr("posts").
ColumnExpr("COUNT(*) FILTER (WHERE status = 'published') as published").
ColumnExpr("COUNT(*) FILTER (WHERE status = 'draft') as draft").
Where("author_id = ?", userID).
Scan(ctx, &result)
if err != nil {
return nil, err
}
return map[string]int{
"published": result.Published,
"draft": result.Draft,
}, nil
}
// Complex query with joins
func (r *UserRepository) GetTopAuthors(ctx context.Context, limit int) ([]*model.User, error) {
var users []*model.User
err := r.db.NewSelect().
Model(&users).
ColumnExpr("u.*").
ColumnExpr("COUNT(post.id) as post_count").
Join("INNER JOIN posts AS post ON post.author_id = u.id").
Where("post.status = ?", "published").
Group("u.id").
Order("post_count DESC").
Limit(limit).
Scan(ctx)
return users, err
}
// Raw SQL query example
func (r *UserRepository) GetUserEngagement(ctx context.Context, userID int64) (map[string]interface{}, error) {
var result map[string]interface{}
err := r.db.NewRaw(`
SELECT
u.username,
COUNT(DISTINCT p.id) as total_posts,
SUM(p.view_count) as total_views,
AVG(p.view_count) as avg_views
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
WHERE u.id = ?
GROUP BY u.id, u.username
`, userID).Scan(ctx, &result)
return result, err
}
Ent vs Bun: Feature Comparison
| Feature | Ent | Bun |
|---|---|---|
| Type Safety | ✅ Excellent (generated) | ✅ Good (struct tags) |
| Schema Definition | Go code | Struct tags + SQL |
| Code Generation | ✅ Yes | ❌ No |
| Query Builder | Graph-style | SQL-style |
| Raw SQL Support | Limited | ✅ Excellent |
| Migrations | Auto-generated | Manual + helpers |
| Relations | ✅ Excellent (graph traversal) | ✅ Good (joins) |
| Learning Curve | Steeper | Gentler |
| Performance | Excellent | Excellent |
| Flexibility | Structured | Very flexible |
| Best For | Complex domains | SQL-heavy apps |
When to Use Which?
Use Ent When:
- ✅ Building new applications from scratch
- ✅ Complex domain models with many relationships
- ✅ Want automatic schema management
- ✅ Prefer graph-style queries
- ✅ Need strong type safety guarantees
- ✅ Want generated CRUD operations
Use Bun When:
- ✅ Working with existing databases
- ✅ Need fine-grained SQL control
- ✅ Want flexibility in queries
- ✅ Prefer SQL-like syntax
- ✅ Need to write complex raw SQL
- ✅ Want lighter-weight solution
Best Practices
1. Always Use Transactions
// Ent
err := client.Tx(ctx, func(tx *ent.Tx) error {
// Your operations
return nil
})
// Bun
err := db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error {
// Your operations
return nil
})
2. Use Context for Cancellation
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
users, err := repo.ListUsers(ctx)
3. Implement Repository Pattern
Keep database logic separate from business logic.
4. Use Migrations
Never modify schema manually in production.
5. Add Proper Indexes
Both support index definitions—use them!
Conclusion
Both Ent and Bun are excellent choices for building reliable data layers in Go:
- Choose Ent for schema-first development with strong type safety and automatic generation
- Choose Bun for SQL-first development with flexibility and fine-grained control
Key Takeaways:
- ✅ Both provide type-safe database operations
- ✅ Both support transactions and migrations
- ✅ Ent excels at complex relationships and graph queries
- ✅ Bun excels at SQL flexibility and existing database integration
- ✅ Use repository pattern with either
- ✅ Always use transactions for multi-step operations
- ✅ Proper indexing is critical for performance
The best choice depends on your specific needs, team preferences, and project constraints. Many teams even use both—Ent for new services and Bun for legacy database integration.