ADR-335: A Layer Is a Declared Value With Fixed Phases — before, after, observe — and No Layer Ever Runs the Handler; It Runs at Most Once, Under the Runtime Alone

Status: Accepted Date: October 8, 2026 (#121) Answers: how logging, compression, rate limits, CORS, request ids and timeouts wrap a tree of routes, without a layer controlling the handler Related: ADR-334 (a tree of routes is a comptime value); ADR-332 (extractors, typed inputs); ADR-326 D4 (a panic ends its task); ADR-327 (memory budgets); ADR-328 D8 (a scope cancels its tasks); ADR-297 (cleanup on the way out); Part III 17.1

1. Question

How does work that many routes share, and that hands the handler no value, wrap them - and what may such work do to the handler and to the response?

An extractor (AuthUser) runs only where a handler names it and never sees the response. Logging needs the status and the time taken, compression the body, a rate limit must refuse before the handler, CORS writes headers on every answer, a timeout must act while the handler runs.

2. How others do it

  Shape
Express, ASP.NET a function with next: before it, after it, or never
Go func(next http.Handler) http.Handler
axum / tower Layer and Service around the handler
Spring preHandle, postHandle, afterCompletion
Phoenix plugs, conn → conn, in a pipeline

From memory, not checked against a version. What transfers: Spring’s separate phases, and an afterCompletion that runs once the answer is sent.

3. Options, weighed

Option Why it lost
A wrapper with next (Express, tower) next may run the handler twice, never, or in a loop; what follows it delays every answer; a panic after it turns a done side effect into a 500 a client retries; the wrapper’s locals live while the handler waits
A wrapper limited to calling next once still opaque to the compiler: no parallel before, no header conflicts, no work moved past the send
A timeout written as a wrapper the runtime cancels a task already (ADR-328 D8); a declared timeout cannot forget to
Independent befores always concurrent a rate limit counts a request an authentication refuses at the same moment; the written order is often the intent
Every failure of an after a 500 compression that fails can send the body as it was

4. Decision

D1 — The handler runs at most once, never again, and only the runtime starts or cancels it.

No layer calls, skips or repeats it. It does not run when a before refuses, and a timeout cancels it as a scope cancels a task (ADR-328 D8), its cleanups running (ADR-297).

D2 — A layer is a value, declared on a tree; its kind implements http::Layer with any of three phases.

comptime APP = http::Routes()
    .mount("/api", api::ROUTES)
    .layer(http::Timeout(2.seconds()))
    .layer(http::Cors(origins: ["https://app.example"]))
    .layer(http::RateLimit(100.per_second(); key: ApiKey))
    .layer(http::Compress())
    .layer(http::AccessLog())
Phase May Runs
before(…typed inputs…) -> R throws Reject refuse, or answer itself; hand R to its after before the handler
after(r: R, response) -> Response change the response after the handler
observe(exchange: http::Exchange) read after the response is sent

A layer captures nothing, as a handler does not (ADR-334); its inputs are typed parameters, read as a handler’s are, State[T] among them. What before hands to after is its R, nothing else.

D3 — befores run in written order; concurrently only where nothing can tell.

Two befores run at once only where neither depends on the other’s result by its declared inputs, and either both touch nothing (sync, no effect) or the layer declares that its effect stands when another refuses. A refusal ends the befores that have not begun.

D4 — afters run one by one, innermost first; a layer declares how it fails.

A failing after that fails open lets the response pass as it was - a compression; one that fails closed turns it into a 500 - a signature. Once a response has begun to stream, an after is a transformation of the stream, and a failure after the first byte is reported and ends the connection; what was sent stays sent. A handler that fails or is cancelled has its error response pass through the afters, so CORS headers reach a refusal too.

D5 — observe is off the response’s path and cannot change it.

It runs after the send; its failure is reported and changes nothing. What waits to be observed is bounded by a budget (ADR-327); past it an observation is dropped and counted, never waited for. It sees the method, the path, the status, the time and the sizes - not the bodies.

D6 — The runtime’s own layers are std’s.

A timeout, a body’s size limit and a panic answered with 500 act on the runtime itself, and only std declares them.

D7 — A tree’s layers wrap its routes; an outer tree’s layers wrap an inner one’s.

.layer on a tree applies to every route in it and in the trees it mounts; the outer tree’s layers are outside the inner’s.

D8 — Layers are checked and described while the program is built.

Two afters that write the same header, and a layer whose inputs a route under it cannot give, are refused. A layer’s typed inputs appear in the API description always; the statuses it may answer with appear where the layer declares them.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.