Русский
Env-паттерн: один IO-позвоночник, переносимая бизнес-логика
Этот архитектурный подход я использую в Go уже 7+ лет. Устоявшегося названия у него нет — это естественная эволюция идиоматического Go применительно к реальному монолиту.
Первая версия этой статьи описывала паттерн таким, каким я его помнил. Летом 2026 я прогнал по коду trip2g аудит и выяснил, что код местами разошёлся с текстом. Часть расхождений мы дожали пятью рефакторингами, часть я оставил осознанно. Эта версия статьи описывает то, что есть, включая шрамы.
Идея
Каждый use case живёт в своём пакете под internal/case/. Каждый объявляет минимальный Env-интерфейс — только те методы, которые ему действительно нужны:
// internal/case/hidenotes/resolve.go
type Env interface {
HideNotePath(ctx context.Context, params db.HideNotePathParams) error
LatestNoteViews() *model.NoteViews
PrepareLatestNotes(ctx context.Context, partial bool) (*model.NoteViews, error)
Logger() logger.Logger
}
func Resolve(ctx context.Context, env Env, input Input) (Payload, error) {
// чистая бизнес-логика
}
В cmd/server/ есть один центральный app-struct, который держит всё: подключения к БД, клиенты хранилища, Telegram-сессии, кэши. Он неявно удовлетворяет всем Env-интерфейсам сразу — Go duck typing делает это автоматически.
// GraphQL-резолвер — тонкий адаптер
func (r *queryResolver) HideNotes(ctx context.Context, input model.HideNotesInput) (model.HideNotesOrErrorPayload, error) {
return hidenotes.Resolve(ctx, r.env(ctx), input)
}
app передаёт себя как env. Use case видит только свой узкий срез.
Когда кейс вызывает другой кейс
В первой версии статьи здесь стояло: «use case никогда не импортирует другой use case напрямую». Аудит показал, что код это обещание уже не выполнял — не по замыслу, а по накопившейся энтропии. В пяти местах кейсы добирались до чужого контракта через рантайм-каст env из контекста запроса:
// так было: рантайм-каст к чужому Env посреди логики
webhookEnv, ok := req.Env.(handlenotewebhooks.Env)
if !ok {
env.Logger().Error("failed to cast env")
return // вебхуки молча не отправились
}
Компилятор такое не ловит. Забыл метод — узнаешь из логов, если повезёт.
Теперь межкейсовые зависимости выражаются двумя способами, и оба проверяет компилятор.
Первый — непрозрачный порт: кейс объявляет метод в своём Env, а app оборачивает чужой Resolve:
// internal/case/hidenotes: Env объявляет порт
type Env interface {
// ...
HandleNoteWebhooks(ctx context.Context, changes []handlenotewebhooks.NoteChange, depth int) error
}
// cmd/server: app реализует его одной строкой
func (a *app) HandleNoteWebhooks(ctx context.Context, changes []handlenotewebhooks.NoteChange, depth int) error {
return handlenotewebhooks.Resolve(ctx, a, changes, depth)
}
Второй — встраивание контракта, когда кейсу нужен чужой контракт целиком:
// internal/case/rendernotepage: рендеру страницы нужен весь layout-контракт
type Env interface {
renderlayout.Env // Go 1.14+: дубли методов в embedded-интерфейсах разрешены
// ... свои методы
}
Порт — для оркестрации («сделай X и не спрашивай как»), встраивание — для общих способностей («мне нужно всё, что умеет layout»). Третьего способа больше нет.
Компилятор — это твой интеграционный тест
Если у app нет метода, который требует какой-то Env — проект не соберётся. Весь wiring проверяется статически. Никакого DI-контейнера в рантайме, никакой рефлексии. Компилятор и есть контейнер.
Нюанс, который я понял только после аудита: доказательство должно быть побочным эффектом wiring'а, а не отдельной дисциплиной. У нас был файл с двумя десятками строк вида var _ pkg.Env = app — ручной ритуал, который со временем разъезжается с реальностью. Мы его удалили, заменив конструкторами:
// так было: джоб получает env в рантайме и кастует
func (j *Job) Execute(ctx context.Context, env any) (any, error) {
return Resolve(ctx, env.(Env)) //nolint // «guaranteed»
}
// так стало: конструктор захватывает типизированный env
func New(env Env) *Job { return &Job{env: env} }
func (j *Job) Execute(ctx context.Context) (any, error) {
return Resolve(ctx, j.env)
}
Вызов pkg.New(app) в точке регистрации — и есть compile-time доказательство. Сам тип джоба спрятан (unexported), так что сконструировать его мимо New не получится: доказательству просто негде устареть.
Тот же принцип убил у нас фейковый дженерик. Регистрация фоновых джобов выглядела как Register[T, P], где T — тип Env. Выглядит типобезопасно, а внутри — env.(T) с паникой: рантайм-проверка, переодетая в тип-параметр. Оставили один честный P (он реально работает: анмаршалит JSON-payload в нужный тип), а env кейс захватывает замыканием:
func New(env UpdateTelegramMessageEnv) *UpdateTelegramMessageJob {
return &UpdateTelegramMessageJob{
enqueue: jobs.Register(env, QueueID, JobID, Priority,
func(ctx context.Context, params model.TelegramUpdatePostParams) error {
return Resolve(ctx, env, params) // env типизирован компилятором
}),
}
}
Правило простое: дженерик оправдан, когда без него пришлось бы копировать код (JSON-анмаршалинг в 17 джобах). Дженерик не оправдан, когда он изображает типобезопасность, которой нет.
Транзакции: почему Begin() не возвращает Env
Классический вопрос к паттерну: как делать транзакции? Наивный вариант — метод Begin() Env в интерфейсе — не компилируется:
type Env interface {
Begin() Env
}
func (e *RealEnv) Begin() *RealEnv { ... }
// cannot use e (type *RealEnv) as type Env:
// wrong type for Begin method: have Begin() *RealEnv, want Begin() Env
В Go нет ковариантных возвращаемых типов: метод, возвращающий конкретный тип, не удовлетворяет интерфейсу, ожидающему интерфейс. Я споткнулся об это сам много лет назад.
Ответ, к которому код пришёл сам: транзакции — это уровень app, а не кейсов. WithTransaction передаёт замыканию конкретный *app — копию с tx-привязанными запросами:
func (a *app) WithTransaction(ctx context.Context, fn func(context.Context, *app) (bool, error)) error {
tx, _ := a.writeConn.BeginTx(ctx, nil)
newEnv := *a // копия app
newEnv.queries = txQueries // с транзакционными запросами
commit, err := fn(txCtx, &newEnv)
// commit / rollback
}
Кейсы про транзакции не знают вообще. Проблема ковариантности не решена — она обойдена: внутри app интерфейс не нужен, там можно оперировать конкретным типом.
Сервисы встраиваются так же
Клиенты внешних сервисов (Telegram, MinIO, Patreon, git API) следуют тому же паттерну. Каждый объявляет свой Env, принимает его при создании и встраивается в app. Тот же механизм, те же гарантии.
Тестирование простое
Каждый Env маленький. moq генерирует мок одной строкой:
//go:generate go tool github.com/matryer/moq -out mocks_test.go . Env
Мокаешь только те 3-4 метода, которые use case реально вызывает. Никакого God-мока всего приложения. Тесты путешествуют вместе с пакетом use case.
Край системы: один жирный интерфейс — это цена, и она честная
Если все кейсы объявляют узкие Env, кто-то должен собрать их вместе. У нас это graph.Env — интерфейс GraphQL-слоя. В нём сотни методов, и когда-то я сам смеялся над такими: «жирный интерфейс на 50 методов, который все юзают частично».
Разница в том, как он устроен. Это не рукописный god-interface, а сумма контрактов:
// internal/graph/resolver.go
type Env interface {
hidenotes.Env
pushnotes.Env
sitesearch.Env
// ... ~120 case-Env'ов, по строке на кейс
// + методы самого graph: Select'ы для обслуживания запросов
}
Цена паттерна на краю системы — одна строка на use case. Взамен интерфейс становится оглавлением: «вот всё, что обслуживает GraphQL». Он не может уплыть от кейсов, потому что состоит из них. Жирный интерфейс на границе — не отступление от Interface Segregation, а его агрегат: сегрегированные контракты должны где-то сойтись, и лучше в одном явном месте, чем размазанными по коду.
Второе следствие такого края — цикл app↔graph разрывается без выкрутасов: graph не может импортировать cmd/server, поэтому объявляет контракт у себя, а app его выполняет. Тот же consumer-defined interface, только большой.
Шрамы
Аудит 2026 года нашёл в 61 кейсе следующее.
29 рантайм-кастов req.Env.(...). Из них 24 оказались легитимными: HTTP-эндпоинт достаёт env из контекста запроса и кастует к своему собственному Env на входе. Это громкий fail-fast, и он подкреплён компилятором с другой стороны — router.New(app) требует router.Env, который транзитивно встраивает Env каждого эндпоинта. Осталось 5 настоящих дыр — касты к чужим контрактам посреди логики, где промах означал молча пропущенные вебхуки. Их мы вычистили портами и встраиванием (см. выше).
Бизнес-логика в резолверах. Пять GraphQL-резолверов отрастили по 34–73 строки, включая enforcement прав доступа — проверку read-скоупов токена в транспортном слое, где её не покрывают тесты кейсов. Вынесли в кейсы, резолверы вернулись к 3-13 строкам.
Ретраи, написанные в четырёх копиях, и все четыре — с одинаковыми багами: таймаут от context.Background() (игнорирует shutdown), рекурсивный повтор со свежим бюджетом на каждую попытку и без лимита (упорный rate-limit от Telegram = бесконечная рекурсия), time.Sleep вместо ожидания с отменой. Разбиение на Resolve и Resolve1 появилось, чтобы обойти линтерное ограничение на длину функции, — и обёртка с ретраями годами жила отдельно от логики, которую оборачивала. Паттерн тут ни при чём, но он и не спас: скопированный код копирует баги.
Вывод из шрамов один, и он не «паттерн не работает». Все три семейства проблем росли из одного корня — из мест, где проверку у компилятора отобрали и отдали рантайму. Там, где wiring оставался статическим, за семь лет не сгнило ничего.
Недожатые места
Что осталось как есть — осознанно.
Входные касты на границе. Env едет в HTTP-хендлеры через контекст запроса (req.Env), и на входе каждого эндпоинта живёт каст. Убрать его совсем можно, только отказавшись от контекстной доставки env, а она нужна: через тот же механизм подменяется env в транзакциях. Каст остаётся, но за ним стоит транзитивное доказательство от роутера.
Насос подписок в резолвере. GraphQL-subscription NoteChanges — 48 строк горутины с каналами прямо в резолвере. Это транспортная обвязка, ей в кейсе делать нечего. Auth и ACL из неё вынесены и покрыты тестами, насос остался.
Два cron-джоба без своего пакета логики. simplebackup и refreshtelegramchatusernames — чистые делегации в одну строку. Заводить им resolve.go + Env + моки — церемония ради церемонии.
Неявный контракт между кейсами. listnotepaths в ветке поиска не фильтрует результаты по скоупам — потому что sitesearch уже фильтрует. Это правда, и это проверено, но контракт нигде не записан типом. Если однажды sitesearch перестанет фильтровать, компилятор промолчит.
Общий бюджет ретраев. Новый retry-цикл делит один таймаут на все попытки, старый выдавал свежий на каждую. Патологически большой retry_after может съесть весь бюджет. Приемлемо: снаружи страхует редоставка очереди.
Какие паттерны это объединяет
Это не изобретение с нуля — это комбинация известных идей, применённых естественно в Go:
- Interface Segregation (SOLID-I) — каждый кейс видит только свой срез мира
- Dependency Inversion (SOLID-D) — кейсы зависят от абстракций, а не от
*app - Hexagonal Architecture — каждый
Envэто port,appэто adapter - Implicit DI — никакого фреймворка, никакой рефлексии; Go-интерфейсы и есть контейнер
app-struct — это IO-позвоночник. Всё, что касается внешнего мира — база данных, сеть, файловая система, сторонние API — живёт там. Бизнес-логика парит над ним, прикреплённая только через узкие интерфейсные контракты.
Сравнение с похожими подходами
benbjohnson/wtf — интерфейсы определяет провайдер
wtf — канонический Go-пример от Ben Johnson. Паттерн похож, но инвертирован:
// ROOT-пакет определяет интерфейс — provider-defined
type DialService interface {
FindDialByID(ctx context.Context, id int) (*Dial, error)
FindDials(ctx context.Context, filter DialFilter) ([]*Dial, int, error)
CreateDial(ctx context.Context, dial *Dial) error
UpdateDial(ctx context.Context, id int, upd DialUpdate) (*Dial, error)
DeleteDial(ctx context.Context, id int) error
}
// sqlite/ реализует явно
var _ wtf.DialService = (*DialService)(nil)
Интерфейс объявляет провайдер (root-пакет), не потребитель. DialService большой — все операции с сущностью в одном интерфейсе. Моки написаны вручную. Нет центрального app, который передаёт себя как env.
swaggest/usecase — фреймворк вокруг interactor'а
swaggest/usecase строится вокруг одной универсальной точки входа. Старое API (NewIOI) работало через interface{} с рантайм-кастами; современное NewInteractor — красивый дженерик, вход и выход типизированы:
u := usecase.NewInteractor(func(ctx context.Context, input myInput, output *myOutput) error {
output.Value1 = input.Param1 * 2
return nil
})
По нашему же правилу это честный дженерик: он делает реальную работу, убирая касты. Разница в другом: типизированы вход и выход, но не зависимости — их interactor захватывает замыканием из внешнего скоупа, без объявленного контракта вроде Env. Впрочем, подходы ортогональны: внутрь interactor'а можно положить свой Resolve(ctx, env, input) — swaggest возьмёт на себя транспорт и генерацию API-документации, Env-паттерн — зависимости.
Сравнение
| wtf | swaggest | trip2g | |
|---|---|---|---|
| Где интерфейс | root-пакет (провайдер) | нет | use case (потребитель) |
| Размер интерфейса | весь сервис (большой) | нет | только нужные методы |
| Зависимости | поля struct | замыкания | Env-интерфейс |
| Моки | ручные | не нужны | codegen (moq) |
| Точка входа | метод на struct | Interact(ctx, in, out) |
Resolve(ctx, env, input) |
app как hub |
нет аналога | нет аналога | app передаёт себя как env |
| Type safety | явная проверка | generics (новое API) | duck typing в точках вызова + конструкторы как доказательства |
Самое уникальное в trip2g-подходе: app передаёт себя — hidenotes.Resolve(ctx, a, input). Один объект одновременно — адаптер для всех портов, и компилятор проверяет это для 60+ use cases.
Похожее в литературе
У паттерна нет единственного канонического названия, но он хорошо описан авторитетными Go-авторами:
- Peter Bourgon — Go for Industrial Programming (GopherCon EU 2018) — ближайшее к авторитетному описанию: интерфейсы как consumer contracts на callsite, а не объявления в пакете провайдера.
- Dave Cheney — SOLID Go Design — Interface Segregation в Go: маленькие интерфейсы, определённые потребителем. Большой
app, удовлетворяющий десяткам таких интерфейсов, — естественное следствие. - Go Time #102 — Bourgon, Ben Johnson и Mat Ryer обсуждают именно это: где живут интерфейсы и как центральный struct их удовлетворяет.
- benbjohnson/wtf — канонический пример: SQLite-struct удовлетворяет нескольким domain-интерфейсам, объявленным в других пакетах.
Ближайший термин в Go-сообществе — "consumer-defined interfaces": use case владеет своим контрактом, а не зависимость.
Тот же подход работает в TypeScript
Это уже не гипотеза — я построил так второй бэкенд, на TypeScript (Bun + GraphQL). Структурная типизация TS работает как duck typing в Go, и паттерн перенёсся дословно:
// src/cases/adminAddTrc20Wallet.ts — реальный кейс, один файл
const inputSchema = z.object({
createdBy: z.string(),
address: z.string().trim().regex(/^T[a-zA-Z0-9]{33}$/, 'Invalid TRC20 wallet address'),
});
type Env = {
adminAddTrc20Wallet: (args: { createdBy: string; address: string }) => Promise<Wallet | null>;
sendNotification: (payload: SendMessage) => Promise<void>;
};
export async function adminAddTrc20Wallet(input: Input, env: Env) {
const { createdBy, address } = inputSchema.parse(input); // валидация на границе кейса
const row = await env.adminAddTrc20Wallet({ createdBy, address });
if (!row) throw createWalletErr;
await env.sendNotification({ type: 'TRC20_WALLET_ADDED', walletId: row.id, actorId: createdBy });
return { walletId: row.id };
}
Кейс — один файл: zod-схема, свой Env, своя логика, рядом тест. Центральный context.ts реализует всё, как app в Go. Транзакции — тем же ходом, что и WithTransaction: withTx собирает замыканию окружение с tx-запросами через spread.
Три места, где TS даже удобнее. Моки не нужно генерировать: структурная типизация позволяет написать их объект-литералом за пять строк, moq не нужен. Валидация входа живёт прямо в кейсе (zod-схема — часть контракта), а не в транспортном слое. И типы не обязательно импортировать: контракту достаточно структурного совпадения — сошлись имена полей и форма, значит подходит. В Go совпадение точное: сигнатуры методов Env включают конкретные типы из db и model, и кейс тянет эти импорты за собой.
Последний проект, где я конструировал модули
trip2g — последний проект, где эта конструкция собиралась руками и по дороге набивала шишки, описанные выше. В следующих Go-проектах я не проектирую структуру модулей — просто открываю trip2g как референс и повторяю. В TypeScript-проекте сделал то же самое. Паттерн в этом смысле закончен: известно, что он даёт, известна цена (жирный агрегат на краю, касты на границе), известны границы применимости.
Если соберётесь пробовать — начните с одного кейса: пакет, Env из трёх методов, Resolve, тест с моком. Остальное — app, порты, край — достроится по мере того, как кейсов станет много. У меня именно так и вышло.