Привет, Хабр!

Когда начинаешь использовать 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 % шаблонного кода, который не несёт самостоятельной ценности: это просто прокси‑методы, которые вызывают другие методы.

Что я рекомендую делать на практике:

  1. Используйте Querier напрямую, но внедряйте его через маленькие локальные интерфейсы (только нужные методы).

  2. Конвертацию pgtype.* в доменные типы делайте в use case сразу после получения данных.

  3. Если одно и то же преобразование встречается в нескольких местах — вынесите его в простые хелперы (toDomain… / fromDomain…).

  4. Для инфраструктурных деталей (пути в MinIO, типы драйвера и т. п.) храните в БД только логический идентификатор, а маппинг на конкретные пути или типы реализуйте в инфраструктурном слое. Это согласуется с DDD и упрощает будущие миграции.

Отдельный ручной слой имеет смысл только в специфических случаях: библиотеки, фреймворки, жёсткие стандарты. В обычном боевом сервисе такой подход чаще всего оказывается избыточным.

Будьте прагматичными: используйте sqlc, чтобы упростить работу, а не чтобы плодить новые слои абстракций там, где они не нужны.

Комментарии (0)