Per-Request Dataloader pattern in NestJS with Apollo GraphQL

MrManafon
Homullus
5 min readFeb 10, 2023

--

The dataloader package manual tells you the basics, but when it comes to clearing the dataloader cache per-request, a bit of trickery is needed in NestJS.

I’m going to keep it simple. If you are confused about what dataloader is, (no worries I was too, node people like inventing names) — its a memoize pattern entity cache that gets cleared upon each request. Thats it.

“DataLoader is a generic utility to be used as part of your application’s data fetching layer to provide a consistent API over various backends and reduce requests to those backends via batching and caching.”

Now that we got that out of the way, let’s talk about the specific problem I encountered yesterday.

Frederick Arthur Bridgman, Apollo Abducting Cyrene — Or carrying a dataloader in his context, hard to say.

The dataloader and the Nest Dependency Injection

TLDR: If you don’t inject it properly, dataloader cache doesn’t get cleared.

Installing the dataloader npm package is super simple, you just follow the docs, make a couple of factories and voila. We already had that set up a long time ago, everything worked just fine for years.

A couple of customers over the months reported that they had to edit and submit the same form 2–3 times before it goes through. It sounded very much like a fake case of a “500 mile email”, until it didn’t.

Dataloader works behind the scenes and you don’t get in contact with that code that much at all. You forget all about it.

Replicated it locally, disabled all backend cache layers. Backend log claims to have responded with the new entity. Still, a refetch request after an entity update was returning the old entity.

At one point I had this whole theory in my head about how FE Apollo must be calling a local service worker that has a stuck cache key, and Chrome DevTools is lying to me about response origin headers and etags…

I’m not kidding, prime 4chan conspiracy theory brain. 🧠

https://xkcd.com/966/

Dataloader cache was not getting cleared, and it would get forever stuck giving the same response.

{
providers: [
{
inject: [MyEntityService],
useFactory: (service: MyEntityService) => new MyEntityDataLoader(service),
provide: MyEntityDataLoader,
}
]
}

Turns out: using normal DI, not so smart. Normal NestJS DI behaviour is to compile on bootup, so the loader never gets killed off.

A common solution is to set the provider scope to “REQUEST” which would rebuild the loader on each request. However, this will also trigger a rebuild of all services that depend on the loader, which might not be so great. Not only is it unnecessary but it also screws with other shared caches we might want to have in those services.

Separating dataloaders into a separate service registry

First, let’s not use Nest DI to @Inject and instaniate each dataloader separately.

Individual Data Loaders

Instead let’s think about them as “recipes” better known as factories. You can do this a thousand ways but I chose to keep them in their own separate files. Doesn’t matter really.

export class MyEntityDataLoader {
public static create(service: MyEntityService) {
return new DataLoader<string, MyEntity[]>(async (ids: string[]) => {
return await service.findAll(ids);
});
}
}

Service Registry

Next, the big boi. Service registry is pretty simple actually. Beneath all the fancy type algebra its just a record property in a class, and two functions — register and get. In my case i forgo the register because I don’t need it.

export class DataloaderRegistry {
private cache: Record<string, any> = {};

constructor(private readonly myEntityService: MyEntityService) {}

/**
* Fetches a memoized service based on a string key, or invokes fallback to create one.
*/
private get<T>(key: string, fallback: () => T): T {
if (!this.cache[key]) {
this.cache[key] = fallback();
}
return this.cache[key];
}

/**
* Just a pretty type-safe facade for invoking `this.get`.
* Make more of your own as you wish.
*/
public get MyEntityDataLoader() {
return this.get('MyEntityDataLoader', () => MyEntityDataLoader.create(this.myEntityService));
}
}

Oh, we’ll also need a little factory to create these registries at a whim.

@Injectable()
export class DataloaderRegistryFactory {
constructor(private readonly myEntityService: MyEntityService) {}

public create() {
return new DataloaderRegistry(this.myEntityService);
}
}

Use Apollo to reset cache on each request

Lastly, we inject this factory (normal Nest DI) into Apollo, and tell it to make us a new registry on every request.

GraphQLModule.forRootAsync<ApolloFederationDriverConfig>({
// ...
inject: [DataloaderRegistryFactory],
useFactory: (dataloaderRegistryFactory: DataloaderRegistryFactory) => ({
// We also pass in `DataloaderRegistry` which will get recreated on each request.
context: ({ req, res }) => ({ req, res, loaders: dataloaderRegistryFactory.create() }),
}),
}),

How do we use it now?

Good question, we can no longer use NestJS DI to inject these loaders into their respective controllers/resolvers. Instead, we ask Apollo to give us our own dedicated loader bucket:

@Query(() => MyEntity, { name: ‘myEntity’ })
async findOneMyEntity(
@Args(‘ids’) ids: string[],
@Context { loaders }: { loaders: DataloaderRegistry }
) {
return loaders.MyEntityDataLoader.load(ids);
}

Bonus points, additional error handling

I created an annotation that pretty much does the same, but with a tiny little bit type-safety. Feel free to borrow it, doesn’t hurt you create it once and never touch it again…

/**
* Extracts the `DataloaderRegistry` from the current Apollo request context.
* Throws if registry is not found or context is not used with GraphQL.
*
* Usage: `@Dataloaders() loaders: DataloaderRegistry`
*/
export const Dataloaders = createParamDecorator((_args, context: ExecutionContext): DataloaderRegistry => {
if (context.getType<ContextType | GqlContextType>() !== 'graphql') {
throw Error(`Context with a type ${context.getType()} is not supported for GraphQL data loaders.`);
}

const ctx = GqlExecutionContext.create(context);
const loaders = ctx.getContext().loaders;
if (!dataloaderRegistry) {
throw Error('DataloaderRegistry was not attached to the Apollo context in a gql request.');
}

return loaders;
});

--

--