Migrating from Apollo Server v2 to v3 — Part 2: Server Upgrade
In the last section, I wrote about how we upgraded our tooling in preparation for our full Apollo Server 3 upgrade. In the final article on this topic, I’ll talk about the steps we took to mitigate and resolve all the breaking changes in v3, as well as how we upgraded the live server in place.
Quite a few breaking changes occurred with the upgrade to v3. Among these, our particular server was affected by the loss of extensions
and the removal of the schemaDirectives
option. In the next few sections, I’ll walk through how we resolved these breaking changes.
Migrating Extensions to Plugins
One of the key changes between v2 and v3 is the removal of support for Extensions, which in our case were used to extend the logging capabilities for all logs across the server. This includes removing debug logs in production and tracking request execution times.
The task required us to migrate our extension to the new Plugin pattern as described in the documentation. The majority of this work was converting the old Extension class to the Plugin object, with class methods of the same name in the Extension becoming fields in the Plugin object. For example, this Extension class
export class LoggingExtension extends GraphQLExtension {
constructor(logger: Logger) {
super();
this.logger = logger;
}requestDidStart(o: ExtensionPayload) {
this.operationName = displayOperationName(o.operationName);
this.validationSuccess = false;
this.startWallTime = Date.now();
}validationDidStart() {
return () => {
this.endWallTime = Date.now();
};
}
}
becomes this Plugin object
export const createLoggingPlugin = (logger: Logger) => {
let operationName = "*";
let validationSuccess = false;
let startWallTime = Date.now();
let endWallTime = 0;
return {
async requestDidStart(requestContext: GraphQLResponse) {
operationName = displayOperationName(requestContext.operationName);
validationSuccess = false;
startWallTime = Date.now();
return {
async validationDidStart() {
parsingSuccess = true;
return () => {
endWallTime = Date.now();
};
},
};
},
};
};
Of course, much of the plugin has been omitted for brevity, but the work to migrate this extension was fairly minimal, and gave us the opportunity to write more unit tests!
Migrating SchemaDirectives out of Apollo
With the schemaDirectives
option completely removed, we needed another way to use our custom directives. One of these directives allows us to customise our authorization for requests that require it, with the most minimal effort by engineers, meaning it was imperative that it was brought along on our upgrade journey.
Whilst the Apollo migration documentation recommends applying the directives directly in the makeExecutableSchema
, this recommends using an older version of GraphQL tools which we would like to avoid.
The recommended method for including schema directives in executable schemas for V8 of @graphql-tools/schema
is to pass the generated schema to your implementation of these custom directives.
let schema = makeExecutableSchema({
typeDefs,
resolvers,
});const { authDirectiveTransformer } = authDirective("auth");schema = authDirectiveTransformer(schema);
Now, the schema object we had before has been curried with the authDirective
and can be passed into the Apollo Server configuration as normal.
Actually migrating the directive was a bit more challenging. Before the migration, schema directives were implemented as classes, provided by graphql-tools
V4 (deprecated long ago!).
class AuthDirective extends SchemaDirectiveVisitor
These classes would have methods such as visitObject
and wrapField
that would be called at various stages in the schema’s lifecycle. In the latest version at the time of writing, the directive is implemented as an object that exposes a transformer function, as shown in the documentation.
Conveniently, the documentation also lists an example auth directive, which we were able to use as a starting point for our converted directive. In this case, all we needed to do was replace the fieldConfig.resolve = function (source, args, context, info) {…}
function with our own custom implementation, as well as the directive definition.
export const authDirective = (directiveName: string) => {
const typeDirectiveArgumentMaps = {};return {
authDirectiveTypeDefs: `directive @${directiveName}(
skipOnFail: Boolean = false
service: Service
) on OBJECT | FIELD_DEFINITION`,
authDirectiveTransformer: (schema: GraphQLSchema) =>
mapSchema(schema, {
[MapperKind.TYPE]: (type) => {
...
},
[MapperKind.OBJECT_FIELD]: (fieldConfig, _fieldName, typeName) => {
...
}
},
}),
};
};
With the schema directives migrated, we were finally ready to make the Big Switch.
Migrating the server
The final step in our long journey is to actually upgrade the server itself. We’ve brought all our dependant services and configurations up to the latest and greatest standard, so now all that stands in our way is upgrading the Apollo Server dependency to v3, and making sure it all works as normal.
Our own Apollo Server doesn’t use the vanilla implementation; we actually use apollo-server-express
to get a better experience with our express server, which handles more than just GraphQL; it also includes a health check endpoint. The migration documentation references many of these framework integrations that have been changed in the newest version.
Before, we could just define the server, add our express app as middleware, and start listening on our designated port. Now we have a few details to change and reorder.
1. The server has a .start()
method that must be called before any middleware is applied, and directly after the server is created. This method used to be optional in v2, but in v3 it’s now mandatory.
2. The server starting routine is now asynchronous, meaning the entire setup function must be run in a Promise-like fashion. In our case, the server is started with the Node process, meaning we have a function along these lines;
const startServer = async () => {
const httpServer = http.createServer(app);const server = new ApolloServer({
schema,
});await server.start();server.applyMiddleware({
app,
bodyParserConfig: true,
path: "/graphql",
});const PORT = process.env.PORT;// This `listen` method launches a web-server
const expressServer = httpServer.listen({ port: PORT }, () => {
logger.info(
`🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`
);
});
};startServer();
And that’s it! With those steps, our server is ready to run and we can begin taking advantage of all the new features Apollo Server 3 has to offer, such as Federation support to spread responsibility for our schema around the codebase, and Apollo Studio Explorer replacing the old, bloated Playground.
Conclusion
The whole journey, from the investigation beginning, until the final merge of Apollo Server 3 took around 6 months to complete. During this time, numerous upgrades, refactors and other such updates kept us on our toes, continually testing our GraphQL server and putting our architecture through its paces.
Not only did we learn a lot of technical knowledge about running and managing a server of this size, but also how to effectively coordinate the whole team, of which there were many sub-teams, into testing the changes in a manner that prioritised completeness over speed.
Personally, I’m really happy with the results of this extraordinary undertaking and it has left us with a bright GraphQL future ahead.