gerpo: repository pattern для Go через указатели, без struct tags и кодогенерации

от автора

Я пять лет писал на .NET, и там у меня сложилась привычка держать доменную модель отдельно от инфраструктуры хранения. Repository pattern — не как догма из книги Фаулера, а как рабочий способ не тащить DbContext, маппинги и названия колонок в сущности. Домен остаётся доменом. Когда я перешёл на Go, меня сразу царапнули struct tags. Большинство библиотек работы с БД ожидает примерно такое:

type User struct {    ID    uuid.UUID `db:"id"`    Email string    `db:"email"`    Age   int       `db:"age"`}

Доменная сущность перестаёт быть доменной — она знает про свою схему в БД. Мне хотелось, чтобы сущность не знала вообще ничего, а всё знание жило в одном месте — в конфигурации репозитория, да-да, чистый перфекционизм. Конечно это не вся моя мотивация — ещё привычка и ,конечно же, лень. Для проектов среднего масштаба, хочется иметь универсальный репозиторий с кучей фильтров и пр., а не писать каждый метод для этого отдельно, даже если на них нет индексов, то в небольших проектах, пока это работает сносно, можно использовать и так, а потом уже добавить индексы.

Готового решения под такую постановку я не нашёл. Поэтому написал своё — gerpo. Это проект из моих потребностей и вкусов. Я не претендую, что он лучше существующих инструментов, я просто его сделал для себя и решил им поделится, 20 апреля 2026 года вышла версия 1.0.0, и я решил, что пора рассказать про неё публично. Я разрабатывал её довольно долго, с 2024 года — редко и потихоньку, но нашел силы довести до v1.0.0.

Парочка примеров, как это выглядит

Конфигурация репозитория под модель User.

type User struct {    ID        uuid.UUID    Email     *string    Age       int    CreatedAt time.Time}repo, err := gerpo.New[User]().    Adapter(pgx5.NewPoolAdapter(pool)).    Table("users").    Columns(func(m *User, c *gerpo.ColumnBuilder[User]) {        c.Field(&m.ID).OmitOnUpdate()        c.Field(&m.Email)        c.Field(&m.Age)        c.Field(&m.CreatedAt).OmitOnUpdate()    }).    Build()

Никаких тегов в структуре. Никакого go generate. Модель остаётся чистой: четыре поля, четыре типа, ничего лишнего. Всё знание про то, как User кладётся в таблицу users, лежит в одном Columns(…)-блоке.

Запрос на выборку:

adults, _ := repo.GetList(ctx, func(m *User, h query.GetListHelper[User]) {    h.Where().Field(&m.Age).GTE(18)    h.OrderBy().Field(&m.CreatedAt).DESC()    h.Page(1).Size(20)})

Снова те же &m.Age, &m.CreatedAt. Это не переменные и не лямбда-выражения из C#; это буквальные указатели на поля структуры, полученные через &. Вся магия в том, что gerpo умеет сопоставить такой указатель с колонкой, которую вы сконфигурировали при сборке репозитория.
P.S. Ну вы понимаете откуда и почему такой стиль и способ — корни видны 🙂

Центральная идея — field pointers

На этапе Build() gerpo один раз обходит структуру User и запоминает смещение каждого поля через unsafe.Offsetof. Когда потом в запросе приходит &m.Age, gerpo смотрит на смещение и находит ту самую колонку, которую вы привязали к полю в Columns(…). Это не reflection на каждый запрос — на горячем пути идёт только указательная арифметика.Я специально не буду в этой статье копаться в том, как устроен внутренний модуль fmap, как работает unsafe.Offsetof на разных Go-версиях, как обеспечена безопасность при pointer receiver. Это отдельная история, и она потянет на вторую статью. Здесь важно одно: указатель на поле — первоклассный объект в API gerpo. Им идентифицируют колонку в Columns, в Where, в OrderBy, в Only/Exclude. Один и тот же лексический элемент — &m.X — везде означает одно и то же. Нет переключения между «строковым именем колонки» и «именем поля» — это теперь одно целое.

Что это даёт на практике

Компилятор ловит удаление поля. Если я уберу Age из User, все строки с &m.Age перестанут компилироваться. Для struct-tag-библиотеки это ошибка рантайма, вы должны пройти все места sql генерации у удалить эту колонку и там, и если что-то забыто — всё просто перестанет работать где-то после деплоя.

Линтер ловит смену типа. Если я поменяю Age int на Age string, тип указателя &m.Age меняется. Дальше помогает gerpolint — статический анализатор, который я написал специально для этого: он ловит Field(&m.Age).EQ("18"), когда поле int, и Field(&m.Age).Contains("x"), когда Contains применяется к нестроковому полю, и пр. кейсы, выдавая ожидаемые ошибки. Компилятор сам такой вызов пропустит, потому что EQ принимает any. Линтер — нет. Да это известное ограничение Generics в Go. Я решил обойти его тулингом линтера.

Нет тегов, нет кодогенерации. Структура чистая, go generate не нужен. IDE-рефакторинг переименования работает штатно, потому что &m.Age — обычная Go-ссылка на поле.И здесь я обязан честно сказать про компромиссом с которым придется считаться, но и на этот условий тоже найдется фикс:

Переименование поля меняет имя колонки. По умолчанию gerpo берёт snake_case от имени Go-поля. Если вы переименуете Age в YearsOld, колонка в запросе станет years_old. Это поведение convention over configuration: удобное, но ловушка, если вы переименовываете поле и миграция не переименовала колонку в БД. Компилятор здесь молчит.Для стабильности есть явное имя:
c.Field(&m.Age).WithColumnName("age")

Я держусь такого правила: для production-таблиц всегда ставлю .WithColumnName(...). В учебных примерах и в examples/todo-api/ полагаюсь на конвенцию — там переименование структуры и колонки должны идти синхронно, и это упрощает чтение. Выбор — ваш. Но тезис «рефакторинг полностью безопасен» — неправда, есть ложка дегтя в бочке с мёдом.

Важные ограничения

SQL, который gerpo на выходе генерирует, — PostgreSQL-шейп: плейсхолдеры $1, RETURNING, оконные функции, CAST(? AS text) в LIKE. Версия 1.0 поддерживает только PostgreSQL (и PG-совместимые базы — CockroachDB и подобные — «drop-in», без формального тестирования). Multi-dialect — в бэклоге.

Инфраструктурный оверхед над чистым pgx. На реальном PostgreSQL gerpo добавляет порядка 8% latency и около x2 аллокаций. В абсолютных числах — одна микросекунда на операцию, при запросе, который идёт 50–500 µс через сеть, это 0.2–2%. На горячих путях с сотнями тысяч RPS, где каждая аллокация критична, чистый pgx будет предпочтительнее. В умеренных нагрузках разница в шуме.

Что дальше

Первая публичная версия gerpo стабильна, с документацией и runnable-примером. Дальше я планирую несколько статей написать, если будет интересно:

  • Deep dive в fmap. Как на самом деле устроен механизм field pointers, почему unsafe.Offsetof безопасен в том контексте, в котором gerpo его использует, что происходит при embedded-полях и указателях.

  • gerpolint. Статический анализатор для WHERE-фильтров уже доступен как отдельный бинарь и как плагин к golangci-lint v2. Как он устроен, какие правила (GPL001–GPL006) реализует, как подключить в CI.

Ссылки для тех, кто захочет пощупать:

Если у вас есть вопросы, кейсы, где gerpo упадёт, или замечания к API — я читаю issues и благодарен за любой фидбек.

ссылка на оригинал статьи https://habr.com/ru/articles/1028320/