Я пять лет писал на .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-lintv2. Как он устроен, какие правила (GPL001–GPL006) реализует, как подключить в CI.
Ссылки для тех, кто захочет пощупать:
Если у вас есть вопросы, кейсы, где gerpo упадёт, или замечания к API — я читаю issues и благодарен за любой фидбек.
ссылка на оригинал статьи https://habr.com/ru/articles/1028320/