Хотелось бы сразу сделать небольшой дисклеймер. Это не супер-экспертная статья. Скажем так - это мой инсайд о том, как еще можно работать с SQL запросами в Go проекте. В этой статье я расскажу о том как удобно хранить запросы в embeded sql файлах. Решение о котором я буду писать - лишь один из инструментов в арсенале разработчика, а не универсальное решение призванное вылечить все болезни. Надеюсь тебе это будет полезно.
При работе с базой данных в Go-приложениях я использовал несколько подходов к организации SQL-запросов:
Query Builder
Inline SQL
Каждый запрос в отдельном embeded sql файле
Группировка в файле

В этой статье опишу последний, паттерн группировки SQL-запросов. Это подход, где все запросы одной сущности хранятся в одном .sql файле с именованными секциями. Вместо того чтобы хранить каждый запрос в отдельном файле или писать SQL прямо в коде, ты:
Группируешь все запросы сущности в один файл (например,
department.sql)Помечаешь каждый запрос именем через комментарий
-- name: QueryNameЗагружаешь файл один раз при старте приложения через
go:embedПолучаешь нужный запрос по имени через getter
Реализация
Парсер SQL-файлов
package queries import ( "embed" "errors" "fmt" "regexp" "strings" ) // Директива go:embed встраивает все .sql файлы из текущей директории // в бинарник при компиляции. Файлы будут доступны через sqlFiles. //go:embed *.sql var sqlFiles embed.FS // Регулярное выражение для поиска маркеров вида "-- name: {{queryName}}" // (?m) - многострочный режим, ^ соответствует началу каждой строки var queryNameRegex = regexp.MustCompile(`(?m)^--\s*name:\s*(\w+)\s*$`) // Queries хранит распарсенные SQL-запросы в виде map[имя]запрос type Queries struct { queries map[string]string } // MustLoad загружает SQL-файл и паникует при ошибке. // Используй на уровне пакета для fail-fast при старте приложения. func MustLoad(filename string) *Queries { q, err := Load(filename) if err != nil { panic(err) } return q } // Load загружает и парсит SQL-файл, возвращая структуру с запросами. // Читает файл из embedded FS и разбивает на именованные запросы. func Load(filename string) (*Queries, error) { // Читаем содержимое файла из встроенной файловой системы content, err := sqlFiles.ReadFile(filename) if err != nil { return nil, fmt.Errorf("read sql file %s: %w", filename, err) } // Парсим содержимое, разбивая на отдельные запросы по маркерам queries, err := parse(string(content)) if err != nil { return nil, fmt.Errorf("parse sql file %s: %w", filename, err) } return &Queries{queries: queries}, nil } // Get возвращает SQL-запрос по имени. // Паникует если запрос не найден - это защита от опечаток в runtime. func (q *Queries) Get(name string) string { query, ok := q.queries[name] if !ok { panic(fmt.Sprintf("query %q not found", name)) } return query } // parse разбирает содержимое SQL-файла на отдельные именованные запросы. // Ищет маркеры "-- name: X" и извлекает текст между ними. func parse(content string) (map[string]string, error) { queries := make(map[string]string) // Находим все маркеры "-- name: X" с их позициями в тексте matches := queryNameRegex.FindAllStringSubmatchIndex(content, -1) if len(matches) == 0 { return nil, errors.New("no queries found") } // Проходим по каждому найденному маркеру for i, match := range matches { // Извлекаем имя запроса из группы захвата регулярки nameStart, nameEnd := match[2], match[3] name := content[nameStart:nameEnd] // Текст запроса начинается сразу после маркера queryStart := match[1] var queryEnd int // Запрос заканчивается там, где начинается следующий маркер // или в конце файла, если это последний запрос if i+1 < len(matches) { queryEnd = matches[i+1][0] } else { queryEnd = len(content) } // Убираем лишние пробелы и сохраняем запрос query := strings.TrimSpace(content[queryStart:queryEnd]) queries[name] = query } return queries, nil }
SQL-файл с запросами
-- name: Create INSERT INTO departments (name, description, parent_id) VALUES ($1, $2, $3) RETURNING id, created_at, updated_at; -- name: GetByID SELECT id, name, description, parent_id, created_at, updated_at FROM departments WHERE id = $1; -- name: GetAll SELECT id, name, description, parent_id, created_at, updated_at FROM departments ORDER BY name; -- name: Update UPDATE departments SET name = $2, description = $3, parent_id = $4, updated_at = NOW() WHERE id = $1 RETURNING updated_at; -- name: Delete DELETE FROM departments WHERE id = $1;
Использование в репозитории
package postgresql import ( "context" "github.com/dsbasko/team-pulse/internal/domain" "github.com/dsbasko/team-pulse/internal/repositories/postgresql/queries" "github.com/google/uuid" ) // Загружаем все запросы при инициализации пакета. // MustLoad паникует при ошибке - приложение не запустится с битым SQL. // Это происходит один раз при старте, потом запросы берутся из памяти. var departmentQueries = queries.MustLoad("department.sql") // DepartmentRepository - репозиторий для работы с департаментами. // Встраивает BaseRepository для переиспользования общей логики. type DepartmentRepository struct { *BaseRepository } // NewDepartmentRepository создает новый репозиторий. // Принимает интерфейс DB, а не конкретный тип - это позволяет // подставлять моки в тестах. func NewDepartmentRepository(db DB) *DepartmentRepository { return &DepartmentRepository{ BaseRepository: NewBaseRepository(db), } } // Create создает новый департамент в БД. // Возвращает сгенерированные поля обратно в структуру. func (r *DepartmentRepository) Create(ctx context.Context, dept *domain.Department) error { const op = "DepartmentRepository.Create" // Для контекста в ошибках // departmentQueries.Get("Create") возвращает SQL-строку из файла. // Аргументы передаются в том же порядке, что и $1, $2, $3 в запросе. err := r.db.QueryRow(ctx, departmentQueries.Get("Create"), dept.Name, dept.Description, dept.ParentID, ).Scan( &dept.ID, &dept.CreatedAt, &dept.UpdatedAt, ) if err != nil { return WrapError(op, err) } return nil } // GetByID возвращает департамент по UUID. // Возвращает ошибку если департамент не найден. func (r *DepartmentRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.Department, error) { const op = "DepartmentRepository.GetByID" // Выполняем запрос с параметром id rows, err := r.db.Query(ctx, departmentQueries.Get("GetByID"), id) if err != nil { return nil, WrapError(op, err) } // ScanOne - хелпер, который сканирует одну строку или возвращает ErrNoRows department, err := ScanOne(rows, scanDepartment) if err != nil { return nil, WrapError(op, err) } return &department, nil } // GetAll возвращает все департаменты. // Если департаментов нет - возвращает пустой слайс, не ошибку. func (r *DepartmentRepository) GetAll(ctx context.Context) ([]domain.Department, error) { const op = "DepartmentRepository.GetAll" // Запрос без параметров - получаем все записи rows, err := r.db.Query(ctx, departmentQueries.Get("GetAll")) if err != nil { return nil, WrapError(op, err) } // ScanMany - хелпер для сканирования нескольких строк в слайс return ScanMany(rows, scanDepartment) }
Структура проекта
internal/repositories/postgresql/ ├── queries/ # Директория с SQL-файлами │ ├── embed.go # Парсер: go:embed + regex + Load/Get │ ├── department.sql # Все запросы для таблицы departments │ ├── team.sql # Все запросы для таблицы teams │ ├── employee.sql # Все запросы для таблицы employees │ └── project.sql # Все запросы для таблицы projects ├── department.go # Использует departmentQueries.Get("X") ├── team.go # Использует teamQueries.Get("X") ├── employee.go # Использует employeeQueries.Get("X") └── project.go # Использует projectQueries.Get("X") # Принцип: один .sql файл = один .go репозиторий = одна таблица/сущность

