How GraphQL Saved Us from the gRPC Dumpster Fire We Created

REFERENCEWEB
8 min read

DoltHub is the online data community powered by Dolt, the version-controlled SQL database. In the previous article in this series, we took an overview of DoltHub's front-end architecture. In this article, we'll take a look at the pit of sadness our developers were lost in while working directly with DoltHub's gRPC API from the front end, and how we climbed out using GraphQL and Apollo.

Matt is not concerned with the gRPC web clients
Me circa December 2019

Let me say off the bat that this post is not intended to disparage gRPC or any of the associated projects in any way. gRPC is fantastic technology and we continue to use it heavily, just not in all the same ways and places we did before. As the title of this post suggests, our misery was ultimately of our own making.

So what is gRPC, anyway?

gRPC (short for "gRPC Remote Procedure Calls") is an open-source, platform-agnostic, high performance RPC framework originally developed at Google in 2015, and open-sourced shortly after.

Like any RPC framework, it allows you to call a set of predefined functions on another computer: you send that computer the name of the function and its parameters, the remote computer does the calculation, and it sends you back the return value.

gRPC uses Protocol Buffers (protobufs) for serializing the structured data its connections carry. Protobufs are kind of like a futuristic, binary version of JSON—similar idea, but much more streamlined, designed to send as few bytes over the wire as possible.

Thanks to gRPC's usage of protobufs, gRPC APIs are statically typed, and a ton of code can be generated for them in various languages: clients, service stubs, type definitions, etc.

Sounds great...so what's wrong with it?

Well, nothing is really wrong with gRPC itself, as far as we're concerned, although we did find some of the documentation to be a bit lacking/confusing. (It's possible the situation has improved since then.)

But when we first started building DoltHub as a new TypeScript/Next.js application waaaaaay back in early 2019, we were using generated gRPC clients (JavaScript, with separate TypeScript definitions added by ts-protoc-gen) directly from the front end. This was not fun—for a bunch of reasons.

First of all, the clients define a verbose, object-oriented, callback-based API that goes against the functional grain of React:

export class UserServiceClient {
  readonly serviceHost: string;

  constructor(serviceHost: string, options?: grpc.RpcOptions);
  createUser(
    requestMessage: ld_services_dolthubapi_v1alpha1_user_pb.CreateUserRequest,
    metadata: grpc.Metadata,
    callback: (
      error: ServiceError | null,
      responseMessage: ld_services_dolthubapi_v1alpha1_user_pb.User | null
    ) => void
  ): UnaryResponse;
  createUser(
    requestMessage: ld_services_dolthubapi_v1alpha1_user_pb.CreateUserRequest,
    callback: (
      error: ServiceError | null,
      responseMessage: ld_services_dolthubapi_v1alpha1_user_pb.User | null
    ) => void
  ): UnaryResponse;
}

The type definition for our generated UserServiceClient in an imaginary world where UserService only has one RPC.
Notice the callbacks instead of promises—who still does that?

