DataLoader Integration
Effect GraphQL includes built-in DataLoader support to solve the N+1 query problem common in GraphQL APIs. DataLoaders batch multiple individual requests into single bulk queries and cache results within a request.
The N+1 Problem
Section titled “The N+1 Problem”Consider this GraphQL query:
query { posts { # 1 query to fetch posts author { # N queries to fetch each author name } }}Without DataLoader, if you have 100 posts, this executes 101 database queries. With DataLoader, it’s just 2 queries: one for posts, one for all unique authors.
Quick Start
Section titled “Quick Start”-
Define your loaders
import { Loader } from "@effect-gql/core"const loaders = Loader.define({UserById: Loader.single<string, User>({batch: (ids) => db.getUsersByIds(ids),key: (user) => user.id})}) -
Add the loader layer
const serviceLayer = Layer.mergeAll(DatabaseLive,loaders.toLayer()) -
Use in resolvers
field("Post", "author", {type: UserSchema,resolve: (post) => loaders.load("UserById", post.authorId)})
Loader Types
Section titled “Loader Types”Single Loader
Section titled “Single Loader”One key maps to one value. Use for relationships like “post belongs to author”.
const loaders = Loader.define({ UserById: Loader.single<string, User>({ // Batch function receives all requested keys batch: (ids) => Effect.gen(function* () { const db = yield* Database return yield* db.getUsersByIds(ids) }), // Key function extracts the ID from each returned value key: (user) => user.id })})Type Parameters:
K- The key type (typicallystringornumber)V- The value type (the entity being loaded)R- Service requirements (inferred from the batch function)
How it works:
- Collect all
load("UserById", id)calls in the current tick - Call
batch([id1, id2, id3, ...])once - Match returned values to keys using the
keyfunction - Return individual values to each caller
Grouped Loader
Section titled “Grouped Loader”One key maps to many values. Use for one-to-many relationships like “author has many posts”.
const loaders = Loader.define({ PostsByAuthorId: Loader.grouped<string, Post>({ // Batch function returns all posts for the requested author IDs batch: (authorIds) => Effect.gen(function* () { const db = yield* Database return yield* db.getPostsByAuthorIds(authorIds) }), // GroupBy function determines which key each value belongs to groupBy: (post) => post.authorId })})How it works:
- Collect all
load("PostsByAuthorId", authorId)calls - Call
batch([authorId1, authorId2, ...])once - Group returned values by
groupByfunction - Return arrays to each caller
Full Example
Section titled “Full Example”import { GraphQLSchemaBuilder, query, field, Loader } from "@effect-gql/core"import { Effect, Context, Layer } from "effect"import * as S from "effect/Schema"
// Schemasconst UserSchema = S.Struct({ id: S.String, name: S.String, email: S.String})
const PostSchema = S.Struct({ id: S.String, title: S.String, content: S.String, authorId: S.String})
// Database serviceclass Database extends Context.Tag("Database")<Database, { getAllPosts: () => Effect.Effect<Post[]> getUsersByIds: (ids: readonly string[]) => Effect.Effect<User[]> getPostsByAuthorIds: (authorIds: readonly string[]) => Effect.Effect<Post[]>}>() {}
// Define all loadersconst loaders = Loader.define({ UserById: Loader.single<string, User>({ batch: (ids) => Effect.gen(function* () { const db = yield* Database return yield* db.getUsersByIds(ids) }), key: (user) => user.id }),
PostsByAuthorId: Loader.grouped<string, Post>({ batch: (authorIds) => Effect.gen(function* () { const db = yield* Database return yield* db.getPostsByAuthorIds(authorIds) }), groupBy: (post) => post.authorId })})
// Build schemaconst builder = GraphQLSchemaBuilder.empty.pipe( // Object types objectType({ name: "User", schema: UserSchema }), objectType({ name: "Post", schema: PostSchema }),
// Query query("posts", { type: S.Array(PostSchema), resolve: () => Effect.gen(function* () { const db = yield* Database return yield* db.getAllPosts() }) }),
// Relational fields field("Post", "author", { type: UserSchema, resolve: (post) => loaders.load("UserById", post.authorId) }),
field("User", "posts", { type: S.Array(PostSchema), resolve: (user) => loaders.load("PostsByAuthorId", user.id) }))
// Create service layer with loadersconst serviceLayer = Layer.mergeAll( DatabaseLive, loaders.toLayer())Loader Registry API
Section titled “Loader Registry API”Loader.define(definitions)
Section titled “Loader.define(definitions)”Create a registry of loaders:
const loaders = Loader.define({ // Each key becomes a loader name UserById: Loader.single({ ... }), PostsByAuthorId: Loader.grouped({ ... })})registry.toLayer()
Section titled “registry.toLayer()”Create a Layer that provides fresh DataLoader instances. Should be created once per request:
const serviceLayer = loaders.toLayer()registry.load(name, key)
Section titled “registry.load(name, key)”Load a single value. Returns an Effect:
// For single loaders: returns the valueconst user = yield* loaders.load("UserById", "123")
// For grouped loaders: returns an arrayconst posts = yield* loaders.load("PostsByAuthorId", "123")registry.loadMany(name, keys)
Section titled “registry.loadMany(name, keys)”Load multiple values in a single batch:
const users = yield* loaders.loadMany("UserById", ["1", "2", "3"])// Returns: [User, User, User]registry.use(callback)
Section titled “registry.use(callback)”Direct access to DataLoader instances:
const result = yield* loaders.use(async (instances) => { const user = await instances.UserById.load("123") return user})Request Scoping
Section titled “Request Scoping”DataLoaders should be scoped to each request to ensure:
- Cache isolation: One user’s request doesn’t leak data to another
- Fresh data: Each request starts with empty cache
- Batching window: Batches only collect calls within a single request
Effect GraphQL handles this automatically when you use toLayer():
// Each request gets fresh loader instancesconst serviceLayer = Layer.mergeAll( DatabaseLive, loaders.toLayer() // Fresh instances per request)Utility Functions
Section titled “Utility Functions”Loader.mapByKey
Section titled “Loader.mapByKey”Map items to match requested keys (useful in batch functions):
batch: (ids) => Effect.gen(function* () { const db = yield* Database const users = yield* db.getUsersByIds(ids) // Ensure order matches requested ids return Loader.mapByKey(ids, users, (user) => user.id)})Loader.groupByKey
Section titled “Loader.groupByKey”Group items by key (useful for debugging or custom logic):
const grouped = Loader.groupByKey( ["alice", "bob"], posts, (post) => post.authorId)// Map { "alice" => [Post, Post], "bob" => [Post] }Best Practices
Section titled “Best Practices”1. Define Loaders in a Central Place
Section titled “1. Define Loaders in a Central Place”Keep all loaders in one file for easy discovery:
export const loaders = Loader.define({ UserById: Loader.single({ ... }), PostById: Loader.single({ ... }), CommentsByPostId: Loader.grouped({ ... }), // ... all loaders})2. Use Type-Safe Keys
Section titled “2. Use Type-Safe Keys”Define key types explicitly:
type UserId = string & { readonly _brand: "UserId" }
const loaders = Loader.define({ UserById: Loader.single<UserId, User>({ batch: (ids) => ..., key: (user) => user.id as UserId })})3. Handle Missing Values
Section titled “3. Handle Missing Values”Single loaders return an Error for missing keys. Handle gracefully:
field("Post", "author", { type: S.NullOr(UserSchema), resolve: (post) => loaders.load("UserById", post.authorId).pipe( Effect.catchTag("Error", () => Effect.succeed(null)) )})4. Compose with Services
Section titled “4. Compose with Services”Loaders can depend on other services:
const loaders = Loader.define({ UserById: Loader.single<string, User>({ batch: (ids) => Effect.gen(function* () { const db = yield* Database const cache = yield* CacheService
// Check cache first const cached = yield* cache.getMany(ids) const missing = ids.filter((id) => !cached.has(id))
if (missing.length > 0) { const users = yield* db.getUsersByIds(missing) yield* cache.setMany(users.map((u) => [u.id, u])) return [...cached.values(), ...users] }
return [...cached.values()] }), key: (user) => user.id })})Debugging
Section titled “Debugging”Log Batch Calls
Section titled “Log Batch Calls”Wrap the batch function to see what’s being batched:
const loaders = Loader.define({ UserById: Loader.single<string, User>({ batch: (ids) => Effect.gen(function* () { console.log(`Loading users: ${ids.join(", ")}`) const db = yield* Database const users = yield* db.getUsersByIds(ids) console.log(`Loaded ${users.length} users`) return users }), key: (user) => user.id })})Monitor Cache Hits
Section titled “Monitor Cache Hits”The underlying DataLoader tracks statistics:
loaders.use(async (instances) => { const loader = instances.UserById // DataLoader doesn't expose stats directly, // but you can wrap load calls to track hits/misses})Next Steps
Section titled “Next Steps”- Resolvers - Field resolution patterns
- Error Handling - Handle loading errors
- Server Integration - Run your server