22. Repository pattern

В проектировании программного обеспечения существует хроническая болезнь, которую Джоэл Спольски метко назвал «архитектурным астронавтизмом». Инженеры создают десятки слоев абстракций, фабрик, адаптеров и оберток вокруг оберток, забывая, какую практическую задачу они решают. В объектно-ориентированных экосистемах паттерн Repository нередко вырождается в бессмысленную механическую прослойку, где на каждую таблицу базы данных создается класс из пяти CRUD-методов, дублирующих вызовы ORM.

В языке Go паттерн Repository обретает свой первоначальный, чистый инженерный смысл. Это не ритуальная папка internal/repository и не набор шаблонного кода. Это архитектурный шов (architectural seam), изолирующий ядро бизнес-логики от деталей внешнего мира: сетевых сокетов, диалектов SQL, протоколов СУБД и библиотек маппинга. Репозиторий представляет собой иллюзию того, что все доменные сущности приложения уже загружены в оперативную память в виде упорядоченной коллекции, а сервис просто обращается к ней.

Философия паттерна в Go

Repository pattern в Go — это не просто папка internal/repository с CRUD-методами. Это архитектурный контракт, который изолирует бизнес-логику от деталей хранения данных. В отличие от Active Record, где модель сама знает о БД, или DAO (Data Access Object), который часто привязан к конкретной таблице, Repository абстрагирует коллекцию доменных объектов.

Главное правило Go-архитектуры: интерфейсы определяются потребителем, а не создателем. Сервисный слой диктует, какие методы ему нужны для работы, а репозиторий реализует этот контракт, скрывая SQL, драйверы и маппинг строк.

Интерфейсы определяются потребителем

Вместо универсального Save() или Find(), методы отражают предметную область:

Go
package service

import (
    "context"
)

// UserRepository — контракт, определенный сервисом.
// Только те методы, которые реально нужны бизнес-логике.
type UserRepository interface {
    FindByID(ctx context.Context, id int64) (*User, error)
    FindActiveByEmail(ctx context.Context, email string) (*User, error)
    Create(ctx context.Context, u *User) error
    UpdateStatus(ctx context.Context, id int64, status UserStatus) error
}

Это предотвращает раздувание интерфейса и упрощает тестирование. Моки реализуют только нужные методы.

Под капотом. Механика интерфейсов и память

Когда вы передаете указатель на структуру в функцию, принимающую интерфейс, Go выполняет неявное преобразование. В памяти создается двухсловная структура iface:

  1. itab (Interface Table): указатель на тип интерфейса, тип конкретной реализации и vtable — массив указателей на методы.
  2. data: указатель на данные (ваш *pgUserRepo).
flowchart TD
    A["Вызов repo.FindByID()"] --> B["Разыменование iface.data"]
    B --> C["Чтение vtable из itab"]
    C --> D["Косвенный вызов CALL [rax]"]
    D --> E["Переход к реализации<br>pgUserRepo.FindByID"]
    E --> F["Выполнение машинного кода<br>без рефлексии"]

    classDef normal fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
    classDef asm fill:#0f172a,stroke:#10b981,color:#f8fafc;
    class A,B,C,E normal;
    class D,F asm;
  

Идиоматичная реализация

Go
package repository

import (
    "context"
    "database/sql"
    "errors"
    "fmt"
)

type pgUserRepo struct {
    db *sql.DB
}

func NewUserRepo(db *sql.DB) *pgUserRepo {
    return &pgUserRepo{db: db}
}

func (r *pgUserRepo) FindByID(ctx context.Context, id int64) (*User, error) {
    row := r.db.QueryRowContext(ctx, 
        "SELECT id, email, status FROM users WHERE id = $1", id,
    )

    var u User
    if err := row.Scan(&u.ID, &u.Email, &u.Status); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
        }
        return nil, fmt.Errorf("scan user %d: %w", id, err)
    }
    return &u, nil
}

func (r *pgUserRepo) Create(ctx context.Context, u *User) error {
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO users (email, status) VALUES ($1, $2)",
        u.Email, u.Status,
    )
    return err
}

Обратите внимание: интерфейс UserRepository не знает о sql.DB, pgxpool или ORM. Реализация может быть заменена на in-memory mock для тестов или на ClickHouse для аналитики без изменения сервисного слоя.

Транзакции и Unit of Work

Репозиторий не должен управлять транзакциями самостоятельно. Это задача сервисного слоя. Правильный паттерн в Go — явная передача транзакции или использование callback-конструкции.

Go
// Плохо: репозиторий сам начинает/коммитит транзакцию
func (r *pgUserRepo) CreateUserWithProfile(ctx context.Context, u *User, p *Profile) error {
    tx, _ := r.db.BeginTx(ctx, nil) // Утекающая абстракция, сложное тестирование
    // ...
    return nil
}

// Хорошо: сервис управляет транзакцией, репозиторий работает с executor
type DBExecutor interface {
    ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
    QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

func (r *pgUserRepo) Create(ctx context.Context, u *User, exec DBExecutor) error {
    _, err := exec.ExecContext(ctx, "INSERT INTO users (email, status) VALUES ($1, $2)", u.Email, u.Status)
    return err
}

Такой подход позволяет переиспользовать один репозиторий как в *sql.DB, так и в *sql.Tx, сохраняя чистоту контракта.

Ловушки и антипаттерны

Производительность и Mechanical Sympathy

Работа репозитория на hot-path создает несколько точек давления:

  • Аллокации при Scan: row.Scan(&u.ID) требует указателей. Компилятор размещает их в стеке, но если Scan передается в функцию, принимающую []any, слайс уходит в кучу. pgx/v5 решает это через прямую запись в поля структуры, минуя []any.
  • Кэш-локальность: Структура репозитория должна быть компактной. Размещайте часто используемые поля (например, *sql.DB или preparedStmt) в начале. Это улучшает предвыборку L1 кэша при обходе.
  • Подготовленные запросы: stmt, _ := db.PrepareContext() кэширует план выполнения на стороне БД. В Go используйте pgx.Prepared или sql.Conn.Prepare. Однако Prepare создает объект в памяти, который нужно закрывать. При высоких RPS лучше использовать query cache драйвера или пул соединений с prepared statements.

Итог

  1. Интерфейсы репозитория определяются на стороне потребителя (сервиса), а не в пакете инфраструктуры.
  2. Не используйте универсальные Generic Repository для бизнес-логики. Явные методы (FindByEmail, Deactivate) читаемее и безопаснее.
  3. Управляйте транзакциями на уровне сервиса, передавая DBExecutor или *sql.Tx в методы репозитория.
  4. Никогда не возвращайте database/sql типы наружу. Конвертируйте в доменные ошибки и структуры.
  5. pgx/v5 предпочтительнее database/sql для PostgreSQL: меньше аллокаций при Scan, поддержка pipeline mode и бинарных протоколов.
  6. Repository — это контракт, а не ORM. Он должен быть тестируемым, заменяемым и свободным от SQL-магии в сигнатурах.

Следующая статья: 23. Транзакции