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 (with_panic_recovery app) req

Example: panic recovery

fun with_panic_recovery : (Request -> Response needs {..e})
                       -> Request -> Response needs {..e}
with_panic_recovery inner req = case catch_panic (fun () -> inner req) {
  Ok resp -> resp
  Err msg -> text 500 $"internal error: {msg}"
}

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'll write your own boundary function (instead of using to_handler) when you need this — Edda doesn't try to provide a one-liner for it, since the shape of per-request state varies too much app to app.

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.