SagaSaga
Guide

Effects & Handlers

An effect is a capability a function uses — logging, fetching from the network, error handling — without specifying how it's actually carried out. The function declares the effect it needs, and a handler, supplied by the caller, provides the implementation.

If you've worked with dependency injection in other languages, the basic shape will feel familiar:

  • An effect is like an interface: a named set of operations.
  • A handler is a concrete implementation of that interface.
  • The caller of the operation chooses which implementation to use.

With effects, dependency injection is just a pattern — and the compiler checks the wiring for you. You decide which handlers to use at each function call, or even at your application's entry point, and every operation is guaranteed a handler before the program runs.

Handlers control the continuation

Effects go further than DI in one important way. When a function calls an effect operation like log!, control doesn't just call the handler — it hands the handler the code that would run next, as a continuation. The handler can decide what to do with it:

  • Resume it once: behaves like a normal function call with a return value
  • Don't resume it: the rest of the code never runs, like an early return or error handling
  • Resume it after a delay: retries, backoff, supervision
  • Resume it multiple times: backtracking, nondeterminism, generators

This is one of the features that makes effects a powerful abstraction. The same mechanism that allows you to swap behavior in and out also expresses error handling, concurrency, supervision, and even constraint solvers.

Why you'd reach for an effect

  • Testing without mocks. Provide a different handler in tests; the function under test is unchanged.
  • Per-environment swapping. Dev runs against SQLite, prod against Postgres, with one handler choice at main.
  • Ambient context. Per-request values like the current user, request ID, or feature flags can be set up once at the request boundary instead of threaded through every function argument. This subsumes the Reader monad and request-scoped DI containers — see Ambient context for a worked example.
  • Non-local control flow. Errors that propagate without manual forwarding. Backtracking search. Guaranteed cleanup. Retries.

It's effects all the way down

Because effects are this useful, Saga leans on them everywhere. The standard library exposes the file system, console, HTTP, time, randomness, BEAM concurrency, and even the testing framework as effects, and ships with the handlers you'd expect for each. Once you understand the model in this section, you understand the shape of the whole stdlib.

This is why function signatures stay honest: any effect a function performs but doesn't handle internally appears in its needs clause. You can see at a glance which handlers it will require from its caller.

What you get

  • Signatures that tell the truth. No hidden I/O, no surprise side effects.
  • No monad transformers. Effects compose by listing them in needs. No lifting between layers, no stack ordering.
  • Test isolation is just handler swapping. No mocking libraries, no patching imports.
  • Patterns that need a dedicated library elsewhere are a few lines of user code here. Scoped resources, async/await, and supervision all fall out of the same handler machinery.

The rest of this section covers how to declare effects, write handlers, and wire them up. Error Handling, Handler Patterns, and Advanced Effects build on this foundation.

Declaring an effect

An effect is a named group of operations, defined with signatures but no implementation:

effect Log {
  fun log : String -> Unit
}

This says "there is an operation called log that takes a String and returns Unit." It does not say what log actually does. That is the handler's job.

Effects can have multiple operations:

effect Http {
  fun get : Request -> Response
  fun post : Request -> Response
}

Performing effects

Effect operations are called with ! at the call site:

log! "server starting"
let resp = get! health_check

The ! marks the exact point where control may transfer to a handler. Pure function calls never get it. If you see a !, you know something beyond ordinary computation is happening.

Only operations declared directly in an effect block use !. Calling a function that internally uses effects is a normal call:

# log! uses the bang because it is an effect operation
# process_request does not, even though it uses effects internally
fun process_request : Request -> Response needs {Log, Http}
process_request req = {
  log! "handling request"
  let data = get! req.url
  parse_response data
}

# Calling process_request is a regular call
process_request my_request

The needs clause

A function that performs effects without handling them itself must declare them with needs:

fun greet : String -> Unit needs {Log}
greet name = log! $"Hello, {name}!"

This tells callers (and the compiler) what handlers must be provided. If a function calls another function that needs an effect without handling it, that effect propagates to the caller's signature:

# greet needs {Log}, and we don't handle it here, so run also needs {Log}
fun run : Unit -> Unit needs {Log}
run () = greet "world"

Effects propagate all the way up the call chain until someone provides a handler. In some instances, you may want effects to bubble all the way up to your main function, where you wire in their handlers:

fun main : Unit -> Unit
main () = {
  run_app ()
} with {
  console,
  http,
  postgres,
  to_result,
}

Writing a handler

A handler provides implementations for an effect's operations. The simplest form is a named handler declaration:

handler console for Log needs {Stdio} {
  log msg = {
    print! msg
    resume ()
  }
}

Three things to notice:

  1. The handler specifies which effect it implements with for Log.
  2. Each arm implements one operation. The arm for log receives msg (the argument from the call site) and decides what to do with it.
  3. This handler needs {Stdio} because its implementation calls print!. Handlers can require effects of their own, covered in more detail below.

resume: continuing the computation

resume is a keyword available inside any handler arm. It sends a value back to the point where the effect was performed, and the computation continues from there.

In the console handler above, resume () means: "the call to log! returns Unit, and the code after log! keeps running."

Here is a more illustrative example:

effect Env {
  fun get_env : String -> Maybe String
}

handler dev_env for Env {
  get_env "DATABASE_URL" = resume (Just "localhost:5432/dev")
  get_env "APP_SECRET"   = resume (Just "dev-secret")
  get_env _              = resume Nothing
}

fun db_url : Unit -> String needs {Env}
db_url () = case (get_env! "DATABASE_URL") {
  Just url -> url
  Nothing  -> "localhost:5432/default"
}

