Паттерн CRUD-репозитория с generics в Go для чистого слоя данных
Изучите практичный паттерн CRUD-репозитория на Go с generics: как переиспользовать логику list/get/create/update/delete с понятными ограничениями, без рефлексии и с ясным кодом.

Почему CRUD-репозитории в Go начинают путаться
CRUD-репозитории начинаются просто. Сначала вы пишете GetUser, затем ListUsers, потом то же самое для Orders, затем Invoices. Через несколько сущностей слой данных превращается в груду почти-копий, где мелкие отличия легко пропустить.
Чаще всего повторяется не сам SQL, а обвязка: выполнение запроса, сканирование строк, обработка «не найдено», перевод ошибок БД, применение дефолтов пагинации и приведение входных данных к нужным типам.
Типичные больные места знакомы: дублирующийся код Scan, повторяющиеся шаблоны с context.Context и транзакциями, шаблонное LIMIT/OFFSET (иногда с подсчётом общего количества), одинаковая проверка «0 строк = не найдено» и скопированный INSERT ... RETURNING id с вариациями.
Когда дублирование начинает мешать, многие команды тянутся к рефлексии. Она обещает «написать один раз»: взять любой struct и заполнить его колонками во время выполнения. Цена проявляется позже. Код с обилием рефлексии хуже читается, поддержка в IDE ухудшается, а ошибки смещаются из времени компиляции в рантайм. Маленькие изменения, как переименование поля или добавление nullable-колонки, становятся сюрпризами, которые всплывают лишь в тестах или проде.
Типобезопасное переиспользование значит делиться потоком CRUD, не отказываясь от повседневных удобств Go: ясных сигнатур, проверок компилятором и автодополнения, которое действительно помогает. С generics вы можете переиспользовать операции вроде Get[T] и List[T], при этом требуя от каждой сущности того, что нельзя угадать, например функцию сканирования строки в T.
Этот паттерн сознательно про уровень доступа к данным. Он держит SQL и маппинг последовательными и скучными. Он не пытается моделировать домен, навязывать бизнес-правила или заменять логику на уровне сервисов.
Цели дизайна (и что это не будет решать)
Хороший паттерн репозитория делает повседневный доступ к БД предсказуемым. Должно быть легко прочитать репозиторий и быстро понять, что он делает, какой SQL выполняет и какие ошибки может вернуть.
Цели простые:
- Типобезопасность end-to-end (ID, сущности и результаты — не
any) - Ограничения, которые объясняют намерение без сложных трюков с типами
- Меньше шаблонного кода, не скрывая важного поведения
- Последовательное поведение для List/Get/Create/Update/Delete
Не-цели также важны. Это не ORM. Он не должен догадываться с маппингом полей, автоматически джойнить таблицы или тихо менять запросы. «Магический маппинг» возвращает вас к рефлексии, тегам и крайним случаям.
Предположим обычный SQL-флоу: явный SQL (или тонкий билдер запросов), чёткие границы транзакций и ошибки, по которым можно рассуждать. Когда что-то падает, ошибка должна говорить вам «не найдено», «конфликт/нарушение уникальности» или «БД недоступна», а не неопределённое «ошибка репозитория».
Ключевое решение — что сделать generic, а что оставить специфичным для сущности.
- Generic: сам поток (запустить запрос, скан, вернуть типизированные значения, перевод общих ошибок).
- Для сущности: смысл (имена таблиц, выбранные колонки, джоины и SQL-строки).
Пытаться запихнуть все сущности в единую универсальную систему фильтров обычно усложняет код больше, чем написание двух ясных запросов.
Выбор ограничений для сущности и ID
Большая часть CRUD-кода повторяется, потому что у каждой таблицы похожие действия, но у каждой сущности свои поля. С generics фокус — разделить небольшой общий интерфейс и оставить остальное свободным.
Начните с решения, что репозиторий действительно должен знать о сущности. Для многих команд единственное универсальное — это ID. Таймстемпы могут быть полезны, но они не универсальны, и насильное включение их во все типы часто делает модель искусственной.
Выберите удобный тип ID
Тип ID должен соответствовать тому, как вы идентифицируете строки в базе. В проектах используют int64 или UUID-строки. Если хотите единый подход, сделайте ID параметризуемым. Если в кодовой базе уже один стиль ID, фиксирование типа упростит сигнатуры.
Хороший дефолт — ограничение comparable, потому что вы будете сравнивать ID, использовать их как ключи в map и передавать по коду.
type ID interface {
comparable
}
type Entity[IDT ID] interface {
GetID() IDT
SetID(IDT)
}
Держите ограничения для сущности минимальными
Избегайте требовать поля через встраивание struct или хитрые type-set приёмы вроде ~struct{...}. Они выглядят мощно, но связывают доменные типы с паттерном репозитория.
Вместо этого требуйте только то, что нужно общему CRUD-потоку:
- Получить и задать ID (чтобы Create мог вернуть его, а Update/Delete — нацелиться по нему)
Если позже добавите soft deletes или оптимистичную блокировку, добавьте небольшие интерфейсы по выбору (например, GetVersion/SetVersion) и используйте их только там, где нужно. Маленькие интерфейсы обычно живут долго.
Читабельный generic-интерфейс репозитория
Интерфейс репозитория должен описывать то, что нужно приложению, а не то, что делает база данных. Если интерфейс звучит как SQL, он протекает во всё приложение.
Держите набор методов маленьким и предсказуемым. Ставьте context.Context первым, затем основной вход (ID или данные), затем опции в виде структуры.
type Repository[T any, ID comparable, CreateIn any, UpdateIn any, ListQ any] interface {
Get(ctx context.Context, id ID) (T, error)
List(ctx context.Context, q ListQ) ([]T, error)
Create(ctx context.Context, in CreateIn) (T, error)
Update(ctx context.Context, id ID, in UpdateIn) (T, error)
Delete(ctx context.Context, id ID) error
}
Для List избегайте навязывать универсальный тип фильтра. Фильтры — это то место, где сущности отличаются больше всего. Практичный подход — типы запросов на сущность плюс небольшая общая структура пагинации, которую можно встраивать.
type Page struct {
Limit int
Offset int
}
Обработка ошибок — место, где репозитории часто шумят. Решите заранее, по каким ошибкам вызывающие будут ветвить. Набора обычно достаточно:
ErrNotFoundпри отсутствии IDErrConflictдля нарушений уникальности или конфликтов по версииErrValidationкогда вход некорректен (только если репозиторий валидирует)
Всё остальное можно возвращать как обёрнутую низкоуровневую ошибку (БД/сеть). С таким контрактом код сервисов умеет реагировать на «не найдено» или «конфликт», не заботясь о том, PostgreSQL у вас сегодня или что-то другое завтра.
Как избежать рефлексии, но всё же переиспользовать поток
Рефлексия обычно просачивается, когда вы хотите один кусок кода, который «заполнит любой struct». Это скрывает ошибки до рантайма и делает правила неявными.
Чище — переиспользовать только скучные части: выполнить запрос, пройтись по строкам, проверить количество затронутых строк и единообразно оборачивать ошибки. Держите маппинг в обе стороны явным.
Разделите ответственность: SQL, маппинг, общий поток
Практичная разделённость выглядит так:
- Для каждой сущности: храните SQL-строки и порядок параметров в одном месте
- Для каждой сущности: напишите маленькие функции-мэпперы, которые сканят строки в конкретный struct
- Generic: предоставьте общий поток, который выполняет запрос и вызывает маппер
Так generics сокращают повторение, не скрывая, что делает база.
Вот маленькая абстракция, которая позволяет передавать либо *sql.DB, либо *sql.Tx без остального кода заботиться:
type DBTX interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
Что generics должны (и не должны) делать
Generic-слой не должен пытаться «понять» ваш struct. Вместо этого он должен принимать явные функции, которые вы даёте, например:
- биндер, который превращает вход в аргументы запроса
- сканер, который читает колонки в сущность
Например, репозиторий Customer может держать SQL как константы (selectByID, insert, update) и реализовать scanCustomer(rows) один раз. Generic List обрабатывает цикл, контекст и обёртку ошибок, а scanCustomer сохраняет маппинг типобезопасным и очевидным.
Если вы добавляете колонку, вы правите SQL и сканер. Компилятор подскажет, что сломалось.
Шаг за шагом: реализация паттерна
Цель — единый переиспользуемый поток для List/Get/Create/Update/Delete, при этом каждая репозитория остаётся честной насчёт своего SQL и маппинга.
1) Определите базовые типы
Начните с наименьших ограничений. Выберите ID, который подходит для вашей кодовой базы, и интерфейс репозитория, который остаётся предсказуемым.
type ID interface{ ~int64 | ~string }
type Repo[E any, K ID] interface {
Get(ctx context.Context, id K) (E, error)
List(ctx context.Context, limit, offset int) ([]E, error)
Create(ctx context.Context, e *E) error
Update(ctx context.Context, e *E) error
Delete(ctx context.Context, id K) error
}
2) Добавьте исполнитель для БД и транзакций
Не привязывайте generic-код напрямую к *sql.DB или *sql.Tx. Зависите от небольшого интерфейса-исполнителя, который соответствует вызываемым методам. Тогда сервисы смогут передавать DB или транзакцию без изменения кода репозитория.
3) Постройте generic-основание с общим потоком
Создайте baseRepo[E,K], который хранит исполнителя и несколько полей-функций. Базовый уровень обрабатывает скучные части: вызов запроса, маппинг «не найдено», проверку affected rows и единообразное возвращение ошибок.
4) Реализуйте специфичное для сущности
Каждый репозиторий сущности предоставляет то, что не может быть generic:
- SQL для list/get/create/update/delete
- функцию
scan(row), конвертирующую строку вE bind(...), возвращающую аргументы запроса
5) Подключите конкретные репозитории и используйте их из сервисов
Постройте NewCustomerRepo(exec Executor) *CustomerRepo, который встраивает или оборачивает baseRepo. Слой сервисов зависит от интерфейса Repo[E,K] и решает, когда начать транзакцию; репозиторий просто использует переданный ему исполнитель.
Обрабатывать List/Get/Create/Update/Delete без сюрпризов
Generic-репозиторий помогает только если каждый метод ведёт себя одинаково везде. Боль чаще всего от мелких несовпадений: один репозиторий сортирует по created_at, другой — по id; один возвращает nil, nil для отсутствующих строк, другой — ошибку.
List: пагинация и упорядочение, которые не меняются
Выберите стиль пагинации и применяйте его последовательно. Offset-пагинация (limit/offset) проста и работает для админских экранов. Cursor-пагинация лучше для «вечного скролла», но требует стабильного ключа сортировки.
Что бы вы ни выбрали, делайте сортировку явной и стабильной. Сортировка по уникальной колонке (часто первичному ключу) предотвращает «скачки» элементов между страницами при добавлении новых строк.
Get: чёткий сигнал «не найдено»
Get(ctx, id) должен возвращать типизированную сущность и явный сигнал отсутствия записи — обычно общий ошибочный сэнтинел вроде ErrNotFound. Избегайте возвращать нулевую сущность с nil-ошибкой. Тогда вызывающие не смогут отличить «отсутствие записи» от «пустых полей».
Привыкайте: тип — для данных, ошибка — для состояния.
Перед реализацией методов примите несколько решений и соблюдайте их во всех репозиториях:
Create: принимаете входной тип (без ID и таймстемпов) или полный entity? Многие предпочитаютCreate(ctx, in CreateX), чтобы предотвратить установку серверных полей вызывающими.Update: полная замена или патч? Для патча не используйте простые struct, где нулевые значения неоднозначны. Используйте указатели, nullable-типы или явную маску полей.Delete: жёсткое удаление или soft-delete? Если soft-delete, решите, скрывает лиGetудалённые строки по умолчанию.
Также решите, что возвращают методы записи. Низко-удивительные варианты: возвращать обновлённую сущность (после дефолтов БД) или возвращать только ID плюс ErrNotFound при отсутствии изменений.
Стратегия тестирования для generic и специфичных частей
Подход окупается, если ему можно доверять. Разделяйте тесты так же, как код: протестируйте общие помощники один раз, затем отдельно тестируйте SQL и сканирование каждой сущности.
Старайтесь делать общие части маленькими чистыми функциями: нормализация пагинации, маппинг ключей сортировки в разрешённые колонки или сборка фрагментов WHERE. Их удобно покрывать быстрыми юнит-тестами.
Для list-запросов хорошо подходят table-driven тесты: проверяйте пустые фильтры, неизвестные ключи сортировки, limit 0, превышение лимита, отрицательный offset и границы «следующей страницы», когда берёте один лишний ряд.
Тесты на сущность фокусируйте на том, что действительно специфично: ожидаемый SQL и как строки сканируются в тип. Используйте mock SQL или лёгкую тестовую БД и убедитесь, что логика сканирования обрабатывает null, опциональные колонки и конверсии типов.
Если паттерн поддерживает транзакции, тестируйте commit/rollback с простым фейковым исполнителем, который фиксирует вызовы и имитирует ошибки:
- Begin возвращает tx-исполнитель
- при ошибке вызывается ровно один rollback
- при успехе вызывается ровно один commit
- если commit падает, ошибка возвращается без изменений
Можно добавить небольшие «контрактные» тесты, которые должен проходить каждый репозиторий: create→get возвращает одинаковые данные, update меняет нужные поля, delete делает get возвращающим ErrNotFound, list возвращает стабильный порядок для одинаковых входных параметров.
Частые ошибки и ловушки
Generics соблазняют сделать один репозиторий для всех сущностей. Доступ к данным полон мелких различий — и эти различия важны.
Распространённые ловушки:
- Перегeneralизировать, пока у каждого метода не появится огромный мешок опций (джоины, поиск, права, soft delete, кэш). В итоге вы создаёте второй ORM.
- Слишком хитрые ограничения типов. Если читателю нужно разбирать type sets, чтобы понять, что должна реализовывать сущность, абстракция стоит дороже, чем польза.
- Использовать входные типы как модель БД. Когда Create и Update принимают тот же struct, который вы сканируете из строк, детали БД протекают в хендлеры и тесты, а изменения схемы расползаются по всему приложению.
- Немая логика в
List: нестабильная сортировка, непоследовательные дефолты или разные правила пагинации по сущностям. - Обработка «не найдено», которая вынуждает парсить строки ошибок вместо
errors.Is.
Конкретный пример: ListCustomers возвращает клиентов в разном порядке каждый раз, потому что репозиторий не задаёт ORDER BY. Пагинация тогда дублирует или пропускает записи между запросами. Сделайте сортировку явной (хотя бы по первичному ключу) и протестируйте её.
Быстрый чек-лист перед внедрением
Прежде чем внедрять generic-репозиторий повсеместно, убедитесь, что он уберёт повторение, не скрыв важное поведение БД.
Начните с консистентности. Если один репозиторий принимает context.Context, а другой — нет, или один возвращает (T, error), а другой — (*T, error), проблемы проявятся везде: сервисы, тесты и моки.
Убедитесь, что у каждой сущности есть одно очевидное место для её SQL. Generics должны переиспользовать поток (scan, validate, map errors), а не разбрасывать запросы по строковым фрагментам.
Набор проверок, предупреждающих большинство сюрпризов:
- Одна сигнатура для List/Get/Create/Update/Delete
- Одинаковое правило для not-found во всех репозиториях
- Стабильная сортировка list, документированная и протестированная
- Чистый способ запускать один и тот же код на
*sql.DBи*sql.Tx(через интерфейс исполнителя) - Ясная граница между generic-кодом и правилами сущности (валидация и бизнес-проверки остаются вне generic-слоя)
Если вы быстро собираете внутренние инструменты в AppMaster и позже экспортируете или расширяете сгенерированный Go-код, эти проверки помогают держать слой данных предсказуемым и удобным для тестирования.
Реалистичный пример: репозиторий Customer
Ниже — небольшая форма репозитория Customer, которая остаётся типобезопасной без излишней хитрости.
Начните с модели, которую храните. Держите ID строго типизированным, чтобы случайно не перепутать его с другими ID:
type CustomerID int64
type Customer struct {
ID CustomerID
Name string
Status string // "active", "blocked", "trial"...
}
Разделите то, что API принимает, и то, что вы храните. Здесь Create и Update должны отличаться.
type CreateCustomerInput struct {
Name string
Status string
}
type UpdateCustomerInput struct {
Name *string
Status *string
}
Generic-основа может обрабатывать общий поток (выполнение SQL, скан, маппинг ошибок), а Customer-repo владеет SQL и маппингом. Для сервиса интерфейс остаётся чистым:
type CustomerRepo interface {
Create(ctx context.Context, in CreateCustomerInput) (Customer, error)
Update(ctx context.Context, id CustomerID, in UpdateCustomerInput) (Customer, error)
Get(ctx context.Context, id CustomerID) (Customer, error)
Delete(ctx context.Context, id CustomerID) error
List(ctx context.Context, q CustomerListQuery) ([]Customer, int, error)
}
Для List делайте фильтры и пагинацию полноценным объектом запроса. Это делает вызовы читаемыми и помогает не забыть лимиты.
type CustomerListQuery struct {
Status *string // фильтр
Search *string // имя содержит
Limit int
Offset int
}
Отсюда паттерн масштабируется: копируете структуру для следующей сущности, держите входы отделёнными от хранимых моделей и делаете сканирование явным, чтобы изменения оставались очевидными и проверяемыми компилятором.
Вопросы и ответы
Используйте generics, чтобы переиспользовать сам поток операций (выполнение запроса, цикл сканирования, обработка «не найдено», дефолты пагинации, сопоставление ошибок), но держите SQL и маппинг строк явными для каждой сущности. Это сокращает дублирование, не превращая слой данных в рантайм-«магии», которая тихо ломается.
Рефлексия скрывает правила маппинга и переносит ошибки в рантайм. Потеря проверок компилятора и автодополнения в IDE делает мелкие изменения (переименование поля или добавление nullable-колонки) сюрпризом в тестах или проде. С generics и явными сканерами вы сохраняете типобезопасность и читабельность.
Хороший дефолт — comparable, потому что идентификаторы сравнивают, используют как ключи в map и передают повсюду. Если в системе используются разные стили ID (например, int64 и UUID-строки), параметризованный ID позволяет не навязывать единственный подход.
Минимум: то, что нужен общему CRUD-потоку, например GetID() и SetID(). Не заставляйте сущности встраивать поля или применять хитрые type-set трюки — это связывает доменные типы с паттерном репозитория и усложняет рефакторинг.
Определите небольшой интерфейс-исполнитель (обычно DBTX) с методами QueryContext, QueryRowContext, ExecContext. Тогда репозиторий будет работать и с *sql.DB, и с *sql.Tx без ветвлений или дублирования кода.
Возвращать нулевое значение с nil-ошибкой при «не найдено» заставляет вызывать угадывать. Общий сэнтинел вроде ErrNotFound хранит состояние в ошибке, и код сервисов может надёжно проверять errors.Is.
Разделяйте входы и модель хранения. Предпочтительнее Create(ctx, CreateInput) и Update(ctx, id, UpdateInput), чтобы вызывающие не могли задавать серверные поля (ID, таймстемпы). Для патч-обновлений используйте указатели или nullable-типы, чтобы отличать «не задано» от «задано в ноль».
Всегда явно задавайте ORDER BY, желательно по уникальному столбцу (например первичному ключу). Иначе пагинация может пропускать или дублировать записи между запросами по мере добавления новых строк или смены плана выполнения.
Предоставляйте небольшой набор ошибок, по которым вызывающие могут ветвить логику, например ErrNotFound, ErrConflict. Всё остальное можно оборачивать с контекстом базовой ошибки (DB/network). Не заставляйте разбирать строки ошибок — используйте errors.Is и информативные сообщения в логах.
Тестируйте общие хелперы отдельно (нормализация пагинации, проверка affected rows, маппинг not-found), а для каждой сущности проверяйте SQL и сканирование строк. Добавьте «контрактные» тесты: create→get возвращает одинаковые данные, update меняет ожидаемые поля, delete делает get возвращающим ErrNotFound, а list сохраняет порядок.


