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).
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:
import { createGraphQLModule } from "@front-commerce/core/graphql";
export default createGraphQLModule({
namespace: "AcmeInventory",
loadRuntime: () => import("./runtime"),
typeDefs: /* GraphQL */ `
extend type Product {
qty: Int
}
`,
});
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);
},
},
},
});
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):
- The Array of values must be the same length as the Array of keys
- 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
);
makeBatchLoaderFromSingleFetch
returns an Observable. You must thus convert it
to a Promise using the .toPromise()
method.