Pular para o conteúdo

Trilha de Go · parte 21 de 37

GORM na Prática: Modelagem, CRUD, Associações e Trade-offs

Como modelar, fazer CRUD e associações com o GORM, quando vale usar um ORM em Go e como evitar armadilhas como N+1 e queries lentas.

Publicada em · 8 min de leitura

Tamanho da fonte:
Largura da coluna:

GORM na Prática: Modelagem, CRUD, Associações e Trade-offs

O GORM (gorm.io/gorm) é o Object-Relational Mapper (ORM) mais difundido no ecossistema Go. Diferente de abordagens baseadas em geração de código estático como o SQLC - Type-Safe SQL ou drivers de acesso direto como database-sql e Connection Pooling e PostgreSQL com pgx, o GORM atua em tempo de execução via reflection, provendo mapeamento automático de entidades, migrações dinâmicas de schema, hooks de ciclo de vida e gerenciamento transparente de relacionamentos complexos.

Embora amplamente adotado em cenários de prototipagem rápida e aplicações corporativas CRUD, o uso profissional do GORM exige compreensão profunda de suas abstrações internas para evitar armadilhas clássicas como o problema do N+1, silenciamento de zero-values em atualizações e overhead excessivo de alocação de memória.


1. Arquitetura Interna: O Ciclo de Vida de uma Query no GORM

Ao executar operações como db.Create(&user) ou db.Find(&users), o GORM não envia a solicitação imediatamente ao banco de dados. Ele constrói dinamicamente uma árvore de execução composta por plugins, callbacks registrados, inspeção de schema por reflexão e compilação de cláusulas SQL via statement builder.

Pipeline de Execução de Queries no GORM
figura 1 · Pipeline de Execução de Queries no GORM

Camadas de Execução:

  1. Application Call: A struct ou slice é passada por ponteiro ao cliente *gorm.DB.
  2. Callbacks & Middleware Chain: O GORM invoca uma cadeia de handlers (como BeforeSave, BeforeCreate, validações customizadas).
  3. Statement Builder & Reflection: O schema da struct é inspecionado (campos, tags gorm:"..."), e o dialeto configurado (Postgres, MySQL, SQLite) traduz os identificadores e cláusulas em SQL parametrizado.
  4. database/sql & Driver: A query entra no connection pool do Go padrão e viaja via socket TCP até a engine do banco de dados.
  5. Reflection Scan & Hydration: Os bytes brutos retornados são decodificados e injetados de volta nos campos da struct em memória Go.
  6. Post-Hooks: Callbacks finais como AfterCreate ou AfterFind são disparados antes de devolver o controle à aplicação.

2. Definindo Models, Convenções e Struct Tags

Por padrão, o GORM adota a estratégia de convenção sobre configuração:

  • Tabelas: Nomes de structs em CamelCase são pluralizados e convertidos em snake_case (ex: UserProfile vira user_profiles).
  • Primary Key: O campo nomeado ID vira chave primária auto-incremento.
  • Foreign Keys: Nome do model + ID (ex: UserID).

Estrutura Base e Tags Avançadas

package model

import (
	"time"
	"gorm.io/gorm"
)

// User representa o modelo do usuário com rastreabilidade e soft delete
type User struct {
	ID        uint           `gorm:"primaryKey"`
	Name      string         `gorm:"type:varchar(100);not null"`
	Email     string         `gorm:"type:varchar(150);uniqueIndex;not null"`
	Age       uint8          `gorm:"check:age >= 18"`
	Role      string         `gorm:"type:varchar(30);default:'customer';index"`
	Orders    []Order        `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // HasMany
	Profile   Profile        `gorm:"foreignKey:UserID"`                                               // HasOne
	CreatedAt time.Time      `gorm:"autoCreateTime"`
	UpdatedAt time.Time      `gorm:"autoUpdateTime"`
	DeletedAt gorm.DeletedAt `gorm:"index"` // Habilita Soft Delete nativo
}

type Profile struct {
	ID        uint   `gorm:"primaryKey"`
	UserID    uint   `gorm:"uniqueIndex;not null"`
	Bio       string `gorm:"type:text"`
	AvatarURL string `gorm:"type:varchar(255)"`
}

type Order struct {
	ID          uint      `gorm:"primaryKey"`
	UserID      uint      `gorm:"index;not null"`
	User        User      `gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT;"` // BelongsTo
	TotalAmount float64   `gorm:"type:decimal(12,2);not null"`
	Status      string    `gorm:"type:varchar(20);default:'pending';index"`
	CreatedAt   time.Time
}

3. Conexão e AutoMigrate

O GORM se conecta aos bancos através de dialetos específicos (gorm.io/driver/postgres, gorm.io/driver/sqlite, etc.):

package main

