Skip to main content
Version: 3.x

Using DataLoaders in GraphQL loaders

What are DataLoaders?

DataLoader is a pattern promoted by Facebook, from their internal implementations, to solve problems with data fetching. We use this name because it is the name of the reference implementation in Javascript: graphql/dataloader.

A DataLoader is instantiated with a batching function, which allows data to be fetched in groups (see Batching). It also has a caching strategy that prevents fetching the same data twice in the same request or across requests (see Caching).

note

For a better understanding of why we use DataLoaders, read the Common issues in the data fetching layer guide.

By default every DataLoader provides request-level caching. But this can be configured to switch to a persistent caching strategy instead (see Configure caching strategies).

We encourage you to read the DataLoader readme documentation to learn more about how it works.

Front-Commerce provides a factory function to create DataLoaders from your GraphQL modules while keeping caching strategies configurable. Under the hood, it is a pure DataLoader instance, so you could use it in a standard manner.

Using DataLoaders in Front-Commerce

When building a GraphQL module, Front-Commerce will inject a makeDataLoader factory function in your module’s contextEnhancer function.

makeDataLoader usage

The makeDataLoader factory allows developers to build a DataLoader without worrying about the current store scope (in a multi-store environment) or caching concern.

Here is an example based on the use case above:

extensions/acme/inventory/graphql/index.ts
import { createGraphQLModule } from "@front-commerce/core/graphql";

export default createGraphQLModule({
namespace: "AcmeInventory",
loadRuntime: () => import("./runtime"),
typeDefs: /* GraphQL */ `
extend type Product {
qty: Int
}
`,
});
extensions/acme/inventory/graphql/runtime.ts
import { createGraphQLRuntime } from "@front-commerce/core/graphql";
import StockLoader from "./loader";
import axios from "axios";

export default createGraphQLRuntime({
contextEnhancer: ({ makeDataLoader, config }) => {
const axiosInstance = axios.create({
baseURL: config.inventoryApiEndpointUrl,
});

return {
// create an instance of the loader, to be made available in resolvers
Stock: new StockLoader(makeDataLoader, axiosInstance),
};
},
resolvers: {
Product: {
qty: ({ sku }, _, { loaders }) => {
// use the loader instance to fetch data
// batching and caching is transparent in the resolver
return loaders.Stock.loadBySku(sku);
},
},
},
});
extensions/acme/inventory/graphql/loader.ts
import { reorderForIds } from "@front-commerce/core/graphql";
import type { DataLoaderScopedFactory } from "@front-commerce/core/cache";
import type { AxiosInstance } from "axios";

class StockLoader {
private httpClient: AxiosInstance;
private dataLoader: ReturnType<ReturnType<DataLoaderScopedFactory>>;

constructor(
makeDataLoader: DataLoaderScopedFactory,
httpClient: AxiosInstance
) {
// The `Stock` key here must be unique across the project
// and is used in cache configuration to determine the caching strategy to use
this.dataLoader = makeDataLoader("Stock")(
(skus: readonly (string | number)[]) => this.loadStocksBatch(skus)
);
this.httpClient = httpClient;
}

// Our batching function that will be injected in the DataLoader factory
// it is important to return results in the same order than the passed `skus`
// hence the use of `reorderForIds` (documented later in this page)
private loadStocksBatch(skus: readonly (string | number)[]) {
return this.httpClient
.get("/stocks", { params: { skus } })
.then((response) => response.data.items)
.then(reorderForIds(skus, "sku"));
}

loadBySku(sku: string | number) {
return this.dataLoader.load(sku);
}
}

export default StockLoader;

The 2nd parameter to makeDataLoader are the options to pass to the DataLoader instance. You usually don't have to use it. Please refer to dataloader's documentation for further information.

Useful patterns

Prevent caching errors (data not found)

Batching functions will sometimes return null or falsy data for nonexistent items. By default, these values will be cached so further data retrieval could return this null value instead of doing a remote API call.

In some specific cases, you may want to force fetching data from the remote source every time. You can do so by returning an Error for the nonexistent item.

Here is an example:

const fooLoader = makeDataLoader("AcmeFoo")((ids) =>
loadFooBatch(ids).then((items) =>
items.map((item) => {
if (!item) {
return new Error("not found");
}
return item;
})
)
);

