Привет, Хабр!
Когда начинаешь использовать sqlc в Go‑проекте, почти сразу упираешься в один и тот же вопрос: стоит ли оборачивать сгенерированный Querier в отдельный слой репозиториев, чтобы «по всем правилам» соблюсти чистую архитектуру, или проще и честнее использовать его прямо в use case, держа курс на KISS?
Я не буду продавать «единственно верный» подход. В Go всё очень зависит от контекста: размера команды, требований к изоляции домена, планов на масштабирование и даже от того, насколько строго в вашей компании трактуют DDD. Ниже — разбор с реальными примерами кода, которые можно брать и адаптировать.
Проблема: «жирный» интерфейс и ISP
sqlc генерирует один большой интерфейс Queries со всеми методами всех запросов. Если вашему use case нужен только GetExperimentByID, он всё равно зависит от интерфейса, где есть ещё и CreateMeasurement, и DeleteRawFile. Это прямое нарушение ISP (Interface Segregation Principle).
Но в Go эту проблему можно решить изящно — через локальные интерфейсы на стороне потребителя.
Пример: локальный интерфейс вместо полного Querier
// Определяем только то, что реально нужно этому сервису type experimentGetter interface { GetExperimentByID(ctx context.Context, id uuid.UUID) (db.ExperimentRow, error) } type ExperimentService struct { expGetter experimentGetter } func NewExperimentService(expGetter experimentGetter) *ExperimentService { return &ExperimentService{expGetter: expGetter} } func (s *ExperimentService) GetExperiment(ctx context.Context, id uuid.UUID) (*domain.Experiment, error) { row, err := s.expGetter.GetExperimentByID(ctx, id) if err != nil { return nil, err } return toDomainExperiment(row), nil }
А при старте приложения передаём туда сгенерированный db.NewQueries(db):
q := db.NewQueries(pgxpool) svc := NewExperimentService(q) // q реализует experimentGetter, потому что у него есть GetExperimentByID
Почему это важно: сервис зависит только от тех методов, которые реально использует. Никакого лишнего кода и при этом никакой потери гибкости.
DDD: где граница между доменом и инфраструктурой
В строгом DDD доменная модель не должна знать про инфраструктурные типы вроде pgtype.Timestamp или pgtype.UUID. Точно так же, как пути к объектам в MinIO относятся к инфраструктурному слою, а в БД достаточно хранить логический идентификатор объекта.
Пример маппинга в use case (прагматичный подход)
func toDomainExperiment(r db.ExperimentRow) *domain.Experiment { return &domain.Experiment{ ID: r.ID, Title: r.Title, ZenithAngle: r.ZenithAngle, StartTime: r.ExperimentStart, // pgtype.Timestamptz -> time.Time EndTime: r.ExperimentEnd, // Логический ID файла, без путей MinIO RawDataRef: r.RawDataLogicalID, } }
Здесь мы не прячем маппинг в отдельный «репозиторий ради репозитория», но и не позволяем инфраструктурным типам «просачиваться» в домен. Это компромисс, который в реальных Go‑проектах работает лучше всего.
Когда ручной репозиторий действительно нужен
Ручной слой имеет смысл, если:
вы делаете библиотеку/фреймворк, где домен не должен зависеть от БД;
у вас есть жёсткие корпоративные стандарты по архитектуре;
требуется сложная логика поверх запросов (кеширование, повторные попытки, трассировка, сложная валидация).
Пример: ручной репозиторий с дополнительной логикой
type ExperimentRepository interface { GetByID(ctx context.Context, id uuid.UUID) (*domain.Experiment, error) } type pgExperimentRepository struct { q db.Queries } func NewPGExperimentRepository(q db.Queries) *pgExperimentRepository { return &pgExperimentRepository{q: q} } func (r *pgExperimentRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.Experiment, error) { row, err := r.q.GetExperimentByID(ctx, id) if err != nil { return nil, err } // Здесь можно добавить логику: кеширование, логирование, retry, метрики return toDomainExperiment(row), nil }
Теперь сервис зависит от ExperimentRepository, а не от Queries. Это даёт больше контроля, но и больше кода, который нужно поддерживать.
Тестирование: оба подхода легко мокаются
И Queries, и ручной репозиторий — это интерфейсы, поэтому тестирование одинаковое.
Пример теста с моком Querier
type mockExpGetter struct { fn func(context.Context, uuid.UUID) (db.ExperimentRow, error) } func (m mockExpGetter) GetExperimentByID(ctx context.Context, id uuid.UUID) (db.ExperimentRow, error) { return m.fn(ctx, id) } func TestGetExperiment(t *testing.T) { id := uuid.New() mock := mockExpGetter{ fn: func(ctx context.Context, i uuid.UUID) (db.ExperimentRow, error) { return db.ExperimentRow{ ID: i, Title: "Test experiment", ZenithAngle: 45.0, ExperimentStart: pgtype.Timestamptz{Time: time.Now(), Valid: true}, ExperimentEnd: pgtype.Timestamptz{Time: time.Now().Add(time.Hour), Valid: true}, RawDataLogicalID: "log-id-123", }, nil }, } svc := NewExperimentService(mock) exp, err := svc.GetExperiment(context.Background(), id) require.NoError(t, err) require.NotNil(t, exp) require.Equal(t, "Test experiment", exp.Title) }
Если бы мы использовали ручной репозиторий, тест выглядел бы почти так же — только мок был бы для ExperimentRepository.
Скорость изменений: цена абстракций
Это самый практичный критерий.
С
Querier: изменили миграцию →sqlc generate→ готово.С ручным репозиторием: изменили миграцию →
sqlc generate→ обновили интерфейс репозитория → обновили реализацию → обновили тесты.
Цепная реакция изменений реально замедляет разработку. В небольших командах и микросервисах это может быть решающим фактором.
Итоговая таблица
Критерий |
|
Ручной репозиторий |
|---|---|---|
KISS (простота) |
+ |
− (больше кода) |
DRY (отсутствие дублирования) |
+ (sqlc генерирует сам) |
− (дублируются сигнатуры) |
SOLID (ISP) |
± (решается локальными интерфейсами) |
+ (явный контракт) |
Go‑идиоматичность |
+ (меньше абстракций «на вырост») |
− (риск over‑engineering) |
DDD (изоляция домена) |
± (маппинг в use case/хелперах) |
+ (полная изоляция) |
Тестирование |
+ (оба легко мокаются) |
+ |
Скорость разработки |
+ (максимальная) |
− (замедляет) |
Мой практический вывод
Для большинства Go‑проектов (особенно микросервисов с одной БД и небольшой командой) отдельный ручной слой репозиториев не оправдан. Он добавляет примерно 30 % шаблонного кода, который не несёт самостоятельной ценности: это просто прокси‑методы, которые вызывают другие методы.
Что я рекомендую делать на практике:
Используйте
Querierнапрямую, но внедряйте его через маленькие локальные интерфейсы (только нужные методы).Конвертацию
pgtype.*в доменные типы делайте в use case сразу после получения данных.Если одно и то же преобразование встречается в нескольких местах — вынесите его в простые хелперы (
toDomain…/fromDomain…).Для инфраструктурных деталей (пути в MinIO, типы драйвера и т. п.) храните в БД только логический идентификатор, а маппинг на конкретные пути или типы реализуйте в инфраструктурном слое. Это согласуется с DDD и упрощает будущие миграции.
Отдельный ручной слой имеет смысл только в специфических случаях: библиотеки, фреймворки, жёсткие стандарты. В обычном боевом сервисе такой подход чаще всего оказывается избыточным.
Будьте прагматичными: используйте sqlc, чтобы упростить работу, а не чтобы плодить новые слои абстракций там, где они не нужны.