Limitations
Who this is for: anyone deciding whether to adopt servo. This page exists so you can find out what it can’t do now, rather than three weeks into a migration.
Everything here is a real boundary in the current release, checked against the source. None of it is a roadmap item dressed up as a caveat.
How to read this page
The limitations fall into three groups, and the difference between them genuinely matters:
Consequences of resolving at build time. These follow from the core idea — working out the graph before the program runs. They won’t change, because changing them would mean building a different kind of tool. Treat them as permanent. Where one has turned out to be narrower than this page once claimed, the entry says so outright rather than quietly shrinking.
Deliberate omissions. These could be built. They haven’t been, and there’s a reason.
Narrow shapes. Rules about what servo will accept as a constructor. Most people meet these on day one, and most have a straightforward answer.
If you’re short on time: skip to servo is the wrong tool if… at the bottom. It’s six lines and it’ll tell you whether the rest is worth reading.
Consequences of resolving at build time
Almost everything is built once and lives for the whole process
The graph is constructed a single time, in the generated New, and held for the life of the
process.
The one exception is a scope: a type declaring a ScopeKey method gets one
instance per key, shared by everyone presenting that key, drained and stopped when the last holder
lets go. That covers the “one per room / per tenant / per region” shape, with the reference
counting, the linger window and the per-instance lifecycle generated for you.
What it does not cover is a fresh instance per call with no sharing at all. servo.Linger(0)
with a key that is unique per request comes close, at the cost of a goroutine per request to own a
counter that immediately hits zero.
What to do instead, for the genuinely transient case: write a factory type by hand and inject that, like any other dependency.
A value from outside the graph is supplied once per app, never per call
Each constructor parameter resolves to another node in the graph, by type. A value that only comes
into existence while the program runs has no provider to be that node — a parsed flag set, a
version string injected with -ldflags, a DSN assembled from the environment, a *sql.DB some
harness already opened.
servo.Value[T]() covers that case, and this page used to say it was impossible. Declaring one
makes the injector emit a Values struct and a NewWith(ctx, Values{...}) alongside New, and T
resolves from what the caller passed rather than from any provider — ahead of a provider that
produces the same type, since declaring one is how you say “this comes from the caller”.
What it does not cover is a value that differs per call. Values is filled in once, when the app
is constructed, so anything that varies between two uses of the same graph has nowhere to go. The
concrete case is a *testing.T, and it fails for a reason worth stating exactly: servo.Value is
declared in the spec file, and both New and NewTestApp are generated from that one file. A mock
constructor wanting a *testing.T is only in the graph on the override side, so on the production
side nothing depends on the declared type and generation refuses it:
servo: servo.Value[*testing.T]() is declared, but nothing in the graph depends on *testing.T
A declared value becomes a field on the generated Values struct, so this
one would be supplied by every caller and read by nobody.
Two ways out:
...
So mocking libraries still need a small hand-written adapter. The README’s Mocking section walks
through the pattern for moq, mockery, and gomock.
context.Context is the related half-case. It still gets no special treatment, so a constructor
asking for one has no provider by default; servo.Value[context.Context]() gives it one, but the
value is whatever the caller hands NewWith — not the ctx the generated New was called with,
and not a per-request context.
What to do instead: declare the per-app values. For a per-call one, make it a method parameter, or a struct field set after construction, or test that component directly instead of through the graph.
The graph can’t change at run time
One servo.Build call produces exactly one graph, and it is fixed when you generate. There’s no
conditional wiring evaluated at startup, no feature-flagged nodes, no reading an environment
variable to decide what to construct.
What to do instead: put run-time variation behind an interface whose single implementation branches internally.
Variation at build time is supported, and is a different thing: gate a second spec file on a build
tag and generate a variant for it, so go build and go build -tags=prod each select their own
generated file. See Build variants. The cost is a spec file per
configuration — one shared spec can’t do it, because a marker naming a type that only exists under
prod cannot type-check without prod.
Build variants only cover build tags, not GOOS/GOARCH
servo generate --tags=prod records prod in the generated file’s build constraint, so the wrong
variant can never be selected. GOOS=linux servo generate resolves the Linux graph — servo inherits
the environment like any go tool — but nothing in the output records that, so a graph that differs
across platforms has no variant mechanism to keep it straight.
This only bites if your providers actually differ by platform, which is rare for the kind of component servo wires. If yours do, gate them on a build tag of your own and use that.
Your spec file is read, never run
servo generate parses your servo.Build(...) call as text. It never executes it. Each argument
has to be written out literally — servo.Root[T](), servo.Bind[I, C](), servo.Value[T](), or
servo.Override[I, C](), one per line.
You can’t compute roots in a loop, hold them in a variable, or spread a slice. servo.Include(fn)
is the one apparent exception, and it is read the same way everything else is: fn is named,
never called, and servo generate reads the slice literal its body returns exactly as it reads
Build’s own argument list. That is why the body has to be exactly return []servo.Marker{...} —
a variable, a conditional, or an append is refused, because answering it would mean running the
program the spec file deliberately isn’t. Wiring can be shared between injectors; it still can’t be
computed.
This is also why the file carries the servoinject build tag: it’s excluded from your binary, and
servo.Build panics if it ever actually runs — which would mean the tag went missing. An included
marker set must carry the tag for the same reason, and servo generate refuses one that doesn’t.
Normal Go tooling never checks your spec file
Because it sits behind an inactive build tag, go build ./..., go vet ./..., and go test ./...
all skip it. Your editor will grey it out unless you configure the servoinject build flag.
The practical consequence: a type error in your spec file shows up when you run servo generate,
not while you’re writing it.
A cycle is always a build failure
The graph has to be acyclic. There’s no lazy or deferred construction that could break a cycle at runtime. If two components genuinely need each other, one of them has to stop taking the other as a constructor parameter — which is usually a design signal worth listening to, but it is a hard stop.
Scopes are in-process only
Two pods means two instances per key, unless your routing is sticky. servo has no distributed lock, no shared registry and no cross-process coordination, and adding one would make it a different kind of tool.
If two replicas both holding a “room” is wrong for your service, the answer is sticky routing or an external store — not something servo can do for you.
Nested scopes are rejected
A room-scoped node cannot depend on a tenant-scoped one. One instance per key pair means two reference counts and two linger windows with no single owner, and no obvious answer for what happens when the outer one evicts while the inner one is still held.
This is a rejection with its own diagnostic, not an oversight.
What to do instead: depend on the inner scope’s accessor interface and Acquire it inside
the method that needs it. That edge crosses no scope boundary, because an accessor is not an
instance.
Scopes do not reach the transport, and servo will not put them there
The key comes out of context.Context, and putting it there is your middleware’s job. servo ships
no net/http handler, no gRPC interceptor and no framework adapter, and the moment it did it would
stop being a codegen tool and start being a framework with opinions about your router.
What to do instead: one line in the middleware you already have — ctx =
context.WithValue(r.Context(), roomCtxKey{}, chat.RoomKey(...)) — and a ScopeKey method that
reads it back out.
A scoped instance is memory, and dies with the process
Instances are in-memory Go values. Nothing is persisted, nothing is migrated, and a restart starts every key from nothing. An instance evicted at the end of its linger window is gone with whatever it was holding.
What to do instead: if the state has to outlive the process, the instance is a cache in front of a store, not the store. Write through it.
servo exports no metrics
Stats() on the accessor is the whole of the observability surface: live instances, outstanding
references, and monotonic totals for acquires, evictions and failures. It is test- and debug-facing.
servo has no Prometheus dependency, no registry and no exporter, and won’t grow one.
What to do instead: read Stats() from whatever you already scrape with. It is four ints and two
counters, and wiring it to a gauge is a few lines you can see.
A scope accessor cannot be overridden
servo.Override[I, C] replaces one provider with another. A scope’s accessor is emitted, not
selected from candidates, so there is nothing for an override to replace — and rather than accept
one silently and go on exercising the real scope, servo generate refuses it.
What to do instead: the accessor interface is two methods. Give the consumer that interface as an
ordinary parameter and construct a stand-in for it directly in the test — which is what
examples/tutorial’s api_test
does, in about thirty lines, keyed off the same ScopeKey method the real accessor calls.
A panicking constructor fails the acquire, it does not panic the caller
If a scoped type’s constructor or Init panics, servo recovers it, rolls back whatever was built,
and returns it from Acquire as an error. It does not reach your handler as a panic.
That is a deliberate trade. A panic during a concurrent Init comes from a goroutine no caller
can recover, so letting it through would take the process down for what is otherwise one failed
request — while the same panic on a single-node level would merely fail that acquire. Converting
uniformly is the only way both behave the same.
What to do instead: if a constructor can fail, return an error from it. The panic path exists so a bug does not become an outage, not as a control-flow mechanism.
A scoped node has no Health or Ready
The generated Health and Ready cover singletons only. A report with one entry per live chat
room is not a report.
What to do instead: use the accessor’s Stats() for scope-level numbers, and put per-instance
health behind whatever your own component already exposes.
A scoped Run failure surfaces at eviction, not when it happens
Each instance’s Run goes into its own goroutine. If it returns an error other than its context
being cancelled, that error is attached to the instance’s stop result — which you see when the
instance is evicted, possibly a long time later. Nothing evicts an instance because its Run
failed.
What to do instead: if a component needs to react to its own Run failing, it has to do that
itself. Stats().Failures counts evictions whose teardown did not come out clean, which is the
only signal servo offers for a mid-life teardown — no Report is being assembled at the time, and
which phase failed is not recovered.
Deliberate omissions
There are no tags or names, so you can’t have two of the same type
Identity in the graph is purely by type. Two constructors returning the same concrete type aren’t two instances — they’re an ambiguity, and generation fails. (A scope is keyed at runtime by a value, which is a different thing: it gives you N instances of one type, but only one of them per key, and only reachable through an accessor.)
servo.Value[T]() doesn’t change this, because a supplied value is matched by type too. It gives
you one node of type T, not a second one: declaring the same type twice is refused outright
(servo.Value[T]() declared twice), and where a provider also produces T the value replaces it
rather than joining it.
A primary and a replica database. Two SQS clients on two AWS accounts. Two tenant connections. None of these can be expressed as two values of one type.
What to do instead: make them genuinely distinct types. Embed the shared client in two named wrappers, each constructed differently:
type Client struct{ Account string } // the shared thing
type OrdersAccount struct{ *Client } // two distinct graph nodes
type AuditAccount struct{ *Client }
Because the wrappers embed *Client, every method still comes through — no delegation boilerplate.
This is the intended answer rather than a workaround, and the README has a complete worked version.
You can’t depend on “every implementation of X”
There’s no way to ask for all providers of an interface as a slice. Plugin registries, middleware chains assembled from packages that don’t know about each other, and self-registering handler sets have no direct expression.
This is uber-go/fx’s clearest advantage over servo — it calls the feature value groups. See
How servo compares.
What to do instead: write one constructor that takes each implementation explicitly and returns the slice. It works, but you maintain that list by hand.
An override applies everywhere
servo.Override[I, C] is declared against an interface, not against a consumer. It replaces I
everywhere I is requested. You can’t hand one service a fake repository while another one keeps
the real thing.
You can’t reach a component from outside its package
The generated App has only unexported fields, and no accessors are emitted. There’s no Get[T]()
and no lookup by type — there’s no container to ask. Tests that touch the graph have to live in the
injector’s own package.
Narrow shapes
These are the rules servo applies when deciding whether a function can be a constructor. You’ll likely meet one or two early on.
A constructor must match one of four shapes
func F(deps...) T
func F(deps...) (T, error)
func F(deps...) (T, func()) // cleanup function
func F(deps...) (T, func(), error)
Anything else is rejected with does not match a supported result shape. In particular, a
constructor can’t produce two things at once — func NewPair() (*A, *B) isn’t a valid provider.
Variadic constructors are rejected
The options pattern — func New(opts ...Option) *T — cannot be a provider. A variadic parameter is
a slice underneath, and slices are never resolvable.
This one catches people, because the options pattern is common in Go. If a package you own uses it, add a plain constructor alongside for servo to find.
Generic functions are never providers
A function with type parameters can’t be a constructor, whatever its shape.
Only top-level functions count
Methods, function-typed package variables, and struct literals can never provide. It has to be a
top-level func declaration.
The result type must be named, a pointer to a named type, or a non-empty interface
Primitives, slices, arrays, maps, and any are all rejected as results. You can’t provide a bare
string for a connection URL, or an int for a port number.
What to do instead: wrap it in a named type — config.Port instead of int. This feels like
bureaucracy until you notice what it prevents: two unrelated string dependencies quietly
resolving to each other because they happen to share a type.
servo is the wrong tool if…
- You need two instances of the same type and can’t make them distinct types.
- You need to collect every implementation of an interface into a slice.
- Your graph is assembled dynamically, or varies by environment or feature flag.
- You need a genuinely fresh instance per call, with no sharing by key.
- You need to pull components out of a container at runtime.
- Your service has five components and a
main.goyou’re happy with. Write it by hand.
For the first three, uber-go/fx is the better tool, and
How servo compares says so directly. For the last one, nothing beats the code you
already have.
If something here is wrong
If a limitation on this page is out of date, or you hit a boundary it doesn’t name, open an issue at github.com/okian/servo/issues.
A limitation that’s written down is a trade-off you agreed to. One that isn’t is a bug in this page.