Golang

GORM: ORM para Go, migrações e relacionamentos Já leu

12 min de leitura

GORM: ORM para Go, migrações e relacionamentos
ORM — Object-Relational Mapper — é uma camada que mapeia structs Go para tabelas de banco de dados, gerando SQL automaticamente. O GORM é o ORM mais popular do ecossistema Go, com suporte a MySQL, PostgreSQL, SQLite e SQ

ORM — Object-Relational Mapper — é uma camada que mapeia structs Go para tabelas de banco de dados, gerando SQL automaticamente. O GORM é o ORM mais popular do ecossistema Go, com suporte a MySQL, PostgreSQL, SQLite e SQL Server.

A decisão de usar ORM versus SQL puro é uma das mais debatidas em desenvolvimento backend. ORMs aceleram o desenvolvimento inicial, eliminam SQL repetitivo e facilitam migrações. Em contrapartida, podem gerar queries ineficientes, obscurecer o que acontece no banco e tornar problemas de performance difíceis de diagnosticar.

A posição pragmática: use GORM para CRUD simples e migrações, escreva SQL explícito para queries complexas e de alta performance. O GORM não impede isso — permite executar SQL bruto a qualquer momento.


Instalação

go get gorm.io/gorm
go get gorm.io/driver/postgres  # PostgreSQL
go get gorm.io/driver/mysql     # MySQL
go get gorm.io/driver/sqlite    # SQLite

Conexão e configuração

package main

import (
    "fmt"
    "log"
    "os"
    "time"

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

func abrirPostgres(dsn string) (*gorm.DB, error) {
    config := &gorm.Config{
        Logger: logger.New(
            log.New(os.Stdout, "\r\n", log.LstdFlags),
            logger.Config{
                SlowThreshold:             200 * time.Millisecond,
                LogLevel:                  logger.Warn, // loga apenas queries lentas
                IgnoreRecordNotFoundError: true,
                Colorful:                  true,
            },
        ),
        NowFunc: func() time.Time {
            return time.Now().UTC()
        },
        PrepareStmt: true, // cache de prepared statements
    }

    db, err := gorm.Open(postgres.Open(dsn), config)
    if err != nil {
        return nil, fmt.Errorf("abrir postgres: %w", err)
    }

    // Configurar o pool de conexões subjacente
    sqlDB, err := db.DB()
    if err != nil {
        return nil, err
    }

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

    return db, nil
}

func abrirSQLite(caminho string) (*gorm.DB, error) {
    return gorm.Open(sqlite.Open(caminho), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Silent),
    })
}

Modelos: a base do GORM

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

// gorm.Model inclui ID, CreatedAt, UpdatedAt e DeletedAt (soft delete)
type Categoria struct {
    gorm.Model
    Nome string `gorm:"size:100;not null;uniqueIndex"`
    Slug string `gorm:"size:100;not null;uniqueIndex"`
}

type Produto struct {
    gorm.Model
    CategoriaID uint       `gorm:"not null;index"`
    Categoria   Categoria  `gorm:"foreignKey:CategoriaID"`
    Nome        string     `gorm:"size:200;not null;index"`
    Descricao   string     `gorm:"type:text"`
    Preco       float64    `gorm:"type:decimal(10,2);not null"`
    Estoque     int        `gorm:"not null;default:0"`
    Ativo       bool       `gorm:"not null;default:true;index"`
    Tags        []Tag      `gorm:"many2many:produto_tags"`
}

type Tag struct {
    gorm.Model
    Nome     string    `gorm:"size:50;not null;uniqueIndex"`
    Produtos []Produto `gorm:"many2many:produto_tags"`
}

type Usuario struct {
    gorm.Model
    Nome     string    `gorm:"size:200;not null"`
    Email    string    `gorm:"size:200;not null;uniqueIndex"`
    Senha    string    `gorm:"size:255;not null"`
    Ativo    bool      `gorm:"not null;default:true"`
    Pedidos  []Pedido  `gorm:"foreignKey:UsuarioID"`
}

