Request scopes
A scoped service has one instance per gas.Scope. An App opens a scope for
every HTTP request, so a scoped service is effectively per-request: built the
first time something in the request asks for it, shared by everything else in
that request, and closed when the request ends.
That is the right lifetime for per-request state: a buffer flushed at the end of the request, a transaction that spans one request, a logger carrying the request’s ID. Each request gets its own instance, and its cleanup runs without the handler having to remember it.
A scoped service
Section titled “A scoped service”// Audit buffers what happens during one request and flushes it when the// request ends. It is registered as scoped, so every request gets its own.type Audit struct { sink *Sink lines []string}
// NewAudit may depend on singletons, such as the sink.func NewAudit(sink *Sink) *Audit { return &Audit{sink: sink} }
func (a *Audit) Name() string { return "audit" }
// Init runs on first resolution in the scope, with the scope's context and// the container's init deadline.func (a *Audit) Init(context.Context) error { return nil }
// Close runs when the scope closes: in an App, after the response is written.func (a *Audit) Close(context.Context) error { a.sink.Write(a.lines) return nil}
func (a *Audit) Record(line string) { a.lines = append(a.lines, line) }func newApp() *gas.App { return gas.NewApp( gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()), gas.WithSingletonService[*Sink](NewSink), gas.WithScopedService[*Audit](NewAudit), gas.WithSingletonService[*Orders](NewOrders), )}A scoped service implements Service like any other when it has something to
set up or release. Its Init runs on its first resolution in the scope, under
the same init deadline as a singleton’s (in an App its context is the
request’s), and its Close runs when the scope closes.
A scoped type that declares neither method is simply built once per scope.
A scoped constructor can depend on singletons and on other scoped services. The
reverse is not allowed: a singleton that depends on a scoped service fails
startup with gas.ErrCaptiveDependency, because it would capture the first
request’s instance and hand it to every request after it. Take the scoped
dependency in the handler instead; see
Services and DI.
How an App scopes requests
Section titled “How an App scopes requests”gas.NewApp installs a middleware on the router that, for each request:
- creates a scope with
NewScope(r.Context()), - attaches it to the request context and calls the rest of the chain,
- closes the scope after the chain returns, with the request’s context minus its cancellation, so cleanup still runs after the client has disconnected.
The middleware is installed when the App is constructed, before any service’s
Init runs, so every middleware a service adds with Use runs inside the
scope. The scope closes even if the handler panics.
CSRF protection wraps the router from outside, so a request it rejects never
gets a scope.
Scope.Close closes the scoped services in the reverse of the order they
were resolved, so a scoped service can still use the scoped dependencies it
was built from while it closes. A failing Close does not stop the others; the
errors are joined. By then the response has been written, so the middleware
logs the joined error as request scope close failed, with the request’s method
and path, instead of returning it.
Getting the instance
Section titled “Getting the instance”In a DI-aware handler
Section titled “In a DI-aware handler”Declare the scoped service as a parameter, and the router resolves it from that request’s scope before calling the handler:
// A DI-aware handler declares the scoped service as a parameter, and the// router resolves it from this request's scope.func (s *Orders) create(ctx gas.Context, audit *Audit) error { audit.Record("order created") return ctx.NoContent()}If a parameter fails to resolve at request time, for example because a scoped constructor returned an error, the handler is not called and the error goes to the router’s error handler; the default one renders it as a 500. Whether each parameter type is registered at all is checked at startup. See Handlers and Context.
In a plain handler or middleware
Section titled “In a plain handler or middleware”Resolve from the request:
// A plain http.HandlerFunc resolves from the request instead.func (s *Orders) refund(w http.ResponseWriter, r *http.Request) { audit, err := gas.ResolveFromRequestScope[*Audit](r) if err != nil { _ = gas.WriteError(w, r, err) return } audit.Record("refund issued") w.WriteHeader(http.StatusNoContent)}| Helper | Returns |
|---|---|
gas.ResolveFromRequestScope[T](r) |
(T, error) |
gas.MustResolveFromRequestScope[T](r) |
T, and panics if it cannot resolve |
gas.RequestScope(r) |
The request’s *gas.Scope, to resolve several values from it |
All three panic if the request carries no scope, which is the case for a request that did not come through an App’s router.
Within a scope, a scoped type resolves to the scope’s instance, a singleton resolves to the container’s instance, and a transient is built fresh each time.
Outside HTTP
Section titled “Outside HTTP”A background job, a queue consumer, or a test can make a scope of its own. The
context passed to NewScope is the one each scoped service’s Init receives,
and closing the scope is your job:
// runJob gives one background job its own scope, as a request would get.func runJob(ctx context.Context, c *gas.ServiceContainer) (err error) { scope := c.NewScope(ctx) defer func() { if cerr := scope.Close(ctx); cerr != nil && err == nil { err = cerr } }()
audit, err := scope.Resolve[*Audit]() if err != nil { return err } audit.Record("nightly job ran") return nil}Resolving a scoped service from the container itself, rather than from a
scope, fails with an error that wraps gas.ErrScopeRequired:
scoped service *main.Audit cannot be resolved outside a scope; use container.NewScope(ctx)To run code that expects a request scope, attach a scope to a context with
gas.WithRequestScope(ctx, scope). gas.RequestScope(r) finds it on any
request built with that context, which is how a worker or a test can call
handler code without an App serving the request.
Choosing a lifetime
Section titled “Choosing a lifetime”Use scoped when an instance carries per-request state or must be cleaned up
per request. Use singleton for anything stateless or expensive to build,
which is most infrastructure. Use transient for small value objects that
must be fresh on every resolution and own no resources: a transient cannot
implement Service, so nothing ever closes it.
The per-request logger is a common scoped type; Logging
shows how gas.RequestLogger fills it.