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.