type Pedido struct {
    gorm.Model
    UsuarioID   uint        `gorm:"not null;index"`
    Usuario     Usuario     `gorm:"foreignKey:UsuarioID"`
    Total       float64     `gorm:"type:decimal(10,2);not null;default:0"`
    Status      string      `gorm:"size:50;not null;default:'pendente'"`
    ItensPedido []ItemPedido `gorm:"foreignKey:PedidoID"`
}

type ItemPedido struct {
    gorm.Model
    PedidoID  uint    `gorm:"not null;index"`
    ProdutoID uint    `gorm:"not null;index"`
    Produto   Produto `gorm:"foreignKey:ProdutoID"`
    Quantidade int    `gorm:"not null"`
    PrecoUnit float64 `gorm:"type:decimal(10,2);not null"`
}

AutoMigrate: migrações automáticas

func executarMigracoes(db *gorm.DB) error {
    return db.AutoMigrate(
        &Categoria{},
        &Tag{},
        &Produto{},
        &Usuario{},
        &Pedido{},
        &ItemPedido{},
    )
}

AutoMigrate cria tabelas que não existem, adiciona colunas faltantes e cria índices. Ele nunca remove colunas ou altera tipos existentes — é seguro para produção. Para mudanças destrutivas, use migrações manuais com golang-migrate.


CRUD básico

package main

import (
    "errors"
    "fmt"

    "gorm.io/gorm"
)

// CREATE
func criarProduto(db *gorm.DB, input ProdutoInput) (*Produto, error) {
    produto := Produto{
        CategoriaID: input.CategoriaID,
        Nome:        input.Nome,
        Descricao:   input.Descricao,
        Preco:       input.Preco,
        Estoque:     input.Estoque,
    }

    if err := db.Create(&produto).Error; err != nil {
        return nil, fmt.Errorf("criar produto: %w", err)
    }

    return &produto, nil
}

// READ — por ID
func buscarProdutoPorID(db *gorm.DB, id uint) (*Produto, error) {
    var produto Produto

    err := db.First(&produto, id).Error
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, fmt.Errorf("produto %d não encontrado", id)
    }
    if err != nil {
        return nil, fmt.Errorf("buscar produto: %w", err)
    }

    return &produto, nil
}

// READ — com preload de relacionamentos
func buscarProdutoCompleto(db *gorm.DB, id uint) (*Produto, error) {
    var produto Produto

    err := db.
        Preload("Categoria").
        Preload("Tags").
        First(&produto, id).Error

    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, fmt.Errorf("produto %d não encontrado", id)
    }

    return &produto, err
}

// UPDATE — apenas campos alterados
func atualizarProduto(db *gorm.DB, id uint, campos map[string]any) error {
    resultado := db.Model(&Produto{}).Where("id = ?", id).Updates(campos)

    if resultado.Error != nil {
        return fmt.Errorf("atualizar produto: %w", resultado.Error)
    }
    if resultado.RowsAffected == 0 {
        return fmt.Errorf("produto %d não encontrado", id)
    }

    return nil
}

// DELETE — soft delete (marca deleted_at, não remove fisicamente)
func deletarProduto(db *gorm.DB, id uint) error {
    resultado := db.Delete(&Produto{}, id)

    if resultado.Error != nil {
        return fmt.Errorf("deletar produto: %w", resultado.Error)
    }
    if resultado.RowsAffected == 0 {
        return fmt.Errorf("produto %d não encontrado", id)
    }

    return nil
}

// DELETE permanente — ignora soft delete
func deletarProdutoPermanente(db *gorm.DB, id uint) error {
    return db.Unscoped().Delete(&Produto{}, id).Error
}

Consultas avançadas

