Middleware
Edda doesn't have a Middleware type. What people call middleware in
other frameworks splits into three patterns here, each one falling out
of ordinary Saga features. The framework provides the primitives; you
compose them.
The three shapes
| Shape | Lives at | Use when |
|---|---|---|
| Wrap function | fun outer inner = ... | Uniform behavior for everything under it; cross-cutting; no per-route variation. |
| Opt-in effect | with { ... } at the boundary | A capability some routes need; varies by environment; you might want to swap it in tests. |
| Ambient context | with { ... } inside the boundary | Request-scoped values pulled by routes that opt in (current user, request ID, JWT claims). |
A real app uses all three: a wrap for logging or panic recovery, opt-in effects for DB and auth, ambient context for the request ID.
1. Wrap functions
A wrap takes a handler and returns a wrapped handler. Same shape going in as coming out, so they compose by ordinary function application.
fun with_logging : (Request -> Response needs {..e})
-> Request -> Response needs {Stdio, ..e}
with_logging inner req = {
println $"--> {method_str req.method} {req.original_path}"
let resp = inner req
println $"<-- {resp.status}"
resp
}Reach for a wrap when the behavior should be uniform across every request: logging, timing, gzip, CORS, panic recovery, security headers, body-size limits.
Wraps can short-circuit (don't call inner), modify the response, or
perform any effects of their own. Compose them with normal function
application:
with_logging (use_panic_recovery default_panic_response app) reqEdda provides a structured version of this pattern through
use_request_logging:
let app =
routes
|> use_panic_recovery default_panic_response
|> use_request_logging (fun event -> info! (show event))The application owns the logging effect and backend. Each RequestLog contains
the method, original path, response status, peer address, and handler duration.
Query strings, headers, and bodies are omitted to avoid leaking credentials or
personal data. handler_duration_ms measures response construction inside
Edda, not request upload, streamed response delivery, or other socket time.
Place request logging outside panic recovery, as above, to record recovered
500 responses.
For the two most common pure wrapping cases, Edda provides small helpers:
let app =
inner
|> use_request normalize_path
|> use_response (with_header "X-Frame-Options" "DENY")use_request rewrites the Request before the inner handler runs.
use_response rewrites the Response after the inner handler returns.
Edda ships use_cors as one of these wraps:
let cors = { default_cors_config |
allow_origins: ["https://app.example.com"],
allow_methods: [GET, POST],
allow_credentials: True,
expose_headers: ["X-Request-Id"],
}
use_cors cors app requse_cors handles preflight OPTIONS requests itself and adds
Access-Control-* headers to normal responses. With credentials enabled and a
wildcard origin policy, Edda echoes the request origin instead of emitting *.
Security headers are enabled by default on create_app. Apps can opt out or
install a custom policy at the app root:
create_app
|> use_security_headers_policy SecurityHeadersNoneFor sub-apps or explicit wrapping, use use_security_headers.
Compression is also a wrap:
use_compression app requse_compression negotiates Accept-Encoding, emits
Vary: Accept-Encoding, gzip-compresses eligible buffered responses, and wraps
eligible streamed responses. Use use_compression_with to tune thresholds and
policy.
Example: panic recovery
use_panic_recovery default_panic_response app reqCatches both Saga panic and native BEAM exceptions, so it's safe to
put at the top of an app.
2. Opt-in effect handlers
Routes declare the capabilities they need; the boundary provides the handlers. Different routes can declare different sets — capability- based routing falls out of the type system.
fun me : Request -> Response needs {Auth}
me _ = {
let user = current_user! ()
text 200 $"you are {user}"
}The Auth effect is just a regular Saga effect:
effect Auth {
fun current_user : Unit -> String
}Handlers can short-circuit by not calling resume:
app req = req |> choose [
route GET "/me" me,
route GET "/account" account,
] with {
current_user () = case find_header "authorization" req.headers {
Just "Bearer alice-token" -> resume "Alice"
_ -> text 401 "unauthorized" # no resume → 401
}
}Reach for opt-in effects when a capability is route-specific — some routes need DB access, some don't; some need auth, some are public; some emit domain events, some are pure.
Bracket pattern: code before and after resume
When the handler should do something around the route (timing,
tracing, holding a lock), capture resume's result and run code
before and after:
handler timing for Timing needs {Stdio} {
measure label = {
let start = Time.monotonic_ms ()
let result = resume ()
let elapsed = Time.monotonic_ms () - start
println $"[timing] {label}: {elapsed}ms"
result
}
}Routes opt in by calling measure! "label" somewhere in their body.
Typed errors via Fail
A common opt-in-effect use case is a domain-specific Fail effect
whose variants get mapped to HTTP statuses at the boundary — letting
routes read as happy-path code and keeping the status mapping in one
exhaustive handler. See the error handling guide
for the full pattern.
3. Ambient context handlers
Same shape as opt-in effects, but the handler is installed per request inside the user's boundary function, so it can close over the incoming request:
fun handle : Http.Request -> Response
handle hr = {
let req = from_http hr
let rid = gen_request_id ()
app req with {
request_id () = resume rid,
current_user () = case find_header "x-user" req.headers {
Just u -> resume u
Nothing -> text 401 "unauthorized"
},
}
} with consoleRoutes pull values from these handlers:
fun whoami : Request -> Response needs {ReqCtx}
whoami _ = {
let rid = request_id! ()
text 200 $"your request id: {rid}"
}Reach for ambient context when the value is request-scoped — current user, request id, parsed JWT claims, feature-flag set, anything that's "the same for this request, different across requests."
You can install the context at the root boundary, or at a mounted
sub-app boundary. The session demo uses this to parse the signed
session cookie once for /session and provide it through a local
effect:
effect SessionContext {
fun current_session_user : Unit -> Maybe String
}
fun secret : Request -> Response needs {SessionContext}
secret _ = case current_session_user! () {
Just user -> text 200 $"welcome {user}"
Nothing -> redirect 303 "/session"
}
fun app req =
choose [
route GET "/secret" secret,
] req with {
current_session_user () = resume (session_user req)
}This keeps Request lean: the parsed value is available to routes
that opt in, but it is not baked into every request record.
The smell test
- Should every request get this? → wrap function.
- Should the route declare it needs this? → opt-in effect.
- Does this value depend on the request itself? → ambient context.
The three compose freely. A real app might have wraps for
logging/timing/CORS, opt-in effects for DB and auth and a typed
Fail, and ambient context for the request ID and current user.