Skip to content

Request & RequestResolver

A Request describes a single piece of data you want to fetch — its inputs, its success type, and its failure type. A RequestResolver is the one place that actually talks to the external system: it receives a whole batch of pending requests at once and completes each of them. Because requests are plain, comparable values, Effect can collect requests issued across many concurrent fibers, deduplicate the identical ones, and run the resolver a single time per batch.

The pattern below wraps a resolver inside a service, so callers see an ordinary getUserById method and never deal with batching mechanics directly.

import { Context, Effect, Exit, Layer, Request, RequestResolver, Schema } from "effect"
// The domain types the resolver returns.
class User extends Schema.Class<User>("User")({
id: Schema.Number,
name: Schema.String,
email: Schema.String
}) {}
class UserNotFound extends Schema.TaggedErrorClass<UserNotFound>()("UserNotFound", {
id: Schema.Number
}) {}
class Users extends Context.Service<Users, {
getUserById(id: number): Effect.Effect<User, UserNotFound>
}>()("app/Users") {
static readonly layer = Layer.effect(
Users,
Effect.gen(function*() {
// A Request models one external lookup. The four type arguments are:
// inputs, success, error, and required services (here `never`).
class GetUserById extends Request.Class<
{ readonly id: number },
User,
UserNotFound,
never
> {}
// Stand-in for a data source that supports bulk lookup (e.g. WHERE id IN (...)).
const usersTable = new Map<number, User>([
[1, new User({ id: 1, name: "Ada Lovelace", email: "ada@acme.dev" })],
[2, new User({ id: 2, name: "Alan Turing", email: "alan@acme.dev" })],
[3, new User({ id: 3, name: "Grace Hopper", email: "grace@acme.dev" })]
])
// The resolver receives the *whole batch* of unique requests and must
// complete every entry. `withCache` returns an Effect, so we `yield*`.
const resolver = yield* RequestResolver.make<GetUserById>(
Effect.fnUntraced(function*(entries) {
// In a real resolver you would issue ONE bulk query for all the ids
// here, then route each row back to its entry below.
for (const entry of entries) {
const user = usersTable.get(entry.request.id)
// Complete an entry by handing it an Exit: success or typed failure.
entry.completeUnsafe(
user
? Exit.succeed(user)
: Exit.fail(new UserNotFound({ id: entry.request.id }))
)
}
})
).pipe(
// Wait this long before running, so more requests can join the batch.
// Larger delay = bigger batches but more latency on the first request.
RequestResolver.setDelay("10 millis"),
// Wrap each batch in a span (with a `batchSize` attribute) and link it
// to the span of every request that contributed to the batch.
RequestResolver.withSpan("Users.getUserById.resolver"),
// Add an in-memory LRU cache keyed by request value, so a repeated id
// skips the resolver entirely on later calls.
RequestResolver.withCache({ capacity: 1024 })
)
// Callers just see a normal effectful method; batching is invisible.
const getUserById = (id: number) =>
Effect.request(new GetUserById({ id }), resolver).pipe(
Effect.withSpan("Users.getUserById", { attributes: { userId: id } })
)
return { getUserById } as const
})
)
}
// Run several lookups concurrently. The resolver is invoked ONCE with the
// unique ids [1, 2, 3] — the duplicate 1 and 2 are deduplicated for you.
const program = Effect.gen(function*() {
const users = yield* Users
return yield* Effect.forEach([1, 2, 1, 3, 2], users.getUserById, {
concurrency: "unbounded"
})
}).pipe(Effect.provide(Users.layer))
  1. Define a Request. Request.Class<Inputs, Success, Error, Services> produces a class whose instances carry the inputs and declare the success/error types the resolver must complete with. Each instance is a comparable value: new GetUserById({ id: 1 }) equals another new GetUserById({ id: 1 }), which is what makes deduplication possible.

  2. Build a RequestResolver. RequestResolver.make takes a function from a non-empty array of entries to an Effect. This callback is the only code that touches the external system — write your bulk fetch here, then complete every entry.

  3. Issue requests. Effect.request(request, resolver) returns an Effect<Success, Error, Services>. To the caller it behaves like any other effect; under the hood it enqueues the request into the resolver’s current batch.

  4. Run concurrently. Requests issued within the delay window — typically via Effect.forEach(..., { concurrency }) or concurrent fibers — are gathered into one batch, deduplicated, and resolved together.

The resolver callback receives Request.Entry values, not raw requests. Each entry exposes:

  • entry.request — the request value, so you can read its inputs (e.g. entry.request.id).
  • entry.completeUnsafe(exit) — completes the entry with an Exit: Exit.succeed(value) or Exit.fail(error) for a typed failure. You must complete every entry, or the fibers waiting on those requests will hang.
  • entry.context — the services in scope where the request was made. Use Context.getOption(entry.context, SomeTag) to read them inside the resolver.

A resolver is a value you refine with combinators in a pipe:

| Combinator | Effect | | --- | --- | | RequestResolver.setDelay("10 millis") | Wait before running so more requests can join the batch. Trades first-request latency for larger batches. | | RequestResolver.batchN(n) | Cap each batch at n requests; the rest roll into the next batch. | | RequestResolver.grouped(resolver, keyFn) | Split a batch into sub-batches by key (e.g. per shard or per tenant), running the resolver once per group. | | RequestResolver.withSpan(name) | Wrap each batch in a tracing span and link it to every contributing request’s span. | | RequestResolver.withCache({ capacity }) | Add an LRU/FIFO cache keyed by request value. Returns an Effect<RequestResolver> because it allocates the cache, so yield* it. | | RequestResolver.around(resolver, before, after) | Run setup/teardown effects around each batch (open a connection, log, etc.). |

When fetching is itself effectful and you do not need a shared bulk call, build the resolver from a per-request effect with RequestResolver.fromEffect. The runtime forks one fiber per entry and completes each from that fiber’s result:

import { Effect, Request, RequestResolver } from "effect"
class GetUser extends Request.TaggedClass("GetUser")<
{ readonly id: number },
string
> {}
const resolver = RequestResolver.fromEffect<GetUser>((entry) =>
Effect.gen(function*() {
// Each entry resolved independently; still batched at the call site,
// so you get concurrency control and dedup without writing a bulk fetch.
yield* Effect.sleep("50 millis")
return `user-${entry.request.id}`
})
)

Request.TaggedClass(tag) is the variant to reach for when several request types share one resolver — the _tag field lets RequestResolver.fromEffectTagged dispatch each tag to its own batched handler.

Wrapping the resolver in a service keeps the batching machinery — the request class, the delay, the cache — private to the Users.layer. Callers depend only on the Users interface and call getUserById(id); they never see Request, RequestResolver, or Effect.request. Swapping the implementation (real DB, in-memory fake for tests) is just providing a different layer.

  • CachingwithCache and RequestResolver.asCache let resolver results live in a full Cache with TTLs, invalidation, and refresh.
  • Concurrency — the batch is only as large as the number of requests in flight, so drive resolvers with concurrent Effect.forEach.
  • ObservabilitywithSpan makes each batch and its batchSize visible in your traces.