Deploying a Federated Apollo Microservice App to Digital Ocean

Learn how to build a microservice-architectured Node.js app with the gateway pattern and deploy it to DigitalOcean's App Platform.

Bilyal Mestanov - Jan 9, 2022
  • graphql
  • microservices

Introduction

The popularity of GraphQL among development tools is not a coincidence. Even though it requires a bit of a learning curve, the positives of using this protocol are many - strong typing, self-documenting, elimination of over & under-fetching, to name a few examples. Another pattern for building web-based applications that has been on the rise for a while now is microservices. Popularized by Martin Fowler and James Lewis, this architectural style advocates building many small interconnected pieces of independently deployable and scalable pieces of code, bound by business domain, rather than a single (monolithic) application to meet all business needs.

One of the core GraphQL principles is having a single graph for an application. This approach is not very suitable for applying the microservice ideology as it expects every piece of code to be maintained and deployed as a single unit.

Enter Apollo Gateway. The Apollo team maintains the most popular open-source GraphQL framework - apollo-server. Apollo Federation adds further capabilities to an Apollo GraphQL server by allowing it to be a part of a super-schema, with which clients communicate. Each instance of a federated schema provides a sub-schema that implements new types or extends types from other sub-schemas. All sub-schemas are combined into a single gateway by Apollo Gateway, which manages query planning and enforces the integrity of the super-schema. There are many great resources on this topic - the Apollo Federation docs are very detailed. I also recommend watching James Baxley III's talk "Apollo Federation - A revolutionary architecture for building a distributed graph." This post will not be focusing much on the implementation part but more on the deployment on DigitalOcean's App Platform.

The Application

The full codebase of this tutorial can be found on this GitHub repository.

Let's assume we will be building and deploying a skeletal crypto exchange app. Our bare-bones application is composed of two domains - users and orders. All of the code will be in a single repository, but each app is its Node.js project and we have no shared code. The mono-repo approach works great for small teams, and the developer experience is significantly better. 

An important note here is that we will not be using *Yarn Workspaces *in this mono-repo. Even though the idea behind it is neat, it makes the CI/CD process way messier. The culprit here is that we only have a single yarn.lock file, which all services need to use. This means:

  1. The build cache in our CI will always bust for every subproject, even though we have a change in only one.

  2. The build context needs to be the root of the mono-repo, which means needlessly copying the source of the other subprojects.

  3. Harder to split subprojects into separate repositories.

Not using workspaces also has its downsides:

  1. We lose hoisting, meaning there will be node modules that are installed multiple times in the different subprojects. I believe that this is a reasonable price to pay, as storage in development environments is usually not a big concern. If you're starting a new project you can swap yarn with pnpm, which also implements the workspace idea without the problems outlined above.

  2. Code duplication will emerge. Some files and folders are 100% identical on multiple subprojects. If we follow the DRY principle, this is a no-no. However, by having some code duplication, we retain the flexibility to tweak stuff down the road for each component. The DRY principle is that - a principle, not a rule. Trying to realize it religiously can cause more harm than benefits. Check out this excellent talk on the topic by Dan Abramov.

Implementation

The general outline of our mono-repo looks the following:

Project Structure

The users and orders subprojects have identical project structures that create and expose a simple schema. The "users" schema contains the type "User" and a GraphQL query "me" which returns the authenticated user. By using type-graphql, we can construct this with just a few lines of code:

// packages/users/src/resolvers/user-resolver.ts const users = [{ id: "749ada2c-d348-45c5-ac21-64a018b24b82" }]; @ObjectType() @Directive('@key(fields: "id")') export class User { @Field() id: string; } @Resolver() export class UserResolver { @Query(() => User) me() { return users[0]; } }

It's a very expressive library to use if you are going the Typescript route, saving tons of lines code and potential bugs. Recommend it! Back to our code. The "orders" subproject defines the "Order" type and extends the "User" type by adding a field to it called "orders":

// packages/orders/src/resolver/order-resolver.ts const orders = [ { id: "062e5a2d-6fb4-45ea-b928-f069a5a38240", cost: 100, ownerId: "749ada2c-d348-45c5-ac21-64a018b24b82", }, ]; @ObjectType() @Directive("@extends") @Directive('@key(fields: "id")') export class User { @Field() @Directive("@external") id: string; @Field(() => [Order]) orders: Order[]; } @ObjectType() export class Order { @Field() id: string; @Field() cost: number; @Field() owner: User; } @Resolver(() => User) export class UserResolver { @FieldResolver(() => [Order]) orders(@Root() { id }: User) { return orders.filter((row) => row.ownerId === id); } }

Both the "orders" and "users" Apollo servers need to be "federated" for the gateway to be able to work with them. You can't just pass the schema to the ApolloServer constructor, but you need to pass the result of buildFederatedSchema (from the @apollo/federation package) as the schema:

