Skip to main content

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

  1. 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>;
  2. Integrating Dataloaders in Service Interfaces:

    • To ensure seamless access throughout the application, the teamsDataLoader is added to the IServices interface.
    • This allows dependency injection and promotes consistency.
    export interface IServices {
    teamService: ITeamService;
    accountUserDataLoader: IAccountUserDataLoader;
    teamsDataLoader: ITeamsDataLoader;
    }
  3. 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.

    This ensures that the batched result aligns with the request order, even if the database query returns the results in a different sequence.

  4. 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

  1. 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])
  2. 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 cache
  3. Improved Performance:

    • Fewer queries mean reduced latency and lower server/database load.
    • Scales better as the number of concurrent requests increases.
  4. 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 query
  5. Mapping 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

  1. Use the Input IDs for Consistent Ordering:

    • The input IDs passed to the DataLoader (teamIds) must dictate the order of the output array.
  2. 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}`));
  3. 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);

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 fetches teamIds for the user.
  • The teams field uses loadMany to batch all teamIds 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

  1. 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.
  2. Modify types.ts to Add DataLoader Type:

    • Update the TYPES constant to include a new type for TeamsDataLoader.
  3. Update Service Interfaces:

    • Add the TeamsDataLoader to the IServices interface, ensuring it can be accessed across the application.
  4. Implement DataLoader in Team Service:

    • Use the TeamsDataLoader in the ITeamService implementation for efficient data fetching.
  5. Inject DataLoader in Dependency Container:

    • Register the TeamsDataLoader in your dependency injection container, ensuring it is initialized and injected where required.

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

  1. Create a DataLoader for Each Entity:

    • Define a specific DataLoader type for each data entity (e.g., ITeamsDataLoader, IUsersDataLoader).
  2. Ensure Proper Mapping in Batch Functions:

    • Always map the batched results back to the original input keys.
  3. Use Dependency Injection:

    • Pass DataLoader instances via service interfaces (IServices) to ensure easy access across the application.
  4. 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.