func listarProdutosAvancado(db *gorm.DB, f FiltroProduto) ([]Produto, int64, error) {
    var produtos []Produto
    var total int64

    query := db.Model(&Produto{})

    // Filtros condicionais
    if f.CategoriaID > 0 {
        query = query.Where("categoria_id = ?", f.CategoriaID)
    }

    if f.ApenasAtivo {
        query = query.Where("ativo = ?", true)
    }

    if f.PrecoMin > 0 {
        query = query.Where("preco >= ?", f.PrecoMin)
    }

    if f.PrecoMax > 0 {
        query = query.Where("preco <= ?", f.PrecoMax)
    }

    if f.Busca != "" {
        query = query.Where("nome ILIKE ?", "%"+f.Busca+"%")
    }

    // Conta o total antes da paginação
    query.Count(&total)

    // Aplica paginação e ordenação
    limite := f.Limite
    if limite <= 0 {
        limite = 20
    }

    err := query.
        Preload("Categoria").
        Preload("Tags").
        Order("nome ASC").
        Limit(limite).
        Offset((f.Pagina - 1) * limite).
        Find(&produtos).Error

    return produtos, total, err
}

// Scopes: encapsulando condições reutilizáveis
func ScopeAtivo(db *gorm.DB) *gorm.DB {
    return db.Where("ativo = ?", true)
}

func ScopeComEstoque(db *gorm.DB) *gorm.DB {
    return db.Where("estoque > 0")
}

func ScopeCategoria(id uint) func(*gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        return db.Where("categoria_id = ?", id)
    }
}

func produtosDisponiveisNaCategoria(db *gorm.DB, categoriaID uint) ([]Produto, error) {
    var produtos []Produto
    err := db.
        Scopes(ScopeAtivo, ScopeComEstoque, ScopeCategoria(categoriaID)).
        Order("preco ASC").
        Find(&produtos).Error
    return produtos, err
}

Relacionamentos: has many, belongs to, many2many

// Has Many: Um usuário tem muitos pedidos
func pedidosDoUsuario(db *gorm.DB, usuarioID uint) ([]Pedido, error) {
    var pedidos []Pedido
    err := db.
        Where("usuario_id = ?", usuarioID).
        Preload("ItensPedido.Produto").
        Order("created_at DESC").
        Find(&pedidos).Error
    return pedidos, err
}

// Many to Many: associando tags a produto
func associarTags(db *gorm.DB, produtoID uint, nomeTags []string) error {
    var produto Produto
    if err := db.First(&produto, produtoID).Error; err != nil {
        return err
    }

    var tags []Tag
    for _, nome := range nomeTags {
        var tag Tag
        // FirstOrCreate: busca ou cria se não existir
        db.FirstOrCreate(&tag, Tag{Nome: nome})
        tags = append(tags, tag)
    }

    // Association replaces existing tags
    return db.Model(&produto).Association("Tags").Replace(tags)
}

// Contando associações
func contagemPorCategoria(db *gorm.DB) ([]map[string]any, error) {
    var resultados []map[string]any

    err := db.Model(&Produto{}).
        Select("categorias.nome as categoria, count(produtos.id) as total").
        Joins("JOIN categorias ON categorias.id = produtos.categoria_id").
        Where("produtos.ativo = ?", true).
        Group("categorias.id, categorias.nome").
        Order("total DESC").
        Scan(&resultados).Error

    return resultados, err
}

Transações no GORM

