Photo from the moon looking back at the earth
Photo by NASA on Unsplash

Migrating from Apollo Server v2 to v3 — Part 1: GraphQL Tools

Miles Bardon
Engineers @ The LEGO Group
7 min readJan 31, 2022

--

Over the past 6 months, the LEGO.com team have been undertaking efforts to migrate their Apollo GraphQL Server to the latest version. In this multi-part series, we will share our methodologies and findings, as well as resources to help your team make the same transition.

In this blog, I’ll detail the journey we took to understand the prerequisites to the server upgrade, which focuses on using the GraphQL Tools packages along with their best practices, and the challenges faced in following them on an ever-changing, giant codebase. Part 2 will cover the upgrade process in detail, with some more technical considerations that had to be made to make it a reality.

Identifying the problem

In my second week at the LEGO Group, I was presented with what appeared to be a fairly simple task; Ensure we were using the GraphQL Import syntax correctly within our Schema (.graphql) files. For the uninitiated, GraphQL Import allows schemas to be split up into smaller files, that will then be pieced together with import statements in the same vein as JavaScript ESM imports.

In all honesty, I’d barely touched GraphQL before but was eager to learn in a slow, steady fashion by addressing some small syntax issues.

3 months later, I merged the PR with a total of 10,448 line changes, 48 files affected and 7 full team approvals. I can tell you now, this was no mean feat…

Screenshot of pull request title in github saying refactor: SET-12808 Migrate Graphql Import to Graphql Tools showing 4799 line additions and 5649 line removals
A summary of the final PR

To start tackling this issue, I turned to the documentation. Surely they’d have some best practices on how to correctly use their tool, right? Well, I was greeted by a lovely red message at the top of the GraphQL Import npm page.

Red box with message reading: This package has been deprecated. Author message: GraphQL Import has been deprecated and merged into GraphQL Tools, so it will no longer get updates. Use GraphQL Tools instead to stay up-to-date!
Uh oh… deprecated libraries in production?!

It’s a good thing we spotted that, and it even has a set of instructions on how to migrate from the old, deprecated library to this shiny new one! “This is going to be easy”, thought young(er) Miles.

Explicitly defining imports

Following the link explained how we could simply insert loadSchemaSync with an GraphQLFileLoader in place of the importSchema function. However, only applying these changes resulted in dozens, if not hundreds, of errors all stating that various schema types, interfaces and custom directives just couldn’t be found, such as this one;

node_modules/@graphql-tools/load/index.js:83
throw error;
^
Error: Couldn't find type deprecatedWithMetrics in any of the schemas.

Documentation on this issue was lacking, to say the least. So much so, that I had to raise an issue to the developers to finally understand what was going wrong. graphql-import was very lenient with the order of import statements, due to the nature of the way it progressively scanned through files and appended them to one big schema.graphql at the end. This meant that if even one file imported another, that second file would be accessible to every other schema file that was imported next. This works very differently to graphql-tools/schema which imports files and types based on the order they’re imported, if at all. A short example might be as follows;

# app.graphql
# import Order from "./orders.graphql"
# import Price from "./prices.graphql"
type Query {
order: Order!
price: Price!
}
# orders.graphql
# import Price from "./prices.graphql"
type Order {
id: ID!
price: Price!
}
# prices.graphqltype Price {
value: Int!
}
# unused.graphqltype OldPrice {
value: String!
}

Here we have 4 files, and they will be loaded with the following code;

import { loadSchemaSync } from "@graphql-tools/load";
import { GraphQLFileLoader } from "@graphql-tools/graphql-file-loader";
import { addResolversToSchema } from "@graphql-tools/schema";
import { join } from "path";
const schema = loadSchemaSync(join(__dirname, "app.graphql"), {
loaders: [new GraphQLFileLoader()],
});

In this case, the Order and Price types will only be loaded into the schema once, and the OldPrice type will be completely ignored, as it’s not imported anywhere. If we were to remove the Price import from orders.graphql we’d get an error like

Error: Couldn’t find type Price in any of the schemas.

This was our first big hurdle in adopting GraphQL tools; Updating every file to import exactly the types and interfaces it needed. In fact, this accounted for 27 of the files changed in the PR.

“Unknown Type” errors

With the first batch of errors resolved, the next lot could come out of hibernation. Progress at last!

