Middleware wraps a handler to apply a policy to several requests: it can reject input, establish request state, or observe the result. Choose both its scope and its position. A wrapper sees only the handlers and middleware inside it, so an incorrect order can hide failures or leave work unprotected.
These examples use router from arkoren.dev/core/http/router and a fresh
router routes. Call Use before registering routes through that router or
its derived routers; a later call panics. Configuration alternatives below are
independent, not calls to append after earlier route examples.
Choose Scope
For behavior shared by every route, attach middleware to the router. Import
arkoren.dev/core/http/middleware/secureheaders:
routes.Use(router.Recover(), secureheaders.Middleware())
Recovery protects the layers inside it; secure headers apply to the requests
that reach that middleware. At router scope, unmatched 404 and 405 requests
also traverse the stack.
For a billing-only policy, group existing listInvoices and createInvoice
handlers under requireBilling, your router.Middleware:
routes.Group("/billing", func(billing *router.Router) {
billing.Use(requireBilling)
billing.Get("/invoices", listInvoices)
billing.Post("/invoices", createInvoice)
})
The group inherits parent middleware without adding its policy to unrelated
routes. To protect one registration, use With instead:
routes.With(requireBilling).Post("/invoices", createInvoice)
With leaves the parent’s stack unchanged. All handler and middleware captures
remain shared across concurrent requests; scoping a stack does not copy captured
services or mutable state.
Order Policies
Middleware runs in the order passed to Use: the first wrapper sees the
request first and the returned result last. For example, with existing
[]errormap.Mapping named mappings, import errormap, requestlog,
bodylimit, and contenttype from arkoren.dev/core/http/middleware/:
routes.Use(
errormap.Middleware(mappings...),
requestlog.Middleware(),
router.Recover(),
secureheaders.Middleware(),
bodylimit.Middleware(),
contenttype.Middleware(),
)
The mapper sees errors from all inner layers. Logging sees recovered errors; recovery sees panics from the policy middleware and handler. Body limits precede anything that reads the body. An outer logger in this arrangement sees the original error before the mapper replaces it; choose observation placement deliberately if you need mapped errors instead.
Place request tracing outside logging when logs need its trace context. Place CORS before authentication or body policies when preflights should terminate before those checks. A policy outside security headers can reject a request before the headers run, so choose that boundary consciously.
Write Policies
A router.Middleware takes a handler and returns a handler. Construction runs
when the chain is built, not per request. Keep request-local values inside the
returned function. This example uses net/http,
arkoren.dev/core/http/request, and arkoren.dev/foundation/problem:
func requireWorkspace(next router.Handler) router.Handler {
return func(w http.ResponseWriter, r *http.Request) error {
if request.Header(r, "X-Workspace") == "" {
return problem.BadRequest().Derive(func(details *problem.Builder) {
details.Detail("A workspace header is required.")
})
}
return next(w, r)
}
}
Register it before the routes it protects. Missing input returns a 400
problem without calling the handler; otherwise the handler’s error passes
outward. This checks presence only. Validate the workspace and enforce
authorization before using it as a trusted tenant.
A wrapper can inspect or replace the error from next. It cannot replace an
already committed response by returning a different error. For shared domain
translation, prefer error mapping.
Use Response Hooks
Header edits made after next can be too late. To add timing at the last safe
moment, register a hook before calling the handler. Inside a middleware’s
returned function, import fmt and time alongside the request package:
started := time.Now()
request.Hooks(r).BeforeWriteHeader(func(w http.ResponseWriter, _ int) {
w.Header().Set("Server-Timing",
fmt.Sprintf("app;dur=%d", time.Since(started).Milliseconds()))
})
return next(w, r)
BeforeWriteHeader runs once before the final status, not for informational
1xx except 101. A body write or flush can commit an implicit 200.
BeforeWrite observes an attempted body write, which may still fail afterward.
Do not record raw body contents merely to measure writes.
For work that observes handling completion, register an after-response callback.
With an existing application metrics function recordOutcome:
request.Hooks(r).AfterResponse(func(err error) {
recordOutcome(err)
})
Callbacks run in reverse registration order when router handling ends, including panic unwinding. Their argument is the handler’s returned error, not a delivery acknowledgment; an unrecovered panic can leave it nil. A hijacked connection can outlive these callbacks and remains the handler’s responsibility.
request.Hooks requires the context installed by router.Handler.ServeHTTP,
including normal routed requests. Outside that lifecycle it panics with
request.ErrNoHooksMiddleware; there is no non-panicking public lookup. Only
register hooks where wiring guarantees that lifecycle.
Preserve Context
Middleware can resolve application state with router.Provide. Here
workspaces.FromHost validates a hostname against trusted configuration and
withWorkspace derives a context containing that result. Import context:
routes.Use(router.Provide(func(w http.ResponseWriter, r *http.Request) (context.Context, error) {
workspace, err := workspaces.FromHost(r.Context(), r.Host)
if err != nil {
return nil, err
}
return withWorkspace(r.Context(), workspace), nil
}))
An error stops the chain. Derive successful contexts from r.Context(), not
Background or TODO; replacing the parent loses cancellation, lifecycle
hooks, and upstream adapter state. The same rule applies to custom middleware
calling next with a changed request. Static values can use ProvideStatic,
but mutable captured values still need concurrency-safe ownership.
Native Middleware
For existing func(http.Handler) http.Handler middleware named compress, use
the adapter:
routes.Use(router.HTTPMiddleware(compress))
Creating the adapter does not call compress. Its native constructor runs
once per wrapping, normally during registration, not once per request or once
globally. A single Methods or Any registration shares a chain across its
methods; separate registrations build separate chains. The unmatched-request
fallback builds one additional chain on first use and reuses it. Native handlers
and captures must therefore support concurrent use.
Returned errors are rendered inside native middleware while output remains uncommitted, so it can observe the error status and body. The error also returns outward for observation. To map that HTTP response, put the mapper inside the adapter:
routes.Use(
router.HTTPMiddleware(compress),
errormap.Middleware(mappings...),
)
An outer mapper still receives the returned error but cannot rewrite the adapter’s committed output. An inner mapper sees only the layers it wraps. A recovered error rendered before earlier output is not treated as a post-commit panic; an actual panic after earlier output still follows the abort rules.
The adapter tracks writes through next and commitment at enclosing native
boundaries, including wrappers hiding Unwrap. Output in a native wrapper’s
private buffer is not visible until forwarded through a tracked boundary.
Use the supplied writer for normal output; unwrapping it to write bypasses
tracking and hooks. Use http.ResponseController for capability operations,
and preserve the incoming context when forwarding requests.
Individual policies belong with their capabilities: Body Limits,
Content Types, secure headers,
rate limiting, and
authentication explain their setup and boundaries.
For composition and adapters, see the
router and
request API references.