Due to this design, calling a method with some simple parameters takes way more code and effort than it should (and we're not even handling errors in this example):

// How it should be:

const user = await api.createUser({
  name: `users/${username}`,
  identityProviderToken: {
    provider: IdentityProvider.GOOGLE,
    token: googleIdToken,
  },
});
console.log("user:", user);

// How it is:

const req = new CreateUserRequest();
const token = new IdentityProviderToken();
token.setProvider(IdentityProvider.GOOGLE);
token.setToken(googleIdToken);
req.setIdentityProviderToken(token);
req.setName(`users/${username}`);
api.createUser(req, (err, user) => {
  console.log("user:", user);
});

🤦‍♂️

As much as we complained about the generated clients (and still do, when we have to reach under the hood of our GraphQL server to work with them), it makes sense that they are what they are, since they're also not very easy to change. I know because I spent the better part of a day or two trying before throwing a couple of fingers up in defiance (not my thumbs)...and then sinking back into my chair, accepting my defeat.

Furthermore, since the clients are not actually written in TypeScript, the type definitions produced for them by ts-protoc-gen are occasionally incorrect. In one particularly damning and damaging incident, type definitions for protobuf enums needed to be changed when it was discovered that the generated code was not in fact compatible with native TypeScript enums. This was a breaking change for us.

These fundamental issues made it difficult to fix our smaller issues, which were myriad:

  • The clients can't be easily changed to use Promises instead of callbacks, so we had to add a layer of icky wrapping code to promisify them at runtime (we not littering our application code with callbacks, we are civilized people here).

  • It's not easy to query the API; there's no developer dashboard, and curling a gRPC endpoint is a little more complicated than your typical REST API.

  • Little out-of-the-box support for integrating with React for things like errors, loading states, etc. We ended up building our own hooks for these. This was more code to maintain and they weren't that great frankly. (I can say that—I wrote most of them.)

  • Type duplication everywhere. Got a component that only needs a few of the fields from a type? Either bite the bullet and make it take the whole type anyway, or use something like Pick to select the fields. We had a gazillion of these derived Pick types lying around, cluttering our code and causing cryptic type errors when things went wrong.

  • The data rarely came back from the gRPC API in a display-ready format, so tons of data formatting logic ended up in our front end code.

  • gRPC web clients seem to be kind of an afterthought, and documentation is not fantastic. The official gRPC organization recently adopted the project from a third-party contributor, so perhaps we'll see improvement in this area.

Finally (and this is not really gRPC's fault) our Go/gRPC API is difficult (read: nigh-impossible) to run locally, which made it difficult for our developers to iterate on fixes and new features.

As our fatigue and frustration with these issues grew (along with the number of handwritten lines of code in our repository dedicated to mitigating them) it became clear that we needed to find a new solution for communicating between the front end and the gRPC API.

GraphQL to the rescue

Unless you've been living under a Java monolith for the last half-decade or something, you know GraphQL has been having a bit of a moment lately. Originally developed at Facebook and released publicly in 2015, it now boasts considerable adoption and an impressive array of big-name users.

GraphQL bills itself as "a query language for your API". What does that mean?

It means you can take all the data sources your app relies on and stitch them together into a single data graph, with a single schema that describes every available operation. You can then query the graph with requests which are shaped like the data they ask for.

Let's try an example. Say you've got a blog with users who each have posts. Because it's 2020 and your devops team is a bunch of hipsters, users and blog posts are hosted on different services, written in different languages, floating around somewhere in your Kubernetes cluster.

The promise of the graph is that your front end never needs to worry about such things again. Connect all your data sources to your GraphQL server, point the front end at it, and it's off to the races!

Now when the front end needs some user data and the user's latest blog post to display their profile page, this is what it sends the GraphQL server:

query Profile($username: String!) {
  user(username: $username) {
    id
    displayName
    location
    latestBlogPost {
      id
      title
      excerpt
      slug
      timestamp
    }
  }
}

As you might expect, this query requests the fields id, displayName, and location of the user with the specified $username. It also requests the object field latestBlogPost with its listed subfields. Notice that the query is not at all concerned with where this data ultimately resides; that's the GraphQL server's problem now.

If and when the query resolves, the response will only carry the fields that were specifically requested. This is unlike most traditional APIs, where if you just want the value of a field or two, you end up receiving the whole object anyway.

Thus, the GraphQL server's response for the query above would be shaped like this:

{
  "user": {
    "id": 1,
    "displayName": "Matt",
    "location": "Santa Monica, CA",
    "latestBlogPost": {
      "id": 5,
      "title": "Probably another article about DoltHub",
      "excerpt": "Matt wrote some sketchy TypeScript and then blogged about it again",
      "slug": "2020-05-18-only-read-if-really-bored-lol",
      "timestamp": 1589544791020
    }
  }
}

Pretty cool, right? And that's just scratching the surface.

For example, while gRPC's code generation struck us as lackluster at best, we're thrilled by what we can generate from our GraphQL schema using GraphQL Code Generator. Revisiting the gross createUser example from earlier, we now have:

// before, with gRPC:

const req = new CreateUserRequest();
const token = new IdentityProviderToken();
token.setProvider(IdentityProvider.GOOGLE);
token.setToken(googleIdToken);
req.setIdentityProviderToken(token);
req.setName(`users/${username}`);
api.createUser(req, (err, user) => {
  console.log("user:", user);
});

// now, with generated GraphQL hooks:

const [createUser] = useCreateUserMutation();
const { data } = await createUser({
  variables: { username, googleIdToken },
});
console.log("user:", data.createUser);

Mutation hooks also return loading and error properties which are reactively updated, so you can do stuff like this:

const { data, error, loading } = await createUser({
  variables: { username, googleIdToken },
});

if (loading) render <Loader />
if (error) render <ErrorMsg error={error} />
if (!data) render <ErrorMsg error={"no data returned by server"}>

return <UserProfile user={data.currentUser} />

Almost too easy!

Under the hood, the GraphQL server still uses the gRPC API clients, but they're much easier to tolerate in this context: deal with them once while setting up your resolvers (the bits of code defined in your GraphQL server which execute and resolve query results), and then you can use the nice ergonomic hooks throughout the actual front end. The ugliness is contained and separated from the rest of the system.

The DoltHub front end uses GraphQL for almost all of its API interactions now, which has enabled a lot of nice patterns that have left DoltHub much cleaner, sturdier, and easier to work on than ever before.

Conclusion

In the next article in this series, we'll dive deeper into our use of GraphQL and highlight some of our favorite things about using it for DoltHub. Nothing is perfect, so we have a few complaints to share as well, but the story is overwhelmingly a happy one.

We'll also give some details on our actual implementation: the server is a NestJS application using Apollo Server, and we wired an Apollo client instance into our Next.js front end. There were some wrinkles, which we will discuss.

In the meantime, if you haven't tried Dolt and DoltHub yet...well, what are you waiting for? The future of data is waiting for you to dive in. And if there's anything else you'd like to see covered, or you have any other feedback, please don't hesitate to contact us.

SHARE

JOIN THE DATA EVOLUTION

Get started with Dolt

Or join our mailing list to get product updates.