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, and Values/NewWith when the spec declares a servo.Value |
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 |
Both 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.
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. 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
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 |
<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 |
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.
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 |
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.
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. Returns nil
immediately if the graph has no runners. 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. 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.
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.
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. Both fields
are omitempty: an app with no scopes serialises exactly as it did before scopes existed.
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.
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.
That distinctness has one consequence worth planning for. The two graphs are resolved separately, so
a servo.Value that only the production graph depends on is unused in the override graph — and an
unused value is a diagnostic. If overriding an interface removes the only consumer of a supplied
value, either the mock has to take it too or the value has to go.
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 servo_gen.go only, so a drifted override variant is not
reported by it (a go test run is what catches that); 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 — including 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.
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.