// packages/orders/src/index.ts async function buildApp() { const schema = await buildSchema({ resolvers: [path.resolve(__dirname, "resolvers/**/*-resolver.ts")], }); const federatedSchema = buildFederatedSchema({ typeDefs: gql(printSchema(schema)), resolvers: createResolversMap(schema) as any, }); return new ApolloServer({ schema: federatedSchema, }); } const port = process.env.PORT || 4002; buildApp() .then((server) => server.listen(port)) .then(({ url }) => console.info(`Orders server listening on ${url}.`));

To bring those two together, we need the gateway. For it to work, two items need to be fulfilled:

  1. All of your services need to be up. If any of the services is not reachable, the gateway will not be built and the whole app will crash.

  2. The supergraph needs to be a valid GraphQL schema. Meaning it's subject to all of the checks a regular schema needs to pass - no mismatches or ambiguities on the defined types are allowed. The package outputs very useful info when something is not right, outlining the specific service that is problematic, so this is great for avoiding any miscommunication between teams that might have different assumptions over the parts of the graph that don't belong to them.

In order to fulfill 1. we use the wait-on npm package, which constructs a promise that resolves when all of the given URLs have started a TCP connection:

// packages/gateway/src/index.ts waitOn({ resources: serviceList.map((service) => service.url), validateStatus: () => true, timeout: 60 * 1000, });

One important trick is to start the gateway server before adding the GraphQL middleware to it because DigitalOcean follows no particular order when starting your backend services. So in case, the gateway is not attempted to be started last, the waitOn promise will timeout, and the whole deployment will fail. This is why we bind to the 4000 port even though we have nothing yet, and when the services are ready we add the GraphQL layer to the express server.

Once every component is running, we can start our gateway and inspect the explorer:

Sandbox Query

We can see that we can query "orders" on the "User" object in the "me" query. Neat! We are ready to deploy.

Deployment

DigitalOcean provides a very handy and programmatic way to deal with the specification of an application and its subcomponents by using a YAML file called the App Specification. This reference is a must-have bookmark.

We define 3 web services - users, orders, and gateway. Only the gateway exposes a public path, while it communicates with the other services using their private URL.

Some noteworthy points in the app-spec.yml file:

  • DigitalOcean offers images for programming languages that are equipped with the tools needed to run a service. In our case that will be the Node.js image. We mark this by using the environment_slug: node-js option for each of the services.

  • For minimal DevOps efforts, we can automate deploys by using the deploy_on_push: true option and a new deploy will be triggered each time there is a new push on the branch you select, in our case it's master.

  • Notice how only the gateway has a public route (/). The other services are exposed through port 80 in an internal network, which the gateway reference simply as ${users.PRIVATE_URL} and ${orders.PRIVATE_URL}.

The easiest way to create an app using an app-spec file is by using doctl. After you have successfully installed and authenticated with the CLI tool, simply run

doctl apps create --spec app-spec.yml

And that's it! When you go to the DigitalOcean dashboard, under Apps you will see that your application is created and your first deployment is running. After completion, you can access the gateway using a generated public URL, which you can further override with your custom domain.

Sum-Up

We just created an app with a microservice architecture and applied the gateway pattern, all contained in a mono-repo. We then deployed an environment for the app on DigitalOcean with 0-downtime updates on every GitHub push. Having separated the components, we can monitor their performance under the Insights tab and if necessary, scale each component individually - both vertically and horizontally!

Of course, there are some drawbacks and limitations with this approach that need to be mentioned:

  • Currently Apollo Federation does not support GraphQL subscription operations. If you rely on subscriptions in your app, some extra steps need to be taken to make it work. You can check out this article that goes through a decoupled architecture with a federated schema alongside a subscription service, to which your client connects. Other alternatives might be using SaaS solutions like Pusher or Firebase to push real-time data to clients.

  • The federation query planner might not always make the best decisions on how to fetch and combine data, so if you are running into performance issues with queries that span multiple services, make sure to check out the Query Plan in the GraphQL playground to get a better sense of what happens when a client sends a query. There are some directives designed to hint to the query planner on how to do things best (e.g. @provides, @requires).

  • As of October 2021, there is no autoscaling on the App Platform, it's still WIP and expected to arrive by the end of the year. Once introduced, the platform will cover the automation of both the deployment and reliability, a welcome feature for dev teams.

The repository for this tutorial can be found on Lexis Solutions' GitHub profile.

Lexis Solutions is a software agency in Sofia, Bulgaria. We are a team of young professionals improving the digital world, one project at a time.

Contact

  • Deyan Denchev
  • CEO and Co-founder
© 2022 Lexis Solutions. All rights reserved.