← Назад к новостям
sqlc в Go: нужен ли ручной слой репозиториев? Прагматичный разбор с примерами кода
📰 Habr
📅 07.07.2026 04:18
⏱️ 7 мин
Привет, Хабр! Когда начинаешь использовать 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 → обновили интерфейс репозитория → обновили реализацию → обновили тесты. Цепная реакция изменений реально замедляет разработку. В небольших командах и микросервисах это может быть решающим фактором. Итоговая таблица Критерий Querier + локальные интерфейсы Ручной репозиторий KISS (простота) + − (больше кода) DRY (отсутствие дублирования) + (sqlc генерирует сам) − (дублируются сигнатуры) SOLID (ISP) ± (решается локальными интерфейсами) + (явный контракт) Go‑идиоматичность + (меньше абстракций «на вырост») − (риск over‑engineering) DDD (изоляция домена) ± (маппинг в use case/хелперах) + (полная изоляция) Тестирование + (оба легко мокаются) + Скорость разработки + (максимальная) − (замедляет) Мой практический вывод Для большинства Go‑проектов (особенно микросервисов с одной БД и небольшой командой) отдельный ручной слой репозиториев не оправдан. Он добавляет примерно 30 % шаблонного кода, который не несёт самостоятельной ценности: это просто прокси‑методы, которые вызывают другие методы. Что я рекомендую делать на практике: Используйте Querier напрямую, но внедряйте его через маленькие локальные интерфейсы (только нужные методы). Конвертацию pgtype.* в доменные типы делайте в use case сразу после получения данных. Если одно и то же преобразование встречается в нескольких местах — вынесите его в простые хелперы ( toDomain… / fromDomain… ). Для инфраструктурных деталей (пути в MinIO, типы драйвера и т. п.) храните в БД только логический идентификатор, а маппинг на конкретные пути или типы реализуйте в инфраструктурном слое. Это согласуется с DDD и упрощает будущие миграции. Отдельный ручной слой имеет смысл только в специфических случаях: библиотеки, фреймворки, жёсткие стандарты. В обычном боевом сервисе такой подход чаще всего оказывается избыточным. Будьте прагматичными: используйте sqlc , чтобы упростить работу, а не чтобы плодить новые слои абстракций там, где они не нужны.
#Tor
#Domain
#Домен
#Sql
#Бот
#Ai
#Логи
📊 Источник:
Habr
|
Оригинал