Преимущества подхода
1. Чистый Go-код
Репозиторий содержит только логику работы с данными, без SQL-строк:
// ❌ SQL-запрос прямо в коде rows, err := r.db.Query(ctx, ` SELECT id, name, description, parent_id, created_at, updated_at FROM departments WHERE id = $1 `, id) // ✅ Запрос загружается из .sql файла по имени rows, err := r.db.Query(ctx, departmentQueries.Get("GetByID"), id)
2. SQL с подсветкой синтаксиса
IDE распознаёт .sql файлы и предоставляет:
Подсветку синтаксиса
Автодополнение
Проверку ошибок
Форматирование
3. Группировка по сущности
Все запросы одной сущности в одном файле - легко найти и модифицировать.
4. Компиляция в бинарник
go:embed встраивает SQL-файлы в исполняемый файл:
Нет зависимости от внешних файлов
Нет риска потерять SQL-файлы
Ошибка парсинга = ошибка компиляции
5. Fail-fast при старте
MustLoad и panic в Get гарантируют, что:
Ошибки в SQL-файлах обнаруживаются сразу при старте
Опечатки в именах запросов не дойдут до production
6. Лёгкое тестирование
Можно тестировать SQL-запросы изолированно от Go-кода.
Сравнение подходов
Query Builder (squirrel)
// Query Builder строит SQL программно через цепочку методов. // Плюс: можно динамически добавлять условия (if needFilter { .Where(...) }) // Минус: сложнее читать, нет подсветки SQL, overhead на построение query, args, _ := sq. Select("id", "name", "description", "parent_id", "created_at", "updated_at"). From("departments"). Where(sq.Eq{"id": id}). PlaceholderFormat(sq.Dollar). ToSql()
Когда использовать: Динамические запросы с условиями, фильтрация по разным полям.
Inline SQL
// Inline SQL - запрос хранится как константа прямо в Go-коде. // Плюс: всё в одном месте, не нужен парсер // Минус: нет подсветки SQL, захламляет код при большом количестве запросов const getDepartmentByID = ` SELECT id, name, description, parent_id, created_at, updated_at FROM departments WHERE id = $1 `
Когда использовать: Простые проекты, прототипы, один-два запроса.
Файл на запрос
# Каждый SQL-запрос в отдельном файле. # Плюс: изоляция, удобно для очень длинных запросов # Минус: много файлов, сложная навигация при 50+ запросах queries/ ├── department_create.sql # INSERT запрос ├── department_get_by_id.sql # SELECT по ID ├── department_get_all.sql # SELECT всех записей ├── department_update.sql # UPDATE запрос └── department_delete.sql # DELETE запрос
Когда использовать: Очень сложные запросы на 100+ строк с CTE и подзапросами.
Группировка в файле (наш подход)
# Все запросы одной сущности в одном файле с маркерами -- name: X # Плюс: баланс между организацией и простотой # Минус: нужен парсер для разделения запросов queries/ └── department.sql # Create, GetByID, GetAll, Update, Delete - всё здесь
Когда использовать: Большинство реальных проектов с типовыми CRUD-операциями.
Для себя я вывел несколько best practices, которые позволяют удобно работать с этим подходом. Перечислю то что помню:
1. Именование запросов
Используй глаголы в PascalCase:
Create,GetByID,GetAll,Update,DeleteGetByEmail,GetActiveUsers,CountByStatus
2. Один файл = одна сущность
Не спешивай запросы разных таблиц в одном файле (не или хотябы старайся).
3. Комментарии для сложных запросов
-- name: GetEmployeesWithMetrics -- Описание: Возвращает сотрудников с агрегированными метриками за период. -- Используется в: отчёты, дашборды, аналитика команды -- Параметры: -- $1 = team_id (UUID команды) -- $2 = start_date (начало периода) -- $3 = end_date (конец периода) SELECT e.id, e.name, -- Считаем уникальные коммиты сотрудника за период COUNT(DISTINCT c.id) as commit_count, -- Считаем уникальные merge request'ы за период COUNT(DISTINCT mr.id) as mr_count FROM employees e -- LEFT JOIN чтобы показать сотрудников даже без коммитов LEFT JOIN commits c ON c.author_id = e.id AND c.created_at BETWEEN $2 AND $3 -- Фильтр по периоду LEFT JOIN merge_requests mr ON mr.author_id = e.id AND mr.created_at BETWEEN $2 AND $3 WHERE e.team_id = $1 -- Фильтр по команде GROUP BY e.id, e.name; -- Группировка для агрегатных функций
4. Валидация при старте
Используйте MustLoad вместо Load для критичных запросов:
// MustLoad вызывает panic() если: // - файл не найден // - файл не содержит ни одного маркера "-- name: X" // - ошибка чтения файла // // Это гарантирует fail-fast: приложение упадёт при старте, // а не в runtime когда пользователь попытается выполнить запрос. // Лучше узнать о проблеме сразу, чем в 3 часа ночи на проде. // // Можно также добавить валидацию самого SQL при желании. var queries = queries.MustLoad("department.sql")
В общем, пора заканчивать статью! Паттерн группировки SQL-запросов - это золотая середина между полным контролем над SQL и чистотой Go-кода. Он особенно хорош для проектов с классическим CRUD и команд где разработчики пишут SQL вручную.

Философия выбора
Хороший разработчик не привязывается к одному инструменту, а понимает компромиссы каждого.
Вопросы, которые стоит задать себе:
Насколько динамичны мои запросы?
Важнее контроль над SQL или скорость разработки?
Какой уровень экспертизы в SQL у команды?
Как часто меняются требования к запросам?
Правильный ответ всегда зависит от контекста. Паттерн группировки хорошо работает для стабильных CRUD-операций, но для сложной бизнес-логики с динамическими фильтрами может потребоваться комбинация подходов.
Не бойся использовать разные инструменты в одном проекте:
Embedded SQL для стандартных операций
Query Builder для динамических отчётов
Raw SQL для критичных по производительности запросов
Главное - осознанный выбор, а не слепое следование шаблонам.
Спасибо за уделенное время, надеюсь материал был полезен :-)
Кстати, веду небольшой дневник в телеге, вдруг кому интересно...
