SagaSaga
Edda

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

ShapeLives atUse when
Wrap functionfun outer inner = ...Uniform behavior for everything under it; cross-cutting; no per-route variation.
Opt-in effectwith { ... } at the boundaryA capability some routes need; varies by environment; you might want to swap it in tests.
Ambient contextwith { ... } inside the boundaryRequest-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) req

Edda 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 req

use_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 SecurityHeadersNone

For sub-apps or explicit wrapping, use use_security_headers.

Compression is also a wrap:

use_compression app req

use_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 req

Catches 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 console

Routes 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.