When db_url calls get_env! "DATABASE_URL", control transfers to the dev_env handler. The handler pattern-matches on the key, and calls resume (Just "localhost:5432/dev"). At that point, get_env! returns Just "localhost:5432/dev" and db_url continues into the case.

Not resuming: aborting the computation

A handler does not have to call resume. If it doesn't, the computation is abandoned and the handler's return value becomes the result of the entire with block.

The built-in Fail effect is the clearest example. It declares a single operation that never returns normally:

effect Fail {
  fun fail : String -> a
}

fun safe_divide : Int -> Int -> Int needs {Fail}
safe_divide x 0 = fail! "division by zero"
safe_divide x y = x / y

let result = {
  safe_divide 10 0
} with {
  fail reason = Err reason
}
# result is Err "division by zero"

When fail! is performed, the handler returns Err reason directly. Everything after fail! in the computation is skipped. This is how error handling works in Saga: there is no special syntax for exceptions or try/catch. Fail is just an effect, and aborting is just a handler that chooses not to resume.

The return clause

When a handler aborts, the failure path produces a value (like Err reason above). But what about the success path? By default, the success value passes through unchanged. A return clause lets the handler intercept it:

handler to_result for Fail {
  fail reason = Err reason
  return value = Ok value
}

Now both paths return the same type:

safe_divide 10 0 with to_result   # Err "division by zero"
safe_divide 10 2 with to_result   # Ok 5

Note that return here is not the keyword you may know from other languages. Saga has no general return statement. In this context, return is a special clause in a handler that transforms the final value of a successful computation. It only appears inside handler definitions.

Most handlers don't need it. It comes up when both the success and failure paths need to produce a unified type, as with to_result. More advanced uses of return are covered in Handler Patterns.

Attaching handlers with with

The with keyword connects a computation to its handler:

# Single handler
greet "world" with console

# Multiple handlers in a block
fun main : Unit -> Unit
main () = {
  process_request my_request
} with {
  console,
  http,
}

The handler can be a named declaration, a local binding, or even defined inline:

# Inline handler for a one-off case
greet "world" with {
  log msg = {
    print! $"[LOG] {msg}"
    resume ()
  }
}

Everything between the opening { and the } with is the "handled computation." Any effect operations performed inside that block (or in functions called from it) are routed to the attached handlers.

Handlers that use effects

The dev_env handler above is pure: it returns hardcoded values and needs no effects of its own. But a production handler might need to reach out to a secrets manager. Declare those dependencies with needs on the handler:

effect SecretStore {
  fun fetch_secret : String -> Maybe String
}

handler prod_env for Env needs {SecretStore} {
  get_env key = {
    let secret = fetch_secret! key
    resume secret
  }
}

This handler implements Env by delegating to a SecretStore effect. When you attach it, SecretStore must also be handled somewhere in the chain:

main () = {
  run_app ()
} with {
  prod_env,
  onepassword,
}

A handler with no needs clause is pure, like dev_env from earlier.

Effects compose naturally

When a function needs multiple effects, it just lists them. There is no layering, no ordering to get right, and no special plumbing to combine them.

If you've used monad transformers in other languages (building stacks like ReaderT Config (ExceptT Error (StateT S IO)) with lifting between layers), this is the problem effects eliminate entirely:

fun process : Request -> Response needs {Log, Http, Fail, Database}
process req = {
  log! "start"
  let user = query! req.user_id
  let data = get! user.api_url
  if data == "" then fail! "empty response"
  else build_response data
}

Inside the function body, there is no layering. You call log!, query!, get!, and fail! freely without worrying about which "layer" each belongs to. There is no lifting between effect layers.

main () = {
  process my_request
} with {
  console,
  http,
  to_result,
  postgres,
}

Adding a new effect to a function is just adding it to needs and providing a handler. No restructuring, no type-level plumbing.

A function with fewer effects always fits where more are allowed. If a caller expects needs {Log, Http}, a function that only needs {Log} works fine. A pure function (no needs at all) fits everywhere.

Testing with effects

Since handlers are swappable, testing is straightforward: use the same function with different handlers. No mock frameworks, no patching imports.

handler mock_http for Http {
  get req = resume (Response 200 "{\"status\": \"ok\"}")
  post req = resume (Response 201 "created")
}

handler silent for Log {
  log msg = resume ()
}

test "process_request returns valid response" (fun () -> {
  let resp = process_request test_req with {
    mock_http,
    silent,
  }
  assert_eq resp.status 200
})

The function under test is unchanged. Only the handlers differ.

Putting it all together

Here is a complete example: declaring effects, writing functions that use them, implementing handlers, and wiring everything up:

# Declare effects
effect Log {
  fun log : String -> Unit
}

effect Fail {
  fun fail : String -> a
}

# Functions that use effects
fun parse_age : String -> Int needs {Fail}
parse_age input = case (String.to_int input) {
  Ok n  -> n
  Err _ -> fail! $"not a number: {input}"
}

fun validate_age : Int -> Int needs {Fail}
validate_age age =
  if age < 0 || age > 150
  then fail! $"age out of range: {age}"
  else age

fun process_input : String -> String needs {Log, Fail}
process_input input = {
  log! $"processing: {input}"
  let age = parse_age input
  let valid = validate_age age
  $"valid age: {valid}"
}

# Handlers
handler console for Log needs {Stdio} {
  log msg = {
    print! msg
    resume ()
  }
}

handler to_result for Fail {
  fail reason = Err reason
  return value = Ok value
}

# Wire it up
fun main : Unit -> Unit
main () = {
  let result = process_input "25" with to_result
  case result {
    Ok msg  -> print! msg
    Err err -> print! $"Error: {err}"
  }
} with {console, stdio}

The next section covers all the ways handlers can be defined, composed, and combined.