Using a predefined TTL

In some contexts, cache invalidation could be impossible or difficult to implement in remote systems. You may still want to leverage Front-Commerce's caching features, such as the Redis persistent cache, to improve performance of your application.

The Redis strategy supports an additional option (to be provided during instantiation) that allows you to create a loader with a specified expiration time for cached items.

Here is how you could use it:

const fooLoader = makeDataLoader("AcmeFoo")(
(ids) => loadFooBatch(ids),
{ expire: EXPIRE_TIME_IN_SECONDS } // see https://github.com/DubFriend/redis-dataloader
);

Caching scalar values

DataLoaders mostly manipulate objects. Hence, it is safer to design your application to return objects from batching functions. This will ensure a wider range of caching strategies' compatibility (ex: Redis strategy does not support caching of scalar values).

const fooLoader = makeDataLoader("AcmeFoo")((ids) =>
loadFooBatch(ids).then((results) =>
results.map((result) => ({ value: result }))
)
);

// …

return fooLoader.load(id).then((data) => data.value);

Helpers available to create dataLoaders

Writing batching functions and loaders could lead to reusing the same patterns. We have extracted some utility functions to help you in this task.

You can find them in the node_modules/@front-commerce/core/graphql/dataloaderHelpers.ts module.

reorderForIds

Batch functions must satisfy two constraints to be used in a DataLoader (from the graphql/dataloader documentation):

  1. The Array of values must be the same length as the Array of keys
  2. Each index in the Array of values must correspond to the same index in the Array of keys.

reorderForIds will ensure that these constraints are satisfied.

Signature: const reorderForIds = (ids, idKey = "id") => data => sortedData;

It will sort data by idKey to match the order from the ids array passed in parameters. In case no matching values is found, it will return null and log a message so you could then understand why no result was found for a given id.

Example:

// skus will very likely be a param of your batch loader
const skus = ["P01", "P02", "P03"];

return (
axiosInstance
.get("/frontcommerce/price", {
params: {
skus: skus.join(","),
},
})
.then((response) => {
const prices = response.data;
/* [
{sku: "P02", price: 12},
{sku: "P03", price: 13},
{sku: "P01", price: 11},
] */
return prices;
})
// results will be sorted according to the initial skus passed (P01, P02, P03)
.then(reorderForIds(skus, "sku"))
);

reorderForIdsCaseInsensitive

As its name implies, it is very similar to reorderForIds but ids are compared in a case insensitive way.

Example:

return axiosInstance
.get(`/products`, { params: searchCriteria })
.then((response) => response.data.items.map(convertMagentoProductForFront))
.then(reorderForIdsCaseInsensitive(skus, "sku"));

makeBatchLoaderFromSingleFetch

Until now, we created batching functions using a remote API that allowed to request several results at once (https://inventory.example.com/stocks?skus=PANT-01,PANT-02,…,PANT-10).

When using 3rd party APIs or legacy systems, such APIs might not always be available. Using dataLoaders in this case will not allow you to reduce the number of requests in the absolute, however it could still allow you to prevent most of these requests (or reduce its number in practice) thanks to caching. It is thus very convenient when dealing with a slow service.

The makeBatchLoaderFromSingleFetch allows you to create a batching function from a single fetching function easily.

Pseudo signature:

makeBatchLoaderFromSingleFetch = (
function singleFetch, // function that fetches data for a single id
function singleResponseMapper = ({ data }) => data // function that transform a response into data
) => ids => Observable(sortedData);

Example (from the Magento2 category loader):

import { makeBatchLoaderFromSingleFetch } from "server/core/graphql/dataloaderHelpers";

// …
const loadBatch = makeBatchLoaderFromSingleFetch(
(id) => axiosInstance.get(`/categories/${id}`),
(response) => convertCategoryMainAttributesForFront(response.data)
);

const loader = makeDataLoader("CatalogCategory")(
(ids) => loadBatch(ids).toPromise() // <-- note the `toPromise()` here
);
note

makeBatchLoaderFromSingleFetch returns an Observable. You must thus convert it to a Promise using the .toPromise() method.