tool
GORM
GORM
The most popular ORM for Go. Active record-style API with auto-migrations, soft deletes, hooks, and associations. Pragmatic if you want one tool that does everything; opinionated in ways that don’t always reward you.
Why it’s the default
- Familiar API for anyone coming from Rails/Django/Hibernate:
db.First(&user, id),db.Where("active = ?", true).Find(&users). - Auto-migration:
db.AutoMigrate(&User{})keeps schema in sync with structs in dev. (Don’t use in prod — see warnings below.) - Hooks:
BeforeSave,AfterFind, etc. for cross-cutting logic. - Associations: HasMany/BelongsTo/ManyToMany handled via struct tags + callbacks.
- Soft deletes out of the box via
gorm.DeletedAt.
The honest case against
GORM is contentious in the Go community. Reasons:
- Reflection-heavy runtime — slower than raw
database/sqland harder to profile. - Hidden N+1 traps when associations auto-load.
- Opaque SQL — debugging requires
db.Debug()to see what was actually run. - Auto-migration in prod is a foot-gun — silently drops indexes, can lock large tables.
- Type-safety only at struct level — strings for column names, queries unchecked at compile time.
The increasingly popular alternative is sqlc: write SQL in .sql files, generate type-safe Go from them. You get exact SQL visibility and compile-time guarantees, at the cost of less “magic.” For new kulify Go services with non-trivial queries, sqlc is the better default.
When GORM still wins
- Greenfield project with simple CRUD and many tables — auto-migration during dev pays off.
- Team already knows GORM — switching cost not worth it for a side project.
- The 3-layer pattern in go-layered-architecture confines GORM to the Repository layer, where its rough edges are isolated.
Repository pattern wrapper
GORM’s *gorm.DB should not leak past Repository:
type repository struct {
db *gorm.DB
}
func (r *repository) GetUser(ctx context.Context, id string) (*User, error) {
var u User
if err := r.db.WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
return nil, err
}
return &u, nil
}
The interface above (type Repository interface { GetUser(...) (*User, error) }) hides GORM entirely — Service depends on the interface, mockery generates a mock against it, no *gorm.DB in tests.
Transaction caveat
The big unsolved problem in go-layered-architecture: a Service that needs to call multiple Repository methods atomically has no clean way to share a *gorm.DB transaction without leaking GORM upward. Workarounds:
- Pass a
tx Txinterface (whereTxis your own type wrapping*gorm.DB) - Run multi-step writes inside a single Repository method
- Use a Unit-of-Work pattern
kulify usage
- No current kulify project uses GORM. katastar uses raw
database/sqlor pgx. - Likely default for any new kulify Go service that prioritizes velocity over per-query control. For queries-as-code-first, sqlc would be the better pick.
Related
- go-layered-architecture — where GORM lives (Repository layer only)
- gin — the typical companion HTTP framework
- mockery — what makes the Repository-interface boundary actually testable