Migrating from Apollo Server v2 to v3 — Part 1: GraphQL Tools
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…
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.
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!