We still had a lot of types not being found by the schema loader, but these types were often present in the same file as the types that were using them, and not explicitly imported anywhere. Others appeared to have the same issue, though this had been reported to be fixed. With some further digging and experimentation, we found that whilst the fix referenced in this issue targeted imports nested 3 levels deep, that’s where it stopped.

Our schema is large, and often has types that nest more than 8 times! I put forward another Issue with a replication of the issue showing how only 4 nested imports (be they explicit or implicit, within the same file), would break the schema.

As we were very unfamiliar with the tools, we did try to understand where the problem was and fix it but were grateful to be beaten to the mark by GitHub user jacob-carpenter, who submitted a fix that proved to solve our problems (almost).

And then there were 3

We were on the home stretch — only 3 type errors remained once this new version of GraphQL Tools was applied to the server. These errors actually occurred after the schema was collated, when being read by Apollo Server

throw new Error(errors.map(function (error) {
^
Error: Unknown type "ProductSection". Did you mean "BasicProductSection", "CodedSection", "LayoutSection", "GridSection", or "PollsSection"?Unknown type "ProductSection". Did you mean "BasicProductSection", "CodedSection", "LayoutSection", "GridSection", or "PollsSection"?Unknown type "ProductSection". Did you mean "BasicProductSection", "CodedSection", "LayoutSection", "GridSection", or "PollsSection"?

For reference, these types followed this pattern (redacted for brevity);

interface ProductSection {
id: ID!
}
type BasicProductSection implements ContentSection & ProductSection {
id: ID!
removePadding: Boolean
}
type DisruptorProductSection implements ContentSection & ProductSection {
id: ID!
disruptor: Disruptor!
}
type CountdownProductSection implements ContentSection & ProductSection {
id: ID!
countdown: CountdownBannerChild!
}

It took some experimentation to find what was causing only these three types to error out, and we found the answer after inspecting the contents of the schema object created above. Simply, in this case, only the ProductSection is not found, so why is ContentSection not throwing an error, too? Let’s look at the object created by loadSchemaSync again edited for brevity:

"_typeMap": {
"Query": "Query",
"String": "String",
"Float": "Float",
...
"ContentSection": "ContentSection",
...
}

Quite conveniently, we can trace the _typeMap all the way through our schema to find how each type is found. Query is the first type found, as it’s at the top of our app.schema file, and the next types used are the built-in String and Float types. This is completely dependent on your schema, however, so your mileage may vary.

Working our way down the schema, and weaving into and out of nested types, we eventually find the ContentSection used as an interface on a type accessible from the root Query. However, nowhere in this _typeMap exists ProductSection! A quick search shows that this interface, and these types, aren’t used in a single place in the schema, but are referenced as types in the Resolvers, that are passed to the Apollo Server.

What not to do

My first thought was “how do we get the ProductSection into the type map?”. One way is to create a field that has the ProductSection as its type and immediately mark it as deprecated, or otherwise hidden. Whilst this way “works”, it exposes a field on the schema that is never intended for any use. In the case of the LEGO® Shop, we are the only consumers of our Graph, but for other teams with a public audience, this wouldn’t be desirable at all.

A better solution

Going back to basics, the only real problem that the server has with our schema is that the types shown above are not imported. So, why not just import them manually? Luckily for us, the loadSchemaSync API allows for an array of file pointers to be passed, that will all be imported as long as there are no type clashes (that is to say two type names aren’t identical and cannot be merged without conflicts). Here’s what worked for us;

const baseSchema = "./schemas/app.graphql";
// The file with the problem types/interfaces
const extraTypeSchemas = ["./schemas/content.graphql"];
const typeDefs = loadSchemaSync([baseSchema, ...extraTypeSchemas], {
loaders: [new GraphQLFileLoader()],
});

And with that, our server could launch without trouble!

Conclusion

I’ve gone through in fine detail the steps we took to upgrade our tooling to the latest version of GraphQL Tools, including our pitfalls and successes, particularly in the Open Source realm.

Throughout this task, one of the most challenging things was to stay motivated and keep plugging away at the problem, and with the help of my colleagues and the wider GraphQL community, we were able to accomplish what many would have written off as unfixable!

In the next instalment in this blog series, I will take a deep dive into the migration between the two Apollo versions, and detail some more technical challenges faced there, so stay tuned!

--

--