English
The Env Pattern: One IO Spine, Portable Business Logic
This is an architecture pattern I've been using in Go for 7+ years. It doesn't have a single established name — it's a natural evolution of idiomatic Go applied to a real monolith.
The idea
Every use case lives in its own package under internal/case/. Each one declares a minimal Env interface — only the methods it actually needs:
// 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) {
// pure business logic
}
There's one central app struct in cmd/server/ that holds everything: database connections, storage clients, Telegram sessions, caches. It implicitly satisfies every Env interface across all use cases — Go's duck typing handles this automatically.
// cmd/server/case_methods.go
func (a *app) HideNotes(ctx context.Context, input model.HideNotesInput) (model.HideNotesOrErrorPayload, error) {
return hidenotes.Resolve(ctx, a, input)
}
app passes itself as env. The use case only sees its narrow slice.
When a case needs another case
A use case never imports another use case directly. Instead, app wraps it as a method:
func (a *app) HandleNoteWebhooks(ctx context.Context, changes []NoteChange) error {
return handlenotewebhooks.Resolve(ctx, a, changes)
}
If hidenotes needs to trigger webhooks after a hide, it declares that in its own Env:
type Env interface {
// ...
HandleNoteWebhooks(ctx context.Context, changes []NoteChange) error
}
app already has that method. No new wiring needed.
The compiler is your integration test
If app is missing a method that any Env requires — the project won't compile. All wiring is verified statically. You don't need a runtime DI container or reflection. The compiler is the container.
Services embed the same way
External service clients (Telegram, MinIO, Patreon, git API) follow the same pattern. Each declares its own Env:
// internal/gitapi/api.go
type Env interface {
Logger() logger.Logger
PutPrivateObject(ctx context.Context, ...) error
GitTokenByValueSha256(ctx context.Context, ...) (db.GitToken, error)
PushNotes(ctx context.Context, ...) (model.PushNotesOrErrorPayload, error)
}
The service struct takes env Env at construction time and gets embedded in app. Same mechanism, same guarantees.
Testing is straightforward
Each Env is small. moq generates a mock for it in one line:
//go:generate go tool github.com/matryer/moq -out mocks_test.go . Env
You mock only the 3-4 methods the use case actually calls. No God-mock of the entire application. Tests travel with the use case package.
Cases are portable
A use case package is self-contained: business logic + its Env contract + tests with mocks. Moving it to another project means satisfying the contract. If the new app compiles against it — it works. The tests come along for free.
What patterns does this combine
This isn't a novel invention — it's a combination of well-known ideas applied naturally in Go:
- Interface Segregation (SOLID-I) — each case sees only its slice of the world
- Dependency Inversion (SOLID-D) — cases depend on abstractions, not on
*app - Hexagonal Architecture — each
Envis a port;appis the adapter - Implicit DI — no framework, no reflection; Go interfaces are the container
The app struct is the IO spine. Everything that touches the outside world — database, network, filesystem, third-party APIs — lives there. Business logic floats above it, attached only through narrow interface contracts.
How it compares to similar approaches
benbjohnson/wtf — provider-defined interfaces
wtf is a canonical Go reference app by Ben Johnson. The pattern looks similar but is inverted:
// ROOT package defines the interface — 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
// all dial operations in one interface
}
// sqlite/ implements it explicitly
var _ wtf.DialService = (*DialService)(nil) // compile-time assertion
// mock/ — hand-written mocks with function fields
type DialService struct {
FindDialByIDFn func(ctx context.Context, id int) (*wtf.Dial, error)
CreateDialFn func(ctx context.Context, dial *wtf.Dial) error
// ...
}
The interface is declared by the provider (root package), not the consumer. DialService is large — it contains all operations for the entity. Mocks are hand-written. There's no central app struct passing itself as env.
swaggest/usecase — framework with reflection
swaggest/usecase uses a single universal contract via interface{}:
u := usecase.NewIOI(new(myInput), new(myOutput),
func(ctx context.Context, input, output interface{}) error {
in := input.(*myInput) // type assertion at runtime
out := output.(*myOutput)
out.Value1 = in.Param1 * 2
return nil
})
u.SetTitle("Doubler")
u.SetTags("transformation")
No Env interfaces at all — dependencies are captured via closures. Input/output are interface{} with reflection. It's primarily a framework for API documentation generation, not an architecture pattern for dependency management.
Comparison
| wtf | swaggest | trip2g | |
|---|---|---|---|
| Interface location | root package (provider) | none | use case (consumer) |
| Interface size | whole service (large) | none | only needed methods |
| Dependencies | struct fields | closures | Env interface |
| Mocks | hand-written | not needed | codegen (moq) |
| Entry point | method on struct | Interact(ctx, in, out) |
Resolve(ctx, env, input) |
app as hub |
no equivalent | no equivalent | app passes itself as env |
| Type safety | explicit assertion | runtime (reflection) | implicit duck typing |
The most distinctive aspect of the trip2g approach: app passes itself — hidenotes.Resolve(ctx, a, input). One object is simultaneously the adapter for all ports, and the compiler verifies this implicitly across 50+ use cases.
Prior art and references
The pattern doesn't have a single canonical name, but it's well-supported by respected Go authors:
- Peter Bourgon — Go for Industrial Programming (GopherCon EU 2018) — the closest authoritative description. Interfaces as consumer contracts at call sites, not declarations in the provider package.
- Dave Cheney — SOLID Go Design — Interface Segregation in Go: small interfaces defined by the consumer. A large
appstruct satisfying dozens of them is the natural consequence. - Go Time #102 — Bourgon, Ben Johnson, and Mat Ryer discuss exactly this: where interfaces live and how a central struct satisfies them.
- benbjohnson/wtf — a canonical example app where a concrete SQLite struct satisfies multiple domain interfaces defined elsewhere.
The community term closest to this is "consumer-defined interfaces" — the use case owns its contract, not the dependency.
The same approach works in TypeScript
TypeScript's structural typing is identical to Go's duck typing — a type satisfies an interface if it has the right shape, no explicit declaration needed. The pattern translates directly:
// use case defines its own contract
interface Env {
hideNotePath(params: HideNotePathParams): Promise<void>
latestNoteViews(): NoteViews
logger(): Logger
}
export async function resolve(env: Env, input: Input): Promise<Payload> {
// pure business logic
}
TypeScript is actually more convenient for this in one way: you can define the use case's input/output models in the same package, separate from the shared domain types. Each use case is fully self-contained — its own Env, its own Input, its own Payload.
On the server side (Node.js / Bun / Deno) this pattern works exactly as well as in Go. The current trip2g frontend is plain CRUD and doesn't use it — there's no need when a component just calls an API and renders the result. But for complex backend TypeScript services it's the same story: one central object satisfying many small interfaces, compiler as the integration test.