Skip to content

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:

FamilyRuns WhenUse It For
Plugin lifecycleApplication setup, boot, and shutdownCreating infrastructure, registering providers, checking dependencies, flushing clients
Middleware methodsOne HTTP request, GraphQL operation, gRPC call, or queue deliveryAuth, request tracing, rate limits, tenant loading, route-local error shaping
Observers and eventsWhen code publishes an error or system eventTelemetry, 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.

Callback ShapeRuns PerRegistered WithHas Request DataCan Stop The Operation
OnBoot(ctx)Process startapp.OnBoot or plugin RegisterNoYes, by returning an error
OnShutdown(ctx)Process stopapp.OnShutdown or plugin RegisterNoIt can return an error, but shutdown has already started
OnError(ctx, event)Mapped failureapp.OnError or plugin RegisterOnly failure contextNo, it observes after mapping
EventBus().Subscribe(...)Published eventPlugin or app setupOnly the payloadNo, subscribers have no error return
BeforeHTTPHTTP request or WebSocket upgradesdk.Use[T]Yes, sdk.CtxYes, by returning an error
HandleHTTPHTTP request or WebSocket upgradesdk.Use[T]Yes, sdk.CtxYes, by not calling ctx.Next()
OnHTTPErrorDownstream HTTP errorsdk.Use[T]Yes, sdk.CtxYes, by returning nil or another error
AfterHTTPHTTP resultsdk.Use[T]Yes, sdk.CtxYes, by replacing body or error
HandleGraphQLGraphQL operation or subscriptionsdk.Use[T]Yes, sdk.GraphQLCtxYes, by not calling ctx.Next()
HandleGRPCgRPC unary or stream callsdk.Use[T]Yes, sdk.GRPCCtxYes, by not calling ctx.Next()
HandleQueueQueue deliverysdk.Use[T]Yes, sdk.QueueCtxYes, 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.

Use these rules when deciding where code belongs:

Code NeedsPut It In
A database client, S3 client, telemetry reporter, or other app dependencyProvider or plugin registration
A startup check before traffic is acceptedOnBoot
Cleanup after transports stopOnShutdown
Request actor, tenant, rate limit, cache decision, or route-local error handlingMiddleware
App-wide domain error classificationapp.ErrorPipeline().Use(...)
Logging or reporting after an error is already mappedOnError
Deterministic in-process fanoutEventBus().Publish(...)
Durable work with retry or broker semanticsQueue 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].

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:

NameMeaning
OnBootApplication startup callback before transports listen
OnShutdownApplication shutdown callback after transports stop
OnErrorFailure observer after a driver maps an error
BeforeHTTPHTTP setup phase that automatically continues on nil error
HandleHTTPHTTP wrapper phase that decides whether to call ctx.Next()
OnHTTPErrorHTTP error phase for downstream errors
AfterHTTPHTTP final phase for the downstream body and error
HandleGraphQLGraphQL operation or subscription wrapper
HandleGRPCgRPC unary or streaming call wrapper
HandleQueueQueue 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.

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.Ctx type 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.

A normal generated application moves through these steps:

  1. anvil.New(...) applies options.
  2. Driver options install protocol drivers.
  3. anvil.Use(plugin) calls plugin.Register(app) immediately.
  4. Plugins register providers, lifecycle hooks, error observers, event subscribers, or error mappers.
  5. app.Wire() runs generated wiring, resolves providers reached by generated components, and registers routes, services, jobs, and middleware metadata.
  6. app.Run(...) or app.Listen(...) finalizes the runtime.
  7. Anvil builds the shared HTTP-family edge when HTTP, GraphQL, or gRPC drivers are registered.
  8. Boot hooks run in registration order.
  9. Transports start.
  10. Requests, operations, RPC calls, and queue messages run through generated middleware and handlers.
  11. Context cancellation or a transport return starts shutdown.
  12. Transports shut down.
  13. 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.

For an HTTP request, the process looks like this:

  1. The shared edge listener accepts the connection.
  2. Anvil classifies the request as HTTP, GraphQL, or gRPC.
  3. The selected driver receives only the work for its protocol.
  4. The driver creates the protocol context.
  5. Generated middleware runs in route-tree order.
  6. Generated binding and validation run when the route has request input.
  7. The handler runs.
  8. The driver maps any error through the error pipeline.
  9. Error observers run if a failure was published.
  10. 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 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:

NeedUse
Verify database, Redis, S3, or telemetry before trafficOnBoot
Start an app-level background supervisorOnBoot
Flush logs, traces, metrics, or telemetry clientsOnShutdown
Close client pools or stop background workersOnShutdown
Add a dependency to generated DIRegisterProvider 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 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 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 PathMiddleware Method
HTTP routeBeforeHTTP, HandleHTTP, OnHTTPError, AfterHTTP
WebSocket upgrade routeHTTP middleware methods
GraphQL endpointHandleGraphQL
gRPC service methodHandleGRPC
Queue jobHandleQueue

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() 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:

Middlewarectx.Next() Returns
HTTPHandler body and error
WebSocketNil body and any upgrade or socket handler error
GraphQLsdk.GraphQLResponse and error
gRPCRPC response and error
QueueError 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 has four method shapes because HTTP middleware often needs one narrow part of the request:

MethodUse It When
BeforeHTTP(ctx sdk.Ctx) errorSetup 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) errorThe 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:

  1. BeforeHTTP, if the value implements it.
  2. HandleHTTP, if the value implements it. If it does not, Anvil continues to the next step automatically.
  3. OnHTTPError, if downstream returned an error and the value implements it.
  4. 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 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 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 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.

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.

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.

NeedAPI
Create app dependenciesanvil.WithProviders(...) or plugin RegisterProvider
Validate plugin configurationPlugin Register
Check infrastructure before trafficOnBoot
Close or flush infrastructureOnShutdown
Authenticate REST or WebSocket routesHTTP middleware
Authenticate GraphQL operationsHandleGraphQL
Authenticate gRPC callsHandleGRPC
Wrap queue deliveryHandleQueue
Normalize one HTTP route’s downstream errorOnHTTPError
Normalize app-wide domain errorsapp.ErrorPipeline().Use(...)
Report mapped failures to telemetryOnError
Publish synchronous in-process fanoutEventBus().Publish(...)
Run durable background workQueue driver

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.