Generated API
Who this is for: anyone about to call the generated code from main.go or a test, or reading a
servo_gen.go in a pull request and wanting to know which parts are contract and which are
incidental.
The generated file is ordinary Go. It is compiler-checked, IDE-navigable, steppable in a debugger, and reviewable like any other file. What follows is exactly what it contains.
What gets written, and where
| File | When | Contains |
|---|---|---|
servo_gen.go |
Always | App, New, the full method set, one registry per declared scope, one http*Server per served group when the spec declares servo.HTTP(), Values/NewWith when the spec declares a servo.Value, and the config-file reader when it declares a servo.ConfigFile |
servo_gen_test.go |
Only when the spec declares at least one servo.Override |
TestApp, NewTestApp, the same method set, and TestValues/NewTestAppWith under the same condition |
servo_config_gen.go |
One per //servo:config type the graph uses |
The ServoConfig loader — written beside the config type, not the spec, which is what lets the type and its fields stay unexported |
The first two land in the same directory as the spec file, in that directory’s package — which
is why the spec file belongs next to the main.go that will call New. Inside New, a config
value is a local, deliberately never an App field (the type is usually unexported to its own
package); see Generated configuration.
servo_gen.go opens with //go:build !servoinject, the mirror image of the spec file’s constraint,
so the two are never in one build. servo_gen_test.go is a _test.go file, so the test-only
constructor never reaches your binary.
When servo generate is given build flags, the names and the constraints both
follow the configuration: --tags=prod writes servo.prod_gen.go gated //go:build !servoinject &&
prod, alongside rather than instead of the default variant. See
Build variants.
Both carry the standard marker:
// Code generated by servo generate. DO NOT EDIT.
Take it literally. Regenerating overwrites the file completely, and there is no merge. Anything you need to add goes in a hand-written file in the same package.
Commit the generated file. A fresh checkout should build without anyone running servo generate
first, and servo check in CI is what keeps the committed copy honest. servo doctor warns when it
looks untracked.
The header comment
Every generated file documents the graph it was generated from:
// Code generated by servo generate. DO NOT EDIT.
//
// Resolved graph:
//
// [L1] *example.com/servobasic/logger.Logger
// deps: none
// capabilities: Finalizer | binding: sole candidate | logger/logger.go:10:6
// [L2] *example.com/servobasic/postgres.DB
// deps: *example.com/servobasic/logger.Logger
// capabilities: Initializer, Finalizer, Healther | binding: explicit bind | postgres/postgres.go:13:6
// [L3] *example.com/servobasic/api.Server
// deps: *example.com/servobasic/postgres.DB
// capabilities: Runner, Drainer, Finalizer, Readier | binding: sole candidate | api/api.go:15:6
[Ln] is the level, binding is why that provider
was chosen, and the position is relative to the module root so the file is byte-identical across
checkouts. A provider from another module is named by its package’s import path instead
(github.com/okian/servo/v3/middleware/recover.go:17:6), and one from the standard library by
$GOROOT/src/…, so where a machine keeps its dependencies never reaches the file either. This block
is the reason a reviewer can see the effect of a signature change in the diff: a new dependency
shows up here, not just in a call site.
A supplied value is listed first, at [L0], with the servo.Value[…]() call site
in place of a provider:
// [L0] example.com/app/conf.Flags
// supplied by the caller cmd/api/spec.go:13:3
An app with a scope gets a second block per scope, below the node list — its policy,
the accessors that expose it, what one instance holds ([Sn] is the level within that scope),
and the singletons it borrows from the app:
// scope example.com/servoscoped/chat.RoomKey
//
// linger: 30s | max: 10000
// accessor: example.com/servoscoped/chat.Rooms -> *example.com/servoscoped/chat.Room
// [S1] *example.com/servoscoped/chat.RoomLog
// capabilities: Initializer, Flusher
// [S2] *example.com/servoscoped/chat.Room
// capabilities: Initializer, Runner, Drainer, Finalizer
// borrows: *example.com/servoscoped/logger.Logger
An app that declares servo.HTTP() gets an http block too, below the node list — the
config node, the declared groups, each use: and extract: attachment, and one line per served
route naming its handler and the arguments it receives, with extracted parameters marked. From
examples/http’s
real output:
// http (servo.HTTP() at cmd/app/spec.go:23:3):
//
// config: *github.com/okian/servo/v3/servo.HTTPConfig
// groups: internal, telemetry
// use: *github.com/okian/servo/v3/middleware.Recover -> every group
// use: *example.com/servohttp/mw.Auth -> group internal
// extract: *example.com/servohttp/mw.UserExtractor -> *example.com/servohttp/mw.User
// POST /order/{category}/ -> api.Order (api/api.go:94:1)
// args: *example.com/servohttp/store.Store
// GET /whoami -> api.Whoami (api/api.go:145:1)
// args: *example.com/servohttp/mw.User (extracted)
The App type
type App struct {
logger *logger.Logger
loggerStopOnce sync.Once
loggerStopResult servo.NodeResult
db *postgres.DB
dbStopOnce sync.Once
dbStopResult servo.NodeResult
server *api.Server
serverStopOnce sync.Once
serverStopResult servo.NodeResult
consumer *worker.Consumer
ordersAccount *queue.OrdersAccount
auditAccount *queue.AuditAccount
relay *relay.Relay
startupReport servo.StartupReport
}
One field per node, in construction order, plus bookkeeping only where it’s needed:
| Field | Emitted when |
|---|---|
<name> |
Always — the component itself, or, for a supplied value, the value copied out of Values |
<iface>Traced *traced<Iface> |
The spec declares servo.Trace[I]() — the generated wrapper, held like any node and handed to every consumer of I; no stop bookkeeping, since the wrapper has no lifecycle of its own |
<name>Cleanup func() |
The constructor returned a cleanup func |
<name>StopOnce sync.Once |
The node has something to stop (Drain, Flush, Stop, or a cleanup func) |
<name>StopResult servo.NodeResult |
Same condition — memoises the result so the stop path is idempotent |
http<Group>Server, with its own StopOnce/StopResult pair |
The spec declares servo.HTTP() — one triple per served group (httpServer for the default group) |
startupReport |
Always |
In the example above, consumer implements only Runner and the two queue accounts implement
nothing, so neither gets stop bookkeeping. That’s the general rule: you pay for the capabilities you
implement and nothing else.
When the spec declares a scope, the App gains four more fields per scope:
| Field | Purpose |
|---|---|
<key>Scope *<key>Scope |
The registry: the instance map, the policy, the counters |
<iface> |
One accessor value per exposed root, passed to whatever consumer asked for that interface |
<key>ScopeStopOnce sync.Once |
Same idempotence bookkeeping every stoppable node gets |
<key>ScopeStopResult servo.NodeResult |
The whole scope’s merged outcome — one result, not one per instance |
The registry, its entry type and its accessors are package-level declarations in the same file. The
override variant prefixes all of them with test, since both files land in the same package.
When the spec declares servo.HTTP(), the App gains one server triple per served
group — httpServer for the default group, httpTelemetryServer for a group named telemetry —
with the same StopOnce/StopResult bookkeeping every stoppable node gets. The server types are
package-level declarations in the same file, and like a scope registry they are emitted machinery
rather than graph nodes: each holds its listener config, its *http.Server and a readiness flag.
They are constructed after every node and before the Init phase — fallibly, because a declared
group missing from HTTPConfig.Groups is the one wiring error only runtime values can reveal, and
it fails New with the same rollback any Init failure gets, before any Init has run.
Every field is unexported. The generated file is part of your package, so code in that package — including its tests — can reach any component directly, and code outside it cannot. That boundary is deliberate: a graph node is not a service locator. If another package needs a component, pass it in as a dependency.
Field names
Derived from the type’s own name, so the generated code reads like something a person wrote:
| Rule | Type | Field |
|---|---|---|
| First letter lowercased | *api.Server |
server |
| All-caps names lowercase entirely | *postgres.DB |
db (not dB) |
| Collisions get a readable numeric suffix | two Pool types |
pool, pool2 |
| Keywords and predeclared names are avoided | *pkg.Range, *pkg.New |
range2, new2 |
| A scope is named for its key type | chat.RoomKey |
roomKeyScope |
| An accessor is named for its interface | chat.Rooms |
rooms |
| A trace wrapper is named for its interface, suffixed | store.Store |
storeTraced, of type tracedStore |
That last rule covers both the illegal case (range can’t be an identifier) and the merely
unwise one (new would legally shadow the builtin — fine for the compiler, not something to emit
into generated code).
Field names are an implementation detail: they can change if you rename a type, and they are not part of the stable API. Tests that reach into fields are reaching into your own package, which is fine — just expect to update them when you rename things.
Method set
This is the stable surface. Every generated App has exactly these methods — plus
HTTPHandler, the one conditional member, emitted only when the spec declares
servo.HTTP().
New
func New(ctx context.Context) (*App, error)
Constructs every node in dependency order, then runs the Init phase. Returns a ready application,
or nil and an error. Never a partially initialised app. Full semantics, including the two distinct
rollback paths, in Lifecycle.
The ctx is used for Init; the rollback paths strip its cancellation, so a signal arriving during
a slow startup unwinds properly instead of abandoning every node at once. Pass the context that
carries your signal handling.
Values and NewWith
Emitted only when the spec declares at least one servo.Value. A graph that
declares none emits the file it always did, byte for byte.
// Values carries the values servo.Value declares: the ones the caller
// supplies rather than any provider builds.
type Values struct {
Flags conf.Flags
}
// New builds the app with the zero value of every servo.Value.
// Prefer NewWith: the zero value is a real value for a struct of
// options and a nil pointer for anything else, so this is right only when
// the zero value is what you meant.
func New(ctx context.Context) (*App, error) {
return NewWith(ctx, Values{})
}
func NewWith(ctx context.Context, v Values) (*App, error) { … }
NewWith is the one to call. It carries the real body — everything New does above, plus an
assignment per supplied value at the very top of construction, before any constructor runs. A
supplied value is one per app, held for the life of the process, exactly like a constructed
singleton.
New still exists, and delegates with a zero Values{}. That is deliberate: the alternative —
dropping New from a graph that declares a value — would make the presence of a marker change the
generated package’s public API, which is the one thing the method set is supposed to pin down. It
cannot do better than the zero value and its doc comment says so; for a struct of options that is
often exactly right, and for a *sql.DB it is nil.
Field names are exported and allocated separately from App’s. They come from the type’s own
name (conf.Flags → Flags), in a namespace of their own, because Values is a struct you write a
literal for rather than an internal container. Like App’s field names they are not a stable API:
rename the type and the field follows.
In the test variant the three names are TestValues, NewTestAppWith and
NewTestApp, for the same reason TestApp exists — both files land in the same package.
Run
func (a *App) Run(ctx context.Context) error
Runs every Runner until they return, one failing runner cancelling the rest. Emitted HTTP
servers join the same errgroup: each binds its listener, flips its readiness flag, and serves
until the context ends. Returns nil immediately if the graph has no runners and no servers.
Does not shut anything down — see Lifecycle.
Shutdown
func (a *App) Shutdown(ctx context.Context) servo.Report
Stops every stoppable node in reverse dependency order, each phase under a budget, and returns a
per-node servo.Report. Emitted HTTP servers stop first, before
every singleton — they are the inbound edges, and draining them is what lets everything beneath
quiesce. Idempotent. Never returns an error type; check report.Clean(). Details, including the
second-signal force-exit, in Lifecycle.
Health and Ready
func (a *App) Health(ctx context.Context) servo.Report
func (a *App) Ready(ctx context.Context) servo.Report
Call every Healther / Readier once, in construction order, and report each result. Flat, with no
transitive aggregation, and not called automatically by anything. A graph with no such components
returns an empty report, which is Clean(). See
Lifecycle.
An emitted HTTP server adds one entry per group to Ready — named "http" for the default group,
"http:<group>" for the rest — meaning exactly “that listener is bound”; the entry drops back to
failed the moment Shutdown starts draining that group. Health says nothing about them: a bound
socket is not a health claim.
HTTPHandler
func (a *App) HTTPHandler(group string) http.Handler
Emitted only when the spec declares servo.HTTP() — the one method whose presence depends on the
spec. Returns the named group’s routed, middleware-wrapped handler, so a test can drive the full
stack through httptest without binding a listener. "" and "default" both name the default
group; an unknown name returns nil. TestApp gets it too, so the same seam works against the
override graph.
Graph
func (a *App) Graph() servo.Graph
Returns the resolved graph as data: every node’s type string, level, dependencies, capabilities, selected binding, and source position. It is emitted as a compile-time constant — a literal slice in the generated source — so calling it costs nothing and can never disagree with what was actually built.
It is display-only. Type strings are labels, not lookup keys, and there is no path from a
GraphNode back to the instance it describes. Useful for a /debug/graph endpoint, a startup log
line, or asserting graph shape in a test.
It serialises byte-for-byte the way servo graph --format=json does, and that is a checked
property rather than an aspiration: the two paths populate the identical servo.Graph struct, both
write nil (JSON null) for an empty deps, and both write positions relative to the module root.
A consumer written against one works against the other — which was the point of the claim, and is
now true of the bytes as well as the schema.
A supplied value appears here too, first and at Level: 0, with Binding:
"supplied" and the servo.Value[…]() call site as its Pos. A trace wrapper
appears as a node keyed <interface>#traced, with Binding: "traced", the servo.Trace[…]()
call site as its Pos, and Wraps naming the node it decorates — a field that is omitempty,
so a graph with nothing traced serialises exactly as before.
Scoped nodes appear here too, after the singletons, each carrying its scope’s key in Scope and
its level within that scope in Level. The scopes themselves are listed in Graph.Scopes, with
their policy, their accessor interfaces, their members and the singletons they borrow. When the
spec declares servo.HTTP(), Graph.HTTP carries the emitted plan the same way — the served
groups, every route with its handler and arguments (extracted ones marked), the middleware
attachments and the extractors, as servo.GraphHTTP. All of these fields are
omitempty: an app with no scopes and no HTTP serialises exactly as it always did.
Scope accessors
A scope adds no public method to App — only an unexported stop<Key>Scope, alongside the
per-node stop<Name> methods. What consumers see is a field, satisfying the interface you
declared:
type roomsAccessor struct{ s *roomKeyScope }
func (x roomsAccessor) Acquire(ctx context.Context) (*chat.Room, func(), error) { ... }
func (x roomsAccessor) Stats() servo.ScopeStats { ... }
That shape is part of the contract — an interface declaring anything else is a generate-time diagnostic. Everything the accessor does is documented on Scoped instances.
Report
func (a *App) Report() servo.StartupReport
Returns per-node Init durations recorded during New: which component cost what, with no external
instrumentation. Only nodes implementing Initializer appear. Within a
level whose nodes ran concurrently, entries are in
completion order.
Call it right after New succeeds — most usefully paired with Shutdown’s report in
servotest.NewRecorder to assert real ordering.
stop<Name> (unexported)
func (a *App) stopServer(ctx context.Context) servo.NodeResult
One per stoppable node, called by Shutdown and by construction rollback. Guarded by that node’s
sync.Once, so both callers can invoke it and the component’s teardown still happens once.
Unexported deliberately: shutdown order is a property of the graph, and stopping one node out of
order is not a supported operation.
Emitted HTTP servers get the same treatment — a stopHttpServer (or stopHttp<Group>Server) per
group, draining via net/http.Server.Shutdown under the stop budget. Each is nil-guarded: the
servers are built after every node, so the rollback for a group missing its Groups entry reaches
servers that were never constructed, and the guard reports each as a clean no-op instead of
dereferencing nothing.
The test variant
When the spec declares a servo.Override, servo generate emits a second file with a separate
type:
func NewTestApp(ctx context.Context) (*TestApp, error)
TestApp has the same method set as App and is otherwise a distinct type — not an alias, not a
wrapper. It has to be. Overriding an interface can resolve a completely different concrete type,
which means different fields, different capabilities, and a different set of stop methods; the two
graphs cannot share one struct definition.
Every generated name is prefixed the same way, for the same reason: a spec that also declares a
servo.Value gets TestValues and NewTestAppWith beside NewTestApp, and the
scope registries and accessors get a test prefix.
The two graphs are resolved separately, so a servo.Value that only the production graph depends on
is unused in the override graph. That is not the diagnostic it would be in production: a mock that
takes fewer things than the real implementation is the ordinary case, so the value is left off
TestValues — and when nothing in the test graph reads any value, TestValues and NewTestAppWith
are not emitted at all, leaving NewTestApp alone. An unused servo.Trace (no wrapper is emitted)
and an unused servo.ConfigFile (no file is read) get the same treatment there.
Because it lives in a _test.go file, it exists only under go test. A typical test:
func TestApp(t *testing.T) {
defer servotest.NoLeaks(t)
servotest.Timeout(t, 50*time.Millisecond)
ctx := context.Background()
app, err := NewTestApp(ctx)
if err != nil {
t.Fatal(err)
}
app.storeMock.GetFunc = func(key string) string { return "mocked:" + key }
got := app.server.Lookup("user:42")
// ...
rec := servotest.NewRecorder(app.Report(), app.Shutdown(ctx))
servotest.AssertStopOrder(t, rec, "*api.Server", "*postgres.DB")
}
The mock is reachable as a field because the test is in the same package as the generated file — the same boundary that keeps it unreachable from anywhere else.
Two things to know: servo check compares this file exactly as it compares servo_gen.go, and
refuses one left behind by a spec that no longer declares an Override — servo generate removes
such a leftover, since nothing would regenerate it; and an override applies to the entire graph,
never to one consumer.
What is stable
Stable, and safe to write code against: the method set above, its signatures, and the semantics
of each — HTTPHandler included, under its condition; that New(ctx) exists and keeps that
signature whether or not the spec declares a servo.Value; and that NewWith/Values are the
names used when it does. The JSON schema of servo.Graph. The types in the
servo package, which for HTTP means the sealed
response family, HTTPStatus and its Status table, and
HTTPConfig/HTTPListener.
Not stable: App field names, Values field names, the exact statements emitted, the header
comment’s formatting, variable names inside New. Treat the file’s contents as an implementation
detail of your own package — you commit it, you review it, you don’t depend on its internals, and
you never edit it.