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(), методы отражают предметную область:
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:
itab(Interface Table): указатель на тип интерфейса, тип конкретной реализации и vtable — массив указателей на методы.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;
Идиоматичная реализация
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-конструкции.
// Плохо: репозиторий сам начинает/коммитит транзакцию
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.
Итог
- Интерфейсы репозитория определяются на стороне потребителя (сервиса), а не в пакете инфраструктуры.
- Не используйте универсальные Generic Repository для бизнес-логики. Явные методы (
FindByEmail,Deactivate) читаемее и безопаснее. - Управляйте транзакциями на уровне сервиса, передавая
DBExecutorили*sql.Txв методы репозитория. - Никогда не возвращайте
database/sqlтипы наружу. Конвертируйте в доменные ошибки и структуры. pgx/v5предпочтительнееdatabase/sqlдля PostgreSQL: меньше аллокаций приScan, поддержка pipeline mode и бинарных протоколов.- Repository — это контракт, а не ORM. Он должен быть тестируемым, заменяемым и свободным от SQL-магии в сигнатурах.
Следующая статья: 23. Транзакции