Callbacks and Middleware
This page explains where callback code runs in an Anvil application: plugin lifecycle hooks, middleware methods, error observers, event subscribers, queue jobs, and normal handlers.
Anvil has three callback families. They run at different times and solve different problems:
| Family | Runs When | Use It For |
|---|---|---|
| Plugin lifecycle | Application setup, boot, and shutdown | Creating infrastructure, registering providers, checking dependencies, flushing clients |
| Middleware methods | One HTTP request, GraphQL operation, gRPC call, or queue delivery | Auth, request tracing, rate limits, tenant loading, route-local error shaping |
| Observers and events | When code publishes an error or system event | Telemetry, audit fanout, integration notifications |
The method names look similar because they are all Go callbacks, but they are
not interchangeable. OnBoot never runs inside a request. HandleHTTP never
runs during application startup. OnError observes a failure after the selected
driver has mapped it through the error pipeline.
Anvil uses “hook” only for process lifecycle callbacks such as OnBoot and
OnShutdown. Middleware methods are request-time contracts. They are named
methods on ordinary Go types, discovered by the compiler, and executed by the
selected driver only after Anvil has classified the request or delivery.
One-Screen Model
Section titled “One-Screen Model”| Callback Shape | Runs Per | Registered With | Has Request Data | Can Stop The Operation |
|---|---|---|---|---|
OnBoot(ctx) | Process start | app.OnBoot or plugin Register | No | Yes, by returning an error |
OnShutdown(ctx) | Process stop | app.OnShutdown or plugin Register | No | It can return an error, but shutdown has already started |
OnError(ctx, event) | Mapped failure | app.OnError or plugin Register | Only failure context | No, it observes after mapping |
EventBus().Subscribe(...) | Published event | Plugin or app setup | Only the payload | No, subscribers have no error return |
BeforeHTTP | HTTP request or WebSocket upgrade | sdk.Use[T] | Yes, sdk.Ctx | Yes, by returning an error |
HandleHTTP | HTTP request or WebSocket upgrade | sdk.Use[T] | Yes, sdk.Ctx | Yes, by not calling ctx.Next() |
OnHTTPError | Downstream HTTP error | sdk.Use[T] | Yes, sdk.Ctx | Yes, by returning nil or another error |
AfterHTTP | HTTP result | sdk.Use[T] | Yes, sdk.Ctx | Yes, by replacing body or error |
HandleGraphQL | GraphQL operation or subscription | sdk.Use[T] | Yes, sdk.GraphQLCtx | Yes, by not calling ctx.Next() |
HandleGRPC | gRPC unary or stream call | sdk.Use[T] | Yes, sdk.GRPCCtx | Yes, by not calling ctx.Next() |
HandleQueue | Queue delivery | sdk.Use[T] | Yes, sdk.QueueCtx | Yes, by not calling ctx.Next() |
The registration column matters. Lifecycle callbacks are attached to the app.
Middleware is attached to the generated route tree through sdk.Use[T].
Observers and event subscribers are attached during app setup and receive work
only after something else has happened.
Decision Rules
Section titled “Decision Rules”Use these rules when deciding where code belongs:
| Code Needs | Put It In |
|---|---|
| A database client, S3 client, telemetry reporter, or other app dependency | Provider or plugin registration |
| A startup check before traffic is accepted | OnBoot |
| Cleanup after transports stop | OnShutdown |
| Request actor, tenant, rate limit, cache decision, or route-local error handling | Middleware |
| App-wide domain error classification | app.ErrorPipeline().Use(...) |
| Logging or reporting after an error is already mapped | OnError |
| Deterministic in-process fanout | EventBus().Publish(...) |
| Durable work with retry or broker semantics | Queue driver |
Middleware method names are protocol contracts, not lifecycle hooks. A type
implements only the method it needs. BeforeHTTP means “run this setup before
an HTTP or WebSocket route continues.” HandleGRPC means “wrap one gRPC call.”
Those methods run when the type is attached with sdk.Use[T] and the selected
endpoint uses the matching protocol.
The practical question is: does this code belong to the process, one operation, or an event after the operation?
- Process-level work goes in lifecycle hooks.
- One-operation work goes in middleware.
- Already-mapped failures go to error observers.
- In-process fanout goes through the event bus.
- Durable work goes through a queue.
That split keeps the route tree readable. A request ID middleware should be visible near the routes that inherit it. A telemetry client should be registered once during application setup. A Sentry reporter should observe mapped errors after the selected driver has already built protocol context.
Use a handler for business work. Use middleware for operation policy. Use a
plugin when the concern installs infrastructure into the app. A plugin can
provide middleware types, but it does not make those middleware types run by
itself. Application route code still attaches them with sdk.Use[T].
Callback Names
Section titled “Callback Names”Anvil has no Around method and no separate next parameter. The generated
continuation is always ctx.Next(), and it exists only while middleware is
running.
These are the callback names Anvil recognizes:
| Name | Meaning |
|---|---|
OnBoot | Application startup callback before transports listen |
OnShutdown | Application shutdown callback after transports stop |
OnError | Failure observer after a driver maps an error |
BeforeHTTP | HTTP setup phase that automatically continues on nil error |
HandleHTTP | HTTP wrapper phase that decides whether to call ctx.Next() |
OnHTTPError | HTTP error phase for downstream errors |
AfterHTTP | HTTP final phase for the downstream body and error |
HandleGraphQL | GraphQL operation or subscription wrapper |
HandleGRPC | gRPC unary or streaming call wrapper |
HandleQueue | Queue delivery wrapper |
HandleHTTP, HandleGraphQL, HandleGRPC, and HandleQueue are middleware
methods, not endpoint naming conventions. Endpoint handlers keep their own
protocol shape: HTTP route handlers return (body, error), GraphQL endpoints
implement Execute and optional Subscribe, gRPC services use normal RPC
method names, and queue jobs implement Handle.
The compiler treats those names as protocol contracts. It does not infer
middleware from type names, package names, comments, or framework-specific
interfaces. A type with HandleGRPC participates in gRPC chains. A type with
BeforeHTTP participates in HTTP and WebSocket chains. A type with neither is
not middleware, even if its name ends in Middleware.
HTTP has separate phases because HTTP middleware often needs only one thing:
set up request state, branch the request, normalize errors, or observe the
final result. GraphQL, gRPC, and queue jobs each use one wrapper method because
their response and streaming models are protocol-specific. Put setup code
before ctx.Next() and post-work after it returns.
When a type implements methods for more than one protocol, Anvil still builds
separate chains. HTTP routes receive only the HTTP methods, GraphQL endpoints
receive only HandleGraphQL, gRPC methods receive only HandleGRPC, and queue
jobs receive only HandleQueue. Use that feature for shared library concerns
such as company telemetry. In application route files, separate middleware
types are usually easier to audit.
Why ctx.Next() Lives On The Context
Section titled “Why ctx.Next() Lives On The Context”Anvil keeps next on the protocol context instead of passing it as a separate
function parameter. That keeps every middleware method to one argument, and it
keeps the continuation attached to the operation it belongs to.
The driver installs ctx.Next() immediately before it calls a middleware
method and restores the previous continuation immediately after the method
returns. Outside that middleware call there is no continuation installed.
That design has three concrete effects:
- Middleware can continue the generated chain without carrying another function through every signature.
- Handlers can share the same
sdk.Ctxtype as HTTP middleware without being middleware themselves. - A middleware invocation can call
ctx.Next()once. A second call returns an internal failure instead of running the handler twice.
Full Application Flow
Section titled “Full Application Flow”A normal generated application moves through these steps:
anvil.New(...)applies options.- Driver options install protocol drivers.
anvil.Use(plugin)callsplugin.Register(app)immediately.- Plugins register providers, lifecycle hooks, error observers, event subscribers, or error mappers.
app.Wire()runs generated wiring, resolves providers reached by generated components, and registers routes, services, jobs, and middleware metadata.app.Run(...)orapp.Listen(...)finalizes the runtime.- Anvil builds the shared HTTP-family edge when HTTP, GraphQL, or gRPC drivers are registered.
- Boot hooks run in registration order.
- Transports start.
- Requests, operations, RPC calls, and queue messages run through generated middleware and handlers.
- Context cancellation or a transport return starts shutdown.
- Transports shut down.
- Shutdown hooks run in reverse registration order.
Run does not call Wire for you. Generated applications wire first, then
start listening.
The important boundary is that generated wiring finishes before traffic starts. Providers are resolved, routes are registered, middleware metadata is attached, and drivers have received their generated route, service, GraphQL, WebSocket, or queue descriptors before the listener accepts requests.
One Request Walkthrough
Section titled “One Request Walkthrough”For an HTTP request, the process looks like this:
- The shared edge listener accepts the connection.
- Anvil classifies the request as HTTP, GraphQL, or gRPC.
- The selected driver receives only the work for its protocol.
- The driver creates the protocol context.
- Generated middleware runs in route-tree order.
- Generated binding and validation run when the route has request input.
- The handler runs.
- The driver maps any error through the error pipeline.
- Error observers run if a failure was published.
- The driver writes the protocol response.
For queue jobs, there is no edge listener. The queue driver receives a broker
message, converts it into sdk.QueueMessage, runs the generated queue
middleware chain, and then runs the job handler. Ack, retry, delete, or nack
behavior stays in the selected queue driver.
For lifecycle hooks, there is no request at all. OnBoot runs before
transports start. OnShutdown runs after transports stop. Those hooks should
not depend on sdk.Ctx, route params, locals, GraphQL operations, gRPC
methods, or queue messages because none of that exists during process startup
or shutdown.
Lifecycle Hooks
Section titled “Lifecycle Hooks”Lifecycle hooks are application-level callbacks. They are registered through the app or through plugins:
app.OnBoot(func(ctx context.Context) error { return db.PingContext(ctx)})
app.OnShutdown(func(ctx context.Context) error { return telemetry.Flush(ctx)})Use lifecycle hooks for process-level work:
| Need | Use |
|---|---|
| Verify database, Redis, S3, or telemetry before traffic | OnBoot |
| Start an app-level background supervisor | OnBoot |
| Flush logs, traces, metrics, or telemetry clients | OnShutdown |
| Close client pools or stop background workers | OnShutdown |
| Add a dependency to generated DI | RegisterProvider during plugin Register |
Request auth, route policies, tenant loading, and response shaping belong in middleware because they depend on one request, operation, RPC call, or queue message.
Boot hooks run before transports start. If a boot hook returns an error,
startup fails before the app accepts traffic. In that case transports have not
started, so shutdown hooks are not run by Run.
Shutdown hooks run after transport shutdown, in reverse registration order. If
shutdown starts because the run context was canceled, Anvil calls shutdown with
context.WithoutCancel(ctx) so cleanup code can still read context values.
Plugin Registration
Section titled “Plugin Registration”Plugin registration is not a boot hook. Register runs when the plugin is
installed:
type Plugin interface { Name() string Register(app sdk.AppLifecycle) error}Use Register to validate plugin configuration and attach work to the app:
func (p TelemetryPlugin) Register(app sdk.AppLifecycle) error { if p.Reporter == nil { return errors.New("telemetry reporter is required") }
app.OnBoot(p.Reporter.Start) app.OnShutdown(p.Reporter.Flush) app.OnError(p.Reporter.Capture) return nil}If plugin configuration is invalid, return an error from Register. Panics are
bugs, not configuration flow.
Plugins register providers before app.Wire():
func (p S3Plugin) Register(app sdk.AppLifecycle) error { store, err := NewStore(p.Options) if err != nil { return err }
return app.RegisterProvider(anvil.As[*Store](store))}After wiring has completed, RegisterProvider returns an error because the
generated dependency graph has already been resolved.
Middleware Methods
Section titled “Middleware Methods”Middleware methods run inside one selected protocol path. They are attached
with sdk.Use[T] on groups or policy types:
type ReadPolicy struct { _ sdk.Use[RequireActor] _ sdk.Use[RequestTrace]}
type Routes struct { Get sdk.GETWith[ReadPolicy] `path:"/:projectId"`}Anvil splits middleware by protocol before registration:
| Protocol Path | Middleware Method |
|---|---|
| HTTP route | BeforeHTTP, HandleHTTP, OnHTTPError, AfterHTTP |
| WebSocket upgrade route | HTTP middleware methods |
| GraphQL endpoint | HandleGraphQL |
| gRPC service method | HandleGRPC |
| Queue job | HandleQueue |
A type can implement methods for more than one protocol, but application code
is easier to audit when shared concerns use separate types such as
HTTPTrace, GraphQLTrace, RPCTrace, and QueueTrace.
Use one multi-protocol type only when the same library-owned concern truly
needs to span protocols. For example, a company telemetry package may expose
one Trace type with HTTP, GraphQL, gRPC, and queue methods. In application
code, separate types are usually easier to read because the method signature
shows which protocol context is available.
sdk.Use[T] is placement, not execution by itself. The method set of T
decides which protocol chain can use that placement. If a group contains
sdk.Use[RPCTrace], HTTP routes below that group receive it only if RPCTrace
also implements an HTTP middleware method. If a GraphQL endpoint is under the
same group, it receives only middleware values that implement
HandleGraphQL.
ctx.Next()
Section titled “ctx.Next()”ctx.Next() is the generated continuation for middleware. It runs the next
middleware value. When no middleware is left, it runs the handler.
Handlers return their normal protocol result. HTTP handlers return
(body, error). GraphQL endpoints return sdk.GraphQLResponse. gRPC and queue
handlers return their normal protocol results.
Each middleware invocation can call ctx.Next() once. Calling it twice returns
an internal failure. Calling it outside middleware also returns an internal
failure because there is no continuation installed.
The return value belongs to the protocol:
| Middleware | ctx.Next() Returns |
|---|---|
| HTTP | Handler body and error |
| WebSocket | Nil body and any upgrade or socket handler error |
| GraphQL | sdk.GraphQLResponse and error |
| gRPC | RPC response and error |
| Queue | Error only |
If middleware returns before calling ctx.Next(), the chain stops at that
middleware. That is intentional control flow for auth failures, cached
responses, rate limits, maintenance mode, rejected GraphQL operations, rejected
RPC calls, and queue delivery guards.
HTTP Middleware
Section titled “HTTP Middleware”HTTP has four method shapes because HTTP middleware often needs one narrow part of the request:
| Method | Use It When |
|---|---|
BeforeHTTP(ctx sdk.Ctx) error | Setup should always continue when it succeeds |
HandleHTTP(ctx sdk.Ctx) (any, error) | The middleware decides whether the route continues |
OnHTTPError(ctx sdk.Ctx, err error) error | The middleware only wants downstream errors |
AfterHTTP(ctx sdk.Ctx, body any, err error) (any, error) | The middleware needs the final body or error |
For one middleware value, the HTTP driver runs methods in this order:
BeforeHTTP, if the value implements it.HandleHTTP, if the value implements it. If it does not, Anvil continues to the next step automatically.OnHTTPError, if downstream returned an error and the value implements it.AfterHTTP, if the value implements it.
Use BeforeHTTP for request IDs, tenant lookup, cheap preflight checks, and
headers that every handler should return:
func (RequestTrace) BeforeHTTP(ctx sdk.Ctx) error { requestID := ctx.Request().Header("X-Request-ID") if requestID == "" { requestID = newRequestID() }
ctx.Locals().Set("requestID", requestID) ctx.Response().Header("X-Request-ID", requestID) return nil}Use HandleHTTP when the middleware controls the branch:
func (RequireActor) HandleHTTP(ctx sdk.Ctx) (any, error) { token := ctx.Request().Header("Authorization") if token == "" { return nil, ctx.Errors().Failure(http.StatusUnauthorized, "missing authorization") }
ctx.Locals().Set("actor", parseActor(token)) return ctx.Next()}Returning without calling ctx.Next() short-circuits the route. That is useful
for auth failures, cached responses, maintenance mode, and rate limits.
Use OnHTTPError for route-local error normalization:
func (NormalizeHTTPError) OnHTTPError(ctx sdk.Ctx, err error) error { if domain.IsArchived(err) { return ctx.Errors().Failure(http.StatusPreconditionFailed, "project is archived") } return err}Use AfterHTTP for audit, metrics, or response shaping:
func (AuditTrail) AfterHTTP(ctx sdk.Ctx, body any, err error) (any, error) { audit.Record(ctx.Context(), ctx.Request().Path(), body, err) return body, err}WebSocket routes use the same HTTP middleware because a WebSocket starts as an
HTTP upgrade. For a WebSocket route, ctx.Next() performs the upgrade and runs
the socket handler. Code before ctx.Next() runs before the upgrade. Code
after it runs after the upgrade attempt or socket handler returns.
GraphQL Middleware
Section titled “GraphQL Middleware”GraphQL middleware has one method:
func (GraphQLTrace) HandleGraphQL(ctx sdk.GraphQLCtx) (sdk.GraphQLResponse, error) { response, err := ctx.Next() if err != nil { return response, err }
if response.Extensions == nil { response.Extensions = map[string]any{} } response.Extensions["trace"] = "enabled" return response, nil}Use it for GraphQL-specific auth, operation tracing, subscription policy, and response extension shaping.
GraphQL does not use HTTP middleware. The request arrives over HTTP, but Anvil
dispatches it to the GraphQL driver by path before normal HTTP fallback. The
GraphQL driver then runs HandleGraphQL middleware around Execute or
Subscribe.
Use sdk.GraphQLEndpointWith[Policy] for endpoint-specific middleware:
type ProjectGraph struct { sdk.GraphQLEndpointWith[GraphPolicy] `path:"/graphql"`}
type GraphPolicy struct { _ sdk.Use[GraphQLTrace]}gRPC Middleware
Section titled “gRPC Middleware”gRPC middleware has one method:
func (RPCTrace) HandleGRPC(ctx sdk.GRPCCtx) (any, error) { method := ctx.FullMethod() kind := ctx.StreamKind() _ = method _ = kind
return ctx.Next()}Use it for interceptor-style application concerns: auth, deadline policy, telemetry, stream accounting, and domain error normalization.
Native gRPC interceptors still belong in grpc.ServerOption values passed to
the gRPC driver. Anvil middleware runs inside the generated service method
after the gRPC request has reached Anvil.
Attach gRPC middleware to a parent group:
type V1 struct { sdk.Group `path:"/v1"` _ sdk.Use[RPCTrace]
ProjectService *rpc.ProjectService}For unary and server-streaming calls, ctx.Request() contains the decoded
request message. For client-streaming and bidirectional streaming calls,
ctx.Request() is nil and middleware reads from ctx.Stream() only when it
intentionally needs raw stream access.
Queue Middleware
Section titled “Queue Middleware”Queue middleware has one method:
func (QueueTrace) HandleQueue(ctx sdk.QueueCtx) error { message := ctx.Message() _ = message
return ctx.Next()}Use it for delivery logging, tenant loading, idempotency, retry metadata, and dead-letter preparation.
Queue middleware runs after the selected queue driver has converted the broker
message into sdk.QueueMessage. It is not a broker ack callback. Ack, nack,
visibility timeout, retry, and delete behavior stay in the selected queue
driver.
Error Observers
Section titled “Error Observers”OnError observes mapped failures:
app.OnError(func(ctx context.Context, event sdk.ErrorEvent) { if event.Expected { return }
reporter.Capture(ctx, event.Error, event.Failure.Context)})Use OnError for telemetry, logging, Sentry, Datadog, or Honeycomb. It runs
after the error pipeline maps the error to sdk.Failure and attaches route
context.
Observers run synchronously in registration order. They have no error return. Anvil does not recover observer panics. Keep observer code short, add timeouts around external calls, and recover inside the observer when the reporter can panic.
Use middleware when one route needs local error handling. Use a custom
sdk.ErrorMapper when the whole app has a domain error type that should map
the same way everywhere. Use OnError when the failure has already been mapped
and you only need to observe it.
OnError is not a retry mechanism and it does not change the response. By the
time an observer runs, the selected driver already has the normalized failure
it will encode for the protocol.
Event Bus Subscribers
Section titled “Event Bus Subscribers”The event bus is in-process fanout:
app.EventBus().Subscribe("project.created", func(payload any) { created, ok := payload.(ProjectCreated) if !ok { return }
audit.Record(created)})Publishing is synchronous on the publishing goroutine. Handlers run in subscription order. The bus does not clone payloads and does not recover subscriber panics.
Use the event bus for plugin fanout, boot events, and integration points where synchronous backpressure is acceptable. Use a queue driver when work needs broker-backed retry, durability, or independent worker throughput.
Which One Should I Use?
Section titled “Which One Should I Use?”| Need | API |
|---|---|
| Create app dependencies | anvil.WithProviders(...) or plugin RegisterProvider |
| Validate plugin configuration | Plugin Register |
| Check infrastructure before traffic | OnBoot |
| Close or flush infrastructure | OnShutdown |
| Authenticate REST or WebSocket routes | HTTP middleware |
| Authenticate GraphQL operations | HandleGraphQL |
| Authenticate gRPC calls | HandleGRPC |
| Wrap queue delivery | HandleQueue |
| Normalize one HTTP route’s downstream error | OnHTTPError |
| Normalize app-wide domain errors | app.ErrorPipeline().Use(...) |
| Report mapped failures to telemetry | OnError |
| Publish synchronous in-process fanout | EventBus().Publish(...) |
| Run durable background work | Queue driver |
Short Version
Section titled “Short Version”Lifecycle hooks are for the process. Middleware methods are for one selected protocol operation. Error observers and event subscribers are for fanout after something has already happened.
If code needs request data, it belongs in middleware or a handler. If code needs to prepare or close infrastructure, it belongs in lifecycle hooks. If code only needs to hear about an error or event, use an observer or event subscriber.