Dataloader
How Dataloaders are Used and Their Benefits
Based on PR #2716, dataloaders were introduced to optimize GraphQL performance by batching and caching requests. This document provides an in-depth explanation of their implementation, usage, and advantages.
What is a DataLoader?
A DataLoader is a utility for batching and caching requests in a GraphQL environment. It allows efficient resolution of GraphQL queries by queuing up multiple requests during execution and batching them into a single query to the database or API.
Implementation of Dataloaders
Defining the DataLoader Type:
- A specific
ITeamsDataLoader
type is created to handle team entities. - This type is derived from a generic
IDataLoader
interface for flexibility and reuse.
export type ITeamsDataLoader = IDataLoader<ITeam>;
- A specific
Integrating Dataloaders in Service Interfaces:
- To ensure seamless access throughout the application, the
teamsDataLoader
is added to theIServices
interface. - This allows dependency injection and promotes consistency.
export interface IServices {
teamService: ITeamService;
accountUserDataLoader: IAccountUserDataLoader;
teamsDataLoader: ITeamsDataLoader;
}- To ensure seamless access throughout the application, the
Creating the DataLoader Instance:
- The DataLoader instance is initialized to handle batching and caching logic.
- Below is an example of how a DataLoader might be initialized and how batched results are mapped back to GraphQL child resolvers:
import DataLoader from 'dataloader';
import { getTeamsByIds } from './teamRepository';
export const createTeamsDataLoader = (): ITeamsDataLoader => {
return new DataLoader<string, ITeam>(async (teamIds) => {
// Fetch all teams in a single batch based on provided IDs
const teams = await getTeamsByIds(teamIds);
// Map the fetched teams back to their corresponding IDs
return teamIds.map((id) => teams.find((team) => team.id === id) || new Error(`Team not found: ${id}`));
});
};Explanation of Mapping:
- Input: A list of
teamIds
queued by the DataLoader (e.g.,['team1', 'team2', 'team3']
). - Process: Fetch the data for all
teamIds
in a single query (e.g.,getTeamsByIds(teamIds)
). - Mapping Back:
- Use the input
teamIds
to order the results so they correspond to the original request order. - If a team is missing, return an error or handle it gracefully.
- Use the input
This ensures that the batched result aligns with the request order, even if the database query returns the results in a different sequence.
Using the DataLoader in GraphQL Resolvers:
- Resolvers can now delegate data fetching to the DataLoader, ensuring efficient data retrieval and proper mapping back to the GraphQL schema.
const resolvers = {
Query: {
team: async (_: any, { id }: { id: string }, { services }: { services: IServices }) => {
return services.teamsDataLoader.load(id); // Delegates to the DataLoader
},
},
User: {
teams: async (parent: IUser, _: any, { services }: { services: IServices }) => {
// Batch fetch all team IDs associated with the user
return services.teamsDataLoader.loadMany(parent.teamIds);
},
},
};
Benefits of Using Dataloaders
Batching Requests:
- Groups multiple identical or related queries into a single request.
- Reduces the overhead caused by multiple queries, improving efficiency.
Example: Without DataLoader:
Query: getTeam(1)
Query: getTeam(2)
Query: getTeam(3)With DataLoader:
Single Query: getTeams([1, 2, 3])
Caching:
- Automatically caches the results of requests made during the same execution cycle.
- Prevents fetching the same data more than once, reducing redundant calls.
Example:
First Request: getTeam(1) -> Fetch from database
Second Request: getTeam(1) -> Return from cacheImproved Performance:
- Fewer queries mean reduced latency and lower server/database load.
- Scales better as the number of concurrent requests increases.
Solves the N+1 Problem:
- In GraphQL, resolvers often make repetitive requests (N+1 Problem). DataLoader batches these queries, solving this inefficiency.
Example:
Query: Get all teams for users
Team Resolvers: Fetch team for each user -> N queries
With DataLoader: Fetch all teams in one queryMapping Ensures Correctness:
- By maintaining a consistent mapping of inputs to outputs, DataLoader guarantees that each GraphQL child resolver receives the data it requested, even if some IDs are missing.
Key Steps to Map Batched Results Back to GraphQL Children
Use the Input IDs for Consistent Ordering:
- The input IDs passed to the DataLoader (
teamIds
) must dictate the order of the output array.
- The input IDs passed to the DataLoader (
Handle Missing Data Gracefully:
- If the requested ID is not found in the fetched data, return an error or null to the corresponding resolver.
- Example:
teamIds.map((id) => teams.find((team) => team.id === id) || new Error(`Team not found: ${id}`));
Leverage
loadMany
for Collections:- When fetching collections of data (e.g., all teams for a user), use
loadMany
to handle multiple IDs at once. - Example:
const teamData = await services.teamsDataLoader.loadMany(parent.teamIds);
- When fetching collections of data (e.g., all teams for a user), use
Example Usage in GraphQL Context
Here’s how DataLoader optimizes GraphQL child resolvers:
query {
user(id: "123") {
id
name
teams {
id
name
}
}
}
- The
User
resolver fetchesteamIds
for the user. - The
teams
field usesloadMany
to batch allteamIds
into a single request. - DataLoader maps the results back to each team field for the user in the correct order.
Changes Required to Add DataLoader to Existing Code
Steps to Integrate DataLoader
Create the DataLoader File:
- Create a file named
teams-dataloader.ts
for defining the DataLoader logic. - This file will contain the initialization and batching logic for the
TeamsDataLoader
.
- Create a file named
Modify
types.ts
to Add DataLoader Type:- Update the
TYPES
constant to include a new type forTeamsDataLoader
.
- Update the
Update Service Interfaces:
- Add the
TeamsDataLoader
to theIServices
interface, ensuring it can be accessed across the application.
- Add the
Implement DataLoader in Team Service:
- Use the
TeamsDataLoader
in theITeamService
implementation for efficient data fetching.
- Use the
Inject DataLoader in Dependency Container:
- Register the
TeamsDataLoader
in your dependency injection container, ensuring it is initialized and injected where required.
- Register the
Files Modified for DataLoader Implementation
Here is a list of files modified for integrating DataLoader, along with a new file for the TeamsDataLoader
.
New File: teams-dataloader.ts
Here you can use the BulkDataLoader
to simplify the boilerplate code.
import { injectable, inject } from 'inversify';
import { BulkDataLoader } from '@common-stack/store-mongo';
import { ITeamService, TYPES } from '@adminide-stack/account-api-core';
import { IAccountTeam as ITeam } from '@adminide-stack/core';
@injectable()
export class TeamsDataLoader extends BulkDataLoader<ITeam & { id: string }> {
constructor(
@inject(TYPES.ITeamService)
teamService: ITeamService,
) {
super(teamService as never); // Pass the teamService to the BulkDataLoader utility
}
}
Modified File: types.ts
export const TYPES = {
IOrganizationRepository: Symbol('IOrganizationRepository'),
IOrganizationMicroservice: Symbol('IOrganizationMicroservice'),
AccountUserDataLoader: Symbol('AccountUserDataLoader'),
+ TeamsDataLoader: Symbol('TeamsDataLoader'),
};
Modified File: services.ts
import { IPreferencesService } from '@adminide-stack/core';
import { IOrganizationService } from './organization-service';
import { IAccountService } from './account-service';
import { ITeamsDataLoader, ITeamService } from './team-service';
import { IAccountUserDataLoader } from './account-user-data-loader';
import { ICountryService } from '@container-stack/territory';
export interface IServices {
teamService: ITeamService;
preferenceService?: IPreferencesService;
countryService?: ICountryService;
accountUserDataLoader: IAccountUserDataLoader;
+ teamsDataLoader: ITeamsDataLoader;
}
Modified File: team-service.ts
import { IBaseService, IDataLoader } from '@common-stack/store-mongo';
export type ITeamsDataLoader = IDataLoader<ITeam>;
export interface ITeamService extends IBaseService<ITeam> {
getTeam(id: string): Promise<ITeam>;
getTeamByName(orgName: string, teamName: string): Promise<ITeam>;
}
Modified File: Dependency Injection Container
Ensure the TeamsDataLoader
is registered in your DI container:
container.bind<IDataLoader<ITeam>>(TYPES.TeamsDataLoader).toDynamicValue(() => createTeamsDataLoader());
For more details on how to use generic dataloader you can read Generic Dataloader from common-stac
Guidelines for Developers
Create a DataLoader for Each Entity:
- Define a specific DataLoader type for each data entity (e.g.,
ITeamsDataLoader
,IUsersDataLoader
).
- Define a specific DataLoader type for each data entity (e.g.,
Ensure Proper Mapping in Batch Functions:
- Always map the batched results back to the original input keys.
Use Dependency Injection:
- Pass DataLoader instances via service interfaces (
IServices
) to ensure easy access across the application.
- Pass DataLoader instances via service interfaces (
Gracefully Handle Errors:
- Ensure missing or invalid IDs are handled correctly without breaking the execution.
For more details on implementation, review the files changed in PR #2716.