func criarPedidoCompleto(db *gorm.DB, usuarioID uint, itens []ItemInput) (*Pedido, error) {
    var pedido Pedido

    err := db.Transaction(func(tx *gorm.DB) error {
        // Cria o pedido
        pedido = Pedido{
            UsuarioID: usuarioID,
            Status:    "confirmado",
        }

        if err := tx.Create(&pedido).Error; err != nil {
            return fmt.Errorf("criar pedido: %w", err)
        }

        var totalPedido float64

        for _, item := range itens {
            // Busca produto e bloqueia para atualização
            var produto Produto
            err := tx.Set("gorm:query_option", "FOR UPDATE").
                First(&produto, item.ProdutoID).Error
            if err != nil {
                return fmt.Errorf("buscar produto %d: %w", item.ProdutoID, err)
            }

            if produto.Estoque < item.Quantidade {
                return fmt.Errorf("estoque insuficiente para %s", produto.Nome)
            }

            // Cria item do pedido
            itemPedido := ItemPedido{
                PedidoID:  pedido.ID,
                ProdutoID: produto.ID,
                Quantidade: item.Quantidade,
                PrecoUnit: produto.Preco,
            }

            if err := tx.Create(&itemPedido).Error; err != nil {
                return fmt.Errorf("criar item: %w", err)
            }

            // Atualiza estoque
            if err := tx.Model(&produto).
                Update("estoque", gorm.Expr("estoque - ?", item.Quantidade)).Error; err != nil {
                return fmt.Errorf("atualizar estoque: %w", err)
            }

            totalPedido += produto.Preco * float64(item.Quantidade)
        }

        // Atualiza total do pedido
        return tx.Model(&pedido).Update("total", totalPedido).Error
    })

    if err != nil {
        return nil, err
    }

    return &pedido, nil
}

SQL bruto quando necessário

// Raw SQL para queries complexas
func relatorioVendas(db *gorm.DB, mes, ano int) ([]map[string]any, error) {
    var resultados []map[string]any

    err := db.Raw(`
        SELECT
            c.nome AS categoria,
            COUNT(DISTINCT p.id) AS total_pedidos,
            SUM(ip.quantidade) AS unidades_vendidas,
            SUM(ip.quantidade * ip.preco_unit) AS receita_total,
            AVG(ip.preco_unit) AS ticket_medio
        FROM itens_pedido ip
        JOIN pedidos p ON p.id = ip.pedido_id
        JOIN produtos pr ON pr.id = ip.produto_id
        JOIN categorias c ON c.id = pr.categoria_id
        WHERE
            EXTRACT(MONTH FROM p.created_at) = ?
            AND EXTRACT(YEAR FROM p.created_at) = ?
            AND p.status != 'cancelado'
            AND p.deleted_at IS NULL
        GROUP BY c.id, c.nome
        ORDER BY receita_total DESC
    `, mes, ano).Scan(&resultados).Error

    return resultados, err
}

// Exec para statements sem retorno
func desativarProdutosSemEstoque(db *gorm.DB) (int64, error) {
    resultado := db.Exec(
        "UPDATE produtos SET ativo = false WHERE estoque = 0 AND ativo = true")
    return resultado.RowsAffected, resultado.Error
}

Hooks: lógica antes e depois de operações

// BeforeCreate é chamado automaticamente antes de cada INSERT
func (p *Produto) BeforeCreate(tx *gorm.DB) error {
    if p.Preco <= 0 {
        return fmt.Errorf("preço deve ser positivo")
    }
    if p.Nome == "" {
        return fmt.Errorf("nome é obrigatório")
    }
    return nil
}

// AfterCreate é chamado após cada INSERT bem-sucedido
func (p *Produto) AfterCreate(tx *gorm.DB) error {
    // Poderia disparar evento, invalidar cache, etc.
    fmt.Printf("Produto criado: ID=%d Nome=%s\n", p.ID, p.Nome)
    return nil
}

// BeforeDelete previne deleção de produtos com pedidos
func (p *Produto) BeforeDelete(tx *gorm.DB) error {
    var count int64
    tx.Model(&ItemPedido{}).Where("produto_id = ?", p.ID).Count(&count)
    if count > 0 {
        return fmt.Errorf("produto possui %d pedidos — não pode ser excluído", count)
    }
    return nil
}

Testes com GORM e SQLite em memória

package main

import (
    "testing"

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

func configurarDBTeste(t *testing.T) *gorm.DB {
    t.Helper()

    db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Silent),
    })
    if err != nil {
        t.Fatalf("abrir DB de teste: %v", err)
    }

    if err := db.AutoMigrate(&Categoria{}, &Tag{}, &Produto{},
        &Usuario{}, &Pedido{}, &ItemPedido{}); err != nil {
        t.Fatalf("migrar schema de teste: %v", err)
    }

    t.Cleanup(func() {
        sqlDB, _ := db.DB()
        sqlDB.Close()
    })

    return db
}

