Skip to content

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.

audit.go
// 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) }
main.go
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.

gas.NewApp installs a middleware on the router that, for each request:

  1. creates a scope with NewScope(r.Context()),
  2. attaches it to the request context and calls the rest of the chain,
  3. 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.

Declare the scoped service as a parameter, and the router resolves it from that request’s scope before calling the handler:

orders.go
// 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.

Resolve from the request:

orders.go
// 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.

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:

job.go
// 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.

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.