import (
	"log"
	"time"

	"gorm.io/driver/postgres"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

func InitDatabase(dsn string) (*gorm.DB, error) {
	db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Warn), // Loga queries lentas e erros
		NowFunc: func() time.Time {
			return time.Now().UTC() // Garante armazenamento em UTC consistente
		},
	})
	if err != nil {
		return nil, err
	}

	// Obtendo o *sql.DB subjacente para configurar o connection pool
	sqlDB, err := db.DB()
	if err != nil {
		return nil, err
	}

	sqlDB.SetMaxOpenConns(25)
	sqlDB.SetMaxIdleConns(10)
	sqlDB.SetConnMaxLifetime(15 * time.Minute)

	// AutoMigrate cria tabelas e índices ausentes sem deletar colunas não utilizadas
	if err := db.AutoMigrate(&User{}, &Profile{}, &Order{}); err != nil {
		return nil, err
	}

	return db, nil
}

4. Operações CRUD Completas

4.1 Create & Batch Insert

// Inserção individual
newUser := User{Name: "Ada Lovelace", Email: "ada@example.com", Age: 36}
if result := db.Create(&newUser); result.Error != nil {
    log.Fatalf("Erro ao criar usuário: %v", result.Error)
}
// newUser.ID é preenchido automaticamente pelo driver

// Batch Insert eficiente (gera uma única query INSERT com múltiplos VALUES)
batchUsers := []User{
    {Name: "Alan Turing", Email: "alan@example.com", Age: 41},
    {Name: "Grace Hopper", Email: "grace@example.com", Age: 85},
}
db.Create(&batchUsers) // Inserção em lote nativa

4.2 Leitura com First, Find e gorm.ErrRecordNotFound

Um dos erros mais comuns de iniciantes é não verificar especificamente por gorm.ErrRecordNotFound:

var user User

// First busca pelo primeiro registro ordenado pela primary key e adiciona LIMIT 1
result := db.Where("email = ?", "ada@example.com").First(&user)
if result.Error != nil {
    if errors.Is(result.Error, gorm.ErrRecordNotFound) {
        log.Println("Nenhum usuário localizado com esse email")
        return
    }
    log.Fatalf("Erro inesperado de banco de dados: %v", result.Error)
}

// Find busca múltiplos registros e preenche um slice (NÃO retorna ErrRecordNotFound se vazio)
var orders []Order
db.Where("user_id = ? AND status = ?", user.ID, "completed").Find(&orders)
// Se nada for encontrado, len(orders) == 0 e result.Error == nil

4.3 Armadilha Crítica: Atualizações e Zero-Values

No Go, tipos possuem valores padrão (string vazia "", número 0, booleano false). O GORM por padrão ignora zero-values ao receber uma struct no Updates():

// ❌ PROBLEMA SILENCIOSO:
// Se quisermos zerar a idade do usuário para 0 ou desativar uma flag booleana:
db.Model(&user).Updates(User{Age: 0, Name: "Ada L."})
// O SQL gerado será: UPDATE users SET name = 'Ada L.' WHERE id = 1;
// O campo Age foi IGNORADO porque 0 é o zero-value de uint8!

// ✅ SOLUÇÃO 1: Usando map[string]any
db.Model(&user).Updates(map[string]any{
    "age":  0,
    "name": "Ada L.",
})

// ✅ SOLUÇÃO 2: Forçando campos com Select()
db.Model(&user).Select("Age", "Name").Updates(User{Age: 0, Name: "Ada L."})

4.4 Soft Delete vs Hard Delete

Se o modelo possuir o campo gorm.DeletedAt:

  • db.Delete(&user): Executa um UPDATE users SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?.
  • Queries normais (db.Find(&users)) automaticamente adicionam WHERE deleted_at IS NULL.
  • Para buscar itens incluindo deletados: db.Unscoped().Find(&users).
  • Para remoção física definitiva do disco: db.Unscoped().Delete(&user).

5. Gerenciamento de Associações e Mitigação do N+1

O problema do N+1 ocorre quando a aplicação busca uma lista de $N$ registros principais e, para cada um, realiza uma consulta separada no banco para carregar entidades relacionadas.

Resolução do Problema N+1 com Preload
figura 2 · Resolução do Problema N+1 com Preload

A Abordagem Ingênua (N+1 Queries)

// ❌ Código que detona o pool de conexões:
var users []User
db.Find(&users) // 1 Query para N usuários

for i := range users {
    // N Queries adicionais disparadas sequencialmente via rede!
    db.Model(&users[i]).Association("Orders").Find(&users[i].Orders)
}

A Solução Profissional: Preload

O método Preload do GORM executa apenas 2 queries:

  1. Busca todos os usuários correspondentes.
  2. Extrai a lista de IDs de usuários em memória Go.
  3. Executa um único SELECT * FROM orders WHERE user_id IN (1, 2, 3...) e faz a hidratação em memória.
// ✅ Executa apenas 2 queries via socket:
var users []User
err := db.Preload("Orders", "status = ?", "paid").
    Preload("Profile").
    Where("age >= ?", 18).
    Find(&users).Error