func TestCriarProduto(t *testing.T) {
    db := configurarDBTeste(t)

    // Cria categoria primeiro
    cat := Categoria{Nome: "Eletrônicos", Slug: "eletronicos"}
    db.Create(&cat)

    produto, err := criarProduto(db, ProdutoInput{
        CategoriaID: cat.ID,
        Nome:        "Notebook",
        Preco:       4500.00,
        Estoque:     10,
    })

    if err != nil {
        t.Fatalf("criar produto: %v", err)
    }

    if produto.ID == 0 {
        t.Error("ID não foi gerado")
    }

    // Verifica no banco
    buscado, err := buscarProdutoPorID(db, produto.ID)
    if err != nil {
        t.Fatalf("buscar produto: %v", err)
    }

    if buscado.Nome != "Notebook" {
        t.Errorf("nome esperado 'Notebook', obtido '%s'", buscado.Nome)
    }
}

func TestSoftDelete(t *testing.T) {
    db := configurarDBTeste(t)
    cat := Categoria{Nome: "Teste", Slug: "teste"}
    db.Create(&cat)

    produto, _ := criarProduto(db, ProdutoInput{
        CategoriaID: cat.ID,
        Nome:        "Produto Soft Delete",
        Preco:       100.0,
    })

    deletarProduto(db, produto.ID)

    // Soft delete — não encontrado na busca normal
    _, err := buscarProdutoPorID(db, produto.ID)
    if err == nil {
        t.Error("produto deletado ainda foi encontrado")
    }

    // Mas existe com Unscoped
    var count int64
    db.Unscoped().Model(&Produto{}).Where("id = ?", produto.ID).Count(&count)
    if count == 0 {
        t.Error("produto deveria existir com Unscoped")
    }
}

Resumo do que foi coberto

Este artigo apresentou GORM em profundidade: conexão e configuração com diferentes bancos, definição de modelos com gorm.Model e tags, AutoMigrate para criação automática de schema, CRUD completo com Create, First, Updates e Delete, soft delete e delete permanente com Unscoped, consultas avançadas com filtros condicionais e scopes reutilizáveis, relacionamentos has many, belongs to e many-to-many com Preload, transações via db.Transaction, SQL bruto para casos complexos, hooks de ciclo de vida e testes com SQLite em memória. Com este artigo o Módulo 6 de banco de dados está completo.


Referências e leituras complementares

  • Documentação oficial do GORM — Guia completo com exemplos de todos os recursos. https://gorm.io/docs/

  • GORM — Associations — Referência completa de relacionamentos. https://gorm.io/docs/associations.html

  • GORM — Migrations — Estratégias de migração automática e manual. https://gorm.io/docs/migration.html

  • golang-migrate — Migrações versionadas compatíveis com GORM. https://github.com/golang-migrate/migrate

  • GORM — Hooks — Referência de todos os hooks disponíveis. https://gorm.io/docs/hooks.html

  • sqlc — Alternativa ao ORM: gera código Go tipado a partir de SQL. https://sqlc.dev

Comentários

Mais em Golang

Artigo 43 — Profiling e otimização de performance em Go
Artigo 43 — Profiling e otimização de performance em Go

Artigo 43 — Profiling e otimização de performance em Go Otimizar sem medir é...

O pacote context: propagação de cancelamento e prazos
O pacote context: propagação de cancelamento e prazos

Imagine um servidor HTTP recebendo uma requisição que desencadeia três operaç...

Artigo 42 — Documentação com godoc e boas práticas de comentário
Artigo 42 — Documentação com godoc e boas práticas de comentário

Artigo 42 — Documentação com godoc e boas práticas de comentário Documentação...