Настройка GORM для Go веб-приложения
GORM — наиболее популярный ORM для Go. Не пытается скрыть SQL полностью, даёт прямой доступ к raw queries там, где это нужно. Версия v2 несовместима с v1 по API.
go get gorm.io/gorm
go get gorm.io/driver/postgres
# или MySQL:
go get gorm.io/driver/mysql
Инициализация подключения
// internal/db/db.go
package db
import (
"fmt"
"log"
"os"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func New(dsn string) (*gorm.DB, error) {
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
)
db, err := gorm.Open(postgres.New(postgres.Config{
DSN: dsn,
PreferSimpleProtocol: true, // отключает prepared statements — нужно для pgBouncer
}), &gorm.Config{
Logger: newLogger,
NowFunc: func() time.Time { return time.Now().UTC() },
PrepareStmt: false, // согласовано с PreferSimpleProtocol
DisableForeignKeyConstraintWhenMigrating: false,
})
if err != nil {
return nil, fmt.Errorf("gorm.Open: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("db.DB(): %w", err)
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
sqlDB.SetConnMaxIdleTime(2 * time.Minute)
return db, nil
}
PreferSimpleProtocol: true и PrepareStmt: false — обязательны при работе через pgBouncer в transaction pooling mode. С prepared statements pgBouncer будет выдавать ошибки.
Модели
// internal/models/product.go
package models
import (
"time"
"gorm.io/gorm"
)
type ProductStatus string
const (
StatusDraft ProductStatus = "draft"
StatusPublished ProductStatus = "published"
StatusArchived ProductStatus = "archived"
)
type Product struct {
ID uint `gorm:"primarykey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` // soft delete
Title string `gorm:"type:varchar(500);not null"`
Slug string `gorm:"type:varchar(520);uniqueIndex;not null"`
Price float64 `gorm:"type:decimal(12,2);not null"`
Status ProductStatus `gorm:"type:varchar(20);default:draft;not null"`
CategoryID uint `gorm:"not null;index"`
Category Category `gorm:"foreignKey:CategoryID;constraint:OnDelete:RESTRICT"`
Tags []Tag `gorm:"many2many:product_tags;"`
Images []ProductImage `gorm:"foreignKey:ProductID;constraint:OnDelete:CASCADE"`
}
func (Product) TableName() string {
return "products"
}
GORM по умолчанию ищет таблицу products для Product (pluralize + snake_case). Явный TableName() убирает неоднозначность.
AutoMigrate vs SQL-миграции
AutoMigrate подходит только для разработки:
err := db.AutoMigrate(&models.User{}, &models.Product{}, &models.Category{})
Для production используем golang-migrate:
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
migrate create -ext sql -dir migrations -seq create_products
-- migrations/000002_create_products.up.sql
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
slug VARCHAR(520) NOT NULL UNIQUE,
price DECIMAL(12, 2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
category_id BIGINT NOT NULL REFERENCES categories(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_products_status_created ON products (status, created_at DESC);
CREATE INDEX idx_products_category ON products (category_id, status);
CREATE INDEX idx_products_deleted_at ON products (deleted_at);
Запросы
// internal/repository/product_repo.go
package repository
import (
"context"
"myapp/internal/models"
"gorm.io/gorm"
)
type ProductRepository struct {
db *gorm.DB
}
func NewProductRepository(db *gorm.DB) *ProductRepository {
return &ProductRepository{db: db}
}
func (r *ProductRepository) GetPublished(
ctx context.Context,
categoryID uint,
limit, offset int,
) ([]models.Product, error) {
var products []models.Product
err := r.db.WithContext(ctx).
Preload("Category").
Preload("Tags").
Where("category_id = ? AND status = ?", categoryID, models.StatusPublished).
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&products).Error
return products, err
}
func (r *ProductRepository) GetBySlug(ctx context.Context, slug string) (*models.Product, error) {
var product models.Product
err := r.db.WithContext(ctx).
Preload("Category").
Preload("Tags").
Preload("Images", func(db *gorm.DB) *gorm.DB {
return db.Order("sort_order ASC")
}).
Where("slug = ?", slug).
First(&product).Error
if err != nil {
return nil, err
}
return &product, nil
}
Preload выполняет отдельный SELECT с IN (...) — это не JOIN, но и не N+1. Для сложных join-запросов лучше использовать raw SQL через db.Raw().
Транзакции
func (r *ProductRepository) CreateWithTags(
ctx context.Context,
product *models.Product,
tagIDs []uint,
) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(product).Error; err != nil {
return err
}
var tags []models.Tag
if err := tx.Find(&tags, tagIDs).Error; err != nil {
return err
}
return tx.Model(product).Association("Tags").Append(tags)
})
}
Хуки
func (p *Product) BeforeCreate(tx *gorm.DB) error {
if p.Slug == "" {
p.Slug = slug.Make(p.Title)
}
return nil
}
func (p *Product) BeforeUpdate(tx *gorm.DB) error {
tx.Statement.SetColumn("UpdatedAt", time.Now().UTC())
return nil
}
Сроки
Начальная настройка GORM с golang-migrate для нового Go-проекта: 1 день. Включает подключение к БД, модели, репозитории, миграции и базовые тесты с testcontainers-go.







