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))Anatomy of the pattern
Section titled “Anatomy of the pattern”-
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 anothernew GetUserById({ id: 1 }), which is what makes deduplication possible. -
Build a RequestResolver.
RequestResolver.maketakes a function from a non-empty array of entries to anEffect. This callback is the only code that touches the external system — write your bulk fetch here, then complete every entry. -
Issue requests.
Effect.request(request, resolver)returns anEffect<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. -
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.
Completing entries
Section titled “Completing entries”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 anExit:Exit.succeed(value)orExit.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. UseContext.getOption(entry.context, SomeTag)to read them inside the resolver.
Tuning the batch
Section titled “Tuning the batch”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.). |
Resolving from an Effect
Section titled “Resolving from an Effect”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.
Why a service?
Section titled “Why a service?”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.
Related
Section titled “Related”- Caching —
withCacheandRequestResolver.asCachelet resolver results live in a fullCachewith 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. - Observability —
withSpanmakes each batch and itsbatchSizevisible in your traces.