14. Scoped instances
Every component in the last chapter’s graph is built once, in New, and held until the process
exits. That is the right answer for a connection pool, a JWT issuer, a metrics registry — anything
whose identity doesn’t depend on who is asking.
It is the wrong answer for anything that does. This chapter adds one such thing: a session, one per logged-in user, holding the orders that user has looked at recently. Two requests from Alice share hers; Bob’s is a different object; nobody’s outlives their being gone for five minutes.
The interesting part isn’t the feature. It’s that writing this by hand — a map, a mutex, a reference count, an eviction timer, and a teardown — is somewhere between fifty and a hundred lines of concurrency you’d have to get exactly right, and one specific way of getting it wrong is invisible until production. servo generates all of it, and refuses to compile the wrong version.
The mistake this prevents
Suppose you skip all of it and just inject a *session.Session into api.Server:
func New(..., sess *session.Session) *Server // don't
That compiles. It runs. Every test passes. And it is a cross-user data leak: api.Server is
constructed once, so it captures one session — whichever user’s happened to be built first — and
hands that same one to every request forever. Alice sees Bob’s recently-viewed orders. Nothing in
the running program says so.
Hold that thought; we’ll come back to it once there’s a scope to widen.
The key type
A scope is identified by a key type, and it has to be a defined type of your own:
// session/session.go
type UserID string
Not string. Scope identity is type identity, and if two unrelated scopes both keyed on string,
nothing in the generator could tell them apart.
The key gets into a request the same way the claims already do — in the auth middleware, at the one point where the user’s identity is first known:
// session/session.go
type ctxKey struct{}
func WithUser(ctx context.Context, id UserID) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}
// transport/api/middleware.go, inside requireAuth
ctx := context.WithValue(r.Context(), claimsKey, claims)
ctx = session.WithUser(ctx, session.UserID(claims.UserID.String()))
next(w, r.WithContext(ctx))
Two lines of transport code. servo ships no HTTP adapter on purpose — the moment it does, it stops being a codegen tool and starts being a framework.
The scoped type
// session/session.go
type Session struct {
id UserID
cfg *Config
mu sync.Mutex
recent []uuid.UUID
views int
}
func New(id UserID, cfg *Config, log *observability.Logger) *Session {
return &Session{id: id, cfg: cfg}
}
An ordinary constructor. It takes the key like any other dependency, and *Config like any
other singleton — and that difference is the whole of what servo needs to know. UserID varies per
user, so *Session is one per user. *Config doesn’t, so it stays one shared instance and
is not rebuilt fifty thousand times.
Nothing here is annotated. servo works it out from the dependency edges.
The extractor
One method turns a context into a key:
func (*Session) ScopeKey(ctx context.Context) (UserID, error) {
id, ok := ctx.Value(ctxKey{}).(UserID)
if !ok || id == "" {
return "", servo.ErrNoScopeKey
}
return id, nil
}
Two things about this signature are load-bearing, and both have a diagnostic behind them.
The receiver is unnamed. Generated code calls this on a typed nil — it has to, because the key
must be known before an instance can be chosen, so there is no instance to call it on. A receiver
the body could reach would be a nil dereference in production, and no signature can say “never
touches its receiver”. So servo checks: servo generate rejects a named receiver, and servo-vet
reports it in your editor. A blank _ receiver is accepted too, but staticcheck’s ST1006 flags it
and asks for exactly the form above.
It returns an error. Drop it, and a request with no key gets the zero UserID — and every
unauthenticated caller silently shares one session. That is the same cross-user leak as before,
arriving through a different door. servo.ErrNoScopeKey is the conventional error; any error will
do. (This is the one place a component may import servo, and even here it’s optional.)
The accessor interface
servo can’t emit a type into your package, so it can’t give api.Server something to depend on.
You declare that yourself:
// session/session.go
type Sessions interface {
Acquire(ctx context.Context) (*Session, func(), error)
Stats() servo.ScopeStats
}
Those are the only two methods the generated accessor has. Declare either, both, or — if you add a third — get a diagnostic at generate time rather than a type error inside a file you were told not to read.
The declaration
One marker, alongside the roots:
// cmd/orders/spec.go
servo.Build(
servo.Root[*api.Server](),
servo.Root[*notifier.Notifier](),
servo.Scoped[*session.Session, session.Sessions](
servo.Linger(5*time.Minute),
servo.Max(50_000),
),
// ... binds and overrides unchanged
)
Linger is how long a session survives its last holder. Without it, the reference count of a
short handler goes 0→1→0 per request and the session is rebuilt every time — which would make the
recently-viewed list permanently empty, since nothing ever survives to the next request. Five
minutes means a user browsing around keeps one session; a user who closes the tab loses it shortly
after.
Max caps the key space. Keys come from user input, so an uncapped scope is an allocation
primitive anyone can point at your heap. Past the cap, Acquire returns servo.ErrScopeFull
instead of allocating.
Both arguments must be constants — the spec file is read, never executed. Anything about a scope
that should be configurable therefore lives where every other setting does. Add one field to
this package’s own Config (chapter 3), which session.New reads:
// config/config.go
// Recent caps the per-user recently-viewed list. The linger
// window and instance cap for that scope are *not* here: both are
// baked into the generated code from servo.Scoped's arguments, which
// the spec file declares as constants.
Recent int `env:"RECENT" envDefault:"10"` // SESSION_RECENT, via envPrefix
Consuming it
api.Server takes the interface, never the instance:
type Server struct {
// ...
sessions session.Sessions
}
func New(..., sessions session.Sessions) *Server
and acquires per call:
func (s *Server) handleRecent(w http.ResponseWriter, r *http.Request) {
sess, release, err := s.sessions.Acquire(r.Context())
if err != nil {
writeError(w, http.StatusServiceUnavailable, "session unavailable")
return
}
defer release()
writeJSON(w, http.StatusOK, recentResponse{Recent: sess.Recent()})
}
with one more DTO beside the others in transport/api/dto.go:
type recentResponse struct {
Recent []uuid.UUID `json:"recent"`
}
and one more route, in api.New’s mux, behind the same requireAuth as everything else — which
is what puts the key in the context in the first place:
mux.HandleFunc("GET /me/recent", requireAuth(issuer, s.handleRecent))
Acquire, defer release(), use it. That defer is the reference unit — not the context.
Cancellation is not completion: a client disconnecting mid-handler cancels the context while your
defers are still running, and freeing the session there would pull it out from under them.
(Forgetting the release() isn’t fatal — a context.AfterFunc backstop releases when the request
ends. Later than ideal, but not never. Which is also why Acquire refuses a
context.Background(): with no Done channel that backstop can never fire.)
The diagnostic, on purpose
Now go back and make the mistake from the top of the chapter. Change api.Server’s field and
api.New’s parameter from session.Sessions to *session.Session, drop the two Acquire calls
in handlers.go for direct s.sessions.RecordView(...) / s.sessions.Recent(), and run
servo generate. It all compiles; servo refuses anyway:
example.com/servoorders/cmd/orders: servo: 1 diagnostic(s):
transport/api/server.go:49:6: servo: *example.com/servoorders/internal/session.Session is scoped, but *example.com/servoorders/internal/transport/api.Server is a singleton that depends on it
needed by *example.com/servoorders/internal/transport/api.Server transport/api/server.go:49:6
root cmd/orders/spec.go:23:3
A singleton is constructed once and held for the life of the process, so it
would capture whichever *example.com/servoorders/internal/session.Session happened to be built first and hand that same
one to every caller afterwards, whatever key they present. Nothing about the
running program would say so.
Two ways out:
- depend on the accessor instead: change api.New's parameter from *example.com/servoorders/internal/session.Session to example.com/servoorders/internal/session.Sessions,
and call Acquire(ctx) per request
- make *example.com/servoorders/internal/transport/api.Server scoped too, by giving it a dependency on example.com/servoorders/internal/session.UserID
(Absolute paths shortened. examples/diagnostics/widening is the same mistake as a permanently
broken fixture, if you’d rather see it without editing this module:
go run ./cmd/servo generate --dir examples/diagnostics/widening.)
That is the reason this feature is in the generator rather than in a library you import. A hand-written registry beside servo gives you the map and the timer; nothing gives you this.
Three more diagnostics guard the neighbouring mistakes — a nested scope, a ScopeKey whose own
dependencies are scoped, and a ScopeKey method with no servo.Scoped declaring it. All four are
in chapter 20 and in the
Scoped instances reference.
What came out
$ servo graph --dir examples/tutorial/cmd/orders
...
══ example.com/servoorders/internal/session.UserID ══
linger: 5m0s max: 50000
accessors: example.com/servoorders/internal/session.Sessions
borrows: *example.com/servoorders/internal/session.Config, *example.com/servoorders/internal/observability.Logger
── Scope level 1 ──
*example.com/servoorders/internal/session.Session
deps: example.com/servoorders/internal/session.UserID, *example.com/servoorders/internal/session.Config, *example.com/servoorders/internal/observability.Logger
capabilities: Initializer, Flusher, Finalizer
binding: sole candidate
pos: session/session.go:70:6
Read the last three lines of the scope header carefully, because they’re the whole model:
accessors— what consumers depend on.borrows—*session.Configand the logger are reached through the scope but aren’t part of it. One of each, shared by every session. Not fifty thousand loggers.- The member list is what one instance holds.
servo explain says the same thing per node:
$ servo explain --dir examples/tutorial/cmd/orders session.Session
*example.com/servoorders/internal/session.Session
provider: session.New (session/session.go:70:6)
binding: sole candidate
lifetime: scoped — one per example.com/servoorders/internal/session.UserID, linger 5m0s, max 50000
level: 1
depends on: example.com/servoorders/internal/session.UserID, *example.com/servoorders/internal/session.Config, *example.com/servoorders/internal/observability.Logger
depended on: (acquired via example.com/servoorders/internal/session.Sessions)
capabilities: Initializer, Flusher, Finalizer
Inside servo_gen.go there’s now a registry, an entry type, and an accessor:
type userIDScope struct {
app *App
mu sync.RWMutex
items map[session.UserID]*userIDEntry
// ...
}
type sessionsAccessor struct{ s *userIDScope }
func (x sessionsAccessor) Acquire(ctx context.Context) (*session.Session, func(), error) { ... }
func (x sessionsAccessor) Stats() servo.ScopeStats { ... }
It’s ordinary Go, in a file you can step through in a debugger. It is also the only concurrent thing servo generates, which is why it carries more comment than the rest of the output: the reader who ends up there is debugging a race, not skimming construction order.
Lifecycle, per instance
Session implements Init, Flush and Stop, and all three are wired — per session, not per
process:
| Phase | When |
|---|---|
New (the constructor) |
First Acquire of a user who has none |
Init |
Immediately after |
Flush |
Five minutes after the last request, once the linger window closes |
Stop |
After Flush |
Flush is where the session’s contents leave memory:
func (s *Session) Flush(context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
slog.Info("session: closed", "user", string(s.id), "views", s.views, "recent", len(s.recent))
return nil
}
The lock matters: eviction runs on the scope’s own goroutine, not on a request’s, so Flush reads
fields that a handler may still be writing.
In a real service that’s where you’d persist whatever should survive the session ending. Here it’s one line, which is enough to watch the lifecycle happen.
Two things are deliberately not wired. Health and Ready don’t cover scoped nodes — a
readiness report with one entry per logged-in user is not a report. And Run, if a scoped type had
one, runs on the instance’s own context rather than any acquirer’s, so one user disconnecting can’t
kill a session someone else is still in.
Try it yourself
cd examples/tutorial
make up # postgres, redis, nats
go run ./cmd/orders
In another terminal:
TOKEN=$(curl -s -XPOST localhost:8080/auth/login \
-d '{"username":"alice","password":"password123"}' | jq -r .token)
curl -s localhost:8080/me/recent -H "Authorization: Bearer $TOKEN"
# {"recent":[]}
ORDER=$(curl -s -XPOST localhost:8080/orders -H "Authorization: Bearer $TOKEN" \
-d '{"item":"widget","quantity":2}' | jq -r .id)
curl -s localhost:8080/orders/$ORDER -H "Authorization: Bearer $TOKEN" > /dev/null
curl -s localhost:8080/me/recent -H "Authorization: Bearer $TOKEN"
# {"recent":["<the order id>"]}
The second /me/recent reads state the first request created — with no database involved. Then
stop the service with Ctrl-C and watch the log:
{"time":"...","level":"INFO","msg":"session: closed","user":"...","views":1,"recent":1}
That’s Flush running as Shutdown tears the scope down.
Testing it
Two levels, and the split is worth noticing.
The session package on its own doesn’t need servo at all — it’s a struct with methods:
func TestScopeKeyReadsTheContext(t *testing.T) {
var zero *session.Session // called on a typed nil, exactly as generated code does
got, err := zero.ScopeKey(session.WithUser(context.Background(), "alice"))
if err != nil {
t.Fatalf("ScopeKey: %v", err)
}
if got != "alice" {
t.Fatalf("ScopeKey = %q, want alice", got)
}
if _, err := zero.ScopeKey(context.Background()); !errors.Is(err, servo.ErrNoScopeKey) {
t.Fatalf("ScopeKey with no key: err = %v, want servo.ErrNoScopeKey", err)
}
}
The API package needs something satisfying session.Sessions, and the generated accessor
lives in package main where api_test can’t reach it. So the test writes its own — about
thirty lines, keyed off the very same ScopeKey method:
type fakeSessions struct {
cfg *Config
mu sync.Mutex
by map[session.UserID]*session.Session
}
func (f *fakeSessions) Acquire(ctx context.Context) (*session.Session, func(), error) {
var zero *session.Session
key, err := zero.ScopeKey(ctx)
if err != nil {
return nil, nil, err
}
// ...one *Session per key, no refcount, no linger
}
That is the payoff of depending on the interface rather than the instance: api is testable with
no servo, no generated code, and no concurrency — and it still gets one session per user, because
the fake keys itself the same way the real one does.
For a test that needs the real scope, servotest.Linger(t, 0) shrinks every linger window to zero
so eviction happens the instant the last holder releases, instead of five minutes later.
Diagnostics
| Message | Cause | Fix |
|---|---|---|
X is scoped, but Y is a singleton that depends on it |
A singleton took the scoped type directly | Depend on the accessor interface; Acquire per request |
X.ScopeKey must not name its receiver |
func (s *Session) ScopeKey(...) |
Write func (*Session) ScopeKey(...) |
ScopeKey must return exactly (K, error) |
The error result was dropped | Put it back — see above for what it prevents |
ScopeKey's key type is string, which is not a defined type |
ScopeKey returns a bare string |
type UserID string, and return that |
X declares a ScopeKey method but no servo.Scoped declares it |
The method is there, the marker isn’t | Add the servo.Scoped[T, I] the message prints |
servo.Linger's argument must be a constant expression |
servo.Linger(cfg.Something) |
The spec is read, never run. Use a constant |
servo: scope is at its Max live-instance cap (at runtime) |
More live keys than Max |
Raise the cap, shorten the linger, or reject the traffic |
Do’s and don’ts
- Do put the key in the context at exactly one place — the middleware that authenticates the request. Two places is two chances for them to disagree about who the user is.
- Do
defer release()on the line afterAcquire, always. The backstop exists for the case where you forgot, not as an alternative to remembering. - Do pick
Lingerfrom how long a gap between two requests should still count as the same session. Zero is a real answer — it means “die with the last holder” — just not a default to fall into. - Don’t store an acquired instance in a struct field that outlives the call. That’s the widening bug by hand, in a place servo can’t see it: the check covers constructor parameters, not assignments you make afterwards.
- Don’t reach for a scope when an eager singleton would do. Two known regions, an A/B pair, a primary and a replica — those are distinct types and belong in the previous chapter’s model (see the README’s “Multiple instances of the same type”). A scope earns its keep when the key space is open and comes from outside.
- Don’t expect a scope to coordinate across processes. Two replicas means two sessions per user unless your routing is sticky. That’s documented, not solved.
Next
Chapter 15: Observability — structured logs, metrics, and tracing, now that there’s a fully wired app, sessions and all, to instrument.