Preload vs Joins:

  • Use Preload para associações HasMany e Many2Many (evita desduplicação de cartesian product).
  • Use Joins para associações BelongsTo ou HasOne quando desejar fazer tudo em uma única query INNER JOIN / LEFT JOIN:
    var orders []Order
    db.Joins("User").Where("User.role = ?", "admin").Find(&orders)

6. Transações Seguras com db.Transaction

Transações no GORM devem garantir o fechamento ou rollback automático caso qualquer etapa retorne erro ou lance pânico em runtime:

func TransferFunds(db *gorm.DB, fromUserID, toUserID uint, amount float64) error {
	return db.Transaction(func(tx *gorm.DB) error {
		// 1. Debita da conta de origem (com verificação de saldo)
		res := tx.Model(&Account{}).
			Where("user_id = ? AND balance >= ?", fromUserID, amount).
			Update("balance", gorm.Expr("balance - ?", amount))
		if res.Error != nil {
			return res.Error
		}
		if res.RowsAffected == 0 {
			return errors.New("saldo insuficiente ou conta de origem inexistente")
		}

		// 2. Credita na conta de destino
		if err := tx.Model(&Account{}).
			Where("user_id = ?", toUserID).
			Update("balance", gorm.Expr("balance + ?", amount)).Error; err != nil {
			return err
		}

		// 3. Registra log de auditoria
		logEntry := AuditLog{
			FromUser: fromUserID,
			ToUser:   toUserID,
			Amount:   amount,
			Status:   "completed",
		}
		if err := tx.Create(&logEntry).Error; err != nil {
			return err
		}

		// Retornar nil confirma o COMMIT; qualquer erro dispara o ROLLBACK automático
		return nil
	})
}

7. Lifecycle Hooks e Callbacks

O GORM permite interceptar eventos de modelo implementando interfaces como BeforeCreate, AfterCreate, BeforeUpdate, AfterDelete:

// Hook executado antes do INSERT
func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
	u.Email = strings.ToLower(strings.TrimSpace(u.Email))
	
	if len(u.Name) < 3 {
		return errors.New("nome do usuário deve conter no mínimo 3 caracteres")
	}

	// É possível inclusive consultar o banco utilizando a instância tx
	var count int64
	tx.Model(&User{}).Where("email = ?", u.Email).Count(&count)
	if count > 0 {
		return errors.New("email já cadastrado no sistema")
	}

	return nil
}

8. Comparativo de Engenharia: GORM vs SQLC vs pgx

CritérioGORM (gorm.io/gorm)SQLC (sqlc)pgx nativo (jackc/pgx)
AbordagemRuntime ORM dinâmicoCompile-time Code GenerationDriver PostgreSQL de Baixo Nível
Type SafetyParcial (strings em queries e tags)100% estrito em compilaçãoParcial (Scan com ponteiros manuais)
Alocações de MemóriaMédio-Alto (Reflection pesada)Quase nulo (Zero-overhead)Mínimo absoluto
Latência por Query~0.8ms - 2.5ms~0.1ms - 0.4ms~0.05ms - 0.2ms
Modelagem de RelaçõesAutomática (Preload, Associations)Manual via queries SQL JOINManual via queries SQL JOIN
ManutenibilidadeFácil no início, complexo em queries SQL avançadasExtremamente alta e previsívelAlta para engenheiros sêniores de SQL
Caso de Uso IdealCRUDs corporativos, protótipos, MVPs rápidosMicrosserviços de alta escala e sistemas coreHigh-frequency trading, streaming de dados, pipelines analíticos

9. Armadilhas Comuns e Como Evitá-las

  1. Reutilizar Instâncias com Condições Acumuladas: O objeto *gorm.DB acumula cláusulas Where quando encadeado indevidamente em variáveis globais. Sempre derive uma nova sessão usando db.WithContext(ctx) ou execute consultas diretamente a partir da conexão base injetada.
  2. Ignorar o Context: Sempre use db.WithContext(ctx) para respeitar cancelamentos de requisições HTTP e timeouts de banco de dados.
  3. Contar Registros com Find: Não use db.Find(&users).RowsAffected para contar total de itens de paginação em tabelas grandes; use db.Model(&User{}).Count(&total), que emite um SELECT COUNT(*) otimizado sem alocar memória para os registros.

10. Resumo Prático

  • Use gorm.Model ou structs customizadas com tags explícitas gorm:"primaryKey;index;uniqueIndex".
  • Nunca assuma que Updates(struct) atualizará booleanos falsos ou zeros; prefira Updates(map[string]any) ou Select().
  • Combata o N+1 sistematicamente através de Preload("Relacao") ou Joins().
  • Encapsule mutações interdependentes com db.Transaction(func(tx *gorm.DB) error { ... }).
  • Verifique a ausência de registros especificamente com errors.Is(err, gorm.ErrRecordNotFound).
  • Em microsserviços de alta throughput e baixa latência, avalie se a conveniência do GORM compensa o custo de reflexão quando comparado ao SQLC - Type-Safe SQL.

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?