Skip to content

Instantly share code, notes, and snippets.

@swalkinshaw
Last active November 13, 2023 08:40
Show Gist options
  • Save swalkinshaw/3a33e2d292b60e68fcebe12b62bbb3e2 to your computer and use it in GitHub Desktop.
Save swalkinshaw/3a33e2d292b60e68fcebe12b62bbb3e2 to your computer and use it in GitHub Desktop.
Designing a GraphQL API
@tungsheng
Copy link

This is super helpful!! Thank you for sharing!!

@danielbentes
Copy link

Great writeup! Appreciated!

@stephencorwin
Copy link

stephencorwin commented Jul 19, 2018

Awesome writeup! I think this is a fantastic baseline to follow. There are a couple rules though that are mentioned as a bit absolute, but I feel can be broken for the sake of performance or use case.

Namely:

Rule #8: Always use object references instead of ID fields.

If I have a deeply nested object or static (does not change) object, then it feels like a basic optimization to fetch this data ahead of time and reuse what has already been retrieved.

In my example, I have something to the effect of:

type Klass {
  uid: ID!
  imageUrl: String
  description: String
  health: Int
  mana: Int
  power: Int
  block: Int
}

type Hero {
  uid: ID!
  name: String!
  klassUid: ID!
}

type User {
  uid: ID!
  heroes: [Hero]
  parties: [String]
}

In this case -- I have 20 heroes associated with my User and have the need to lookup other Users, I can dramatically reduce the payload size of each query by loading the Klass metadata ahead of time and doing a client-side join/reference in JS.

render() {
  const klass: IKlass = metadata.klasses[this.props.klassUid]

  return {
    <div>
      <h1>{hero.name}</h1>
      <img src={klass.image} />
      <p>{klass.description}</p>
    </div>
  }
}

Similarly, I have an Apollo GraphQL Server resolving Redis transactions for the in-game stateful information. I definitely don't want to store all of this information in a volatile Redis instance where I am paying a high dollar for in-memory storage. I would rather store in cheaply in MongoDB. The game server is on a separate cluster than the general API because I anticipate that I will need to have many more instances of the game server (Redis + Websocket over GraphQL Subscriptions) than I will of the API.

Since there is this logistical separation, I can apply the same optimization as before for the Hero data coming from Redis. No need to return the expanded Hero query with the Klass object embedded. I can just reference it client-side with information I already have.

Given this wall of text preface...
Would it then be suitable to either:
A. Only have the klassUid field on Hero
B. Embed the Klass object on EVERY Hero
C. Include both the klassUid and the Klass embed as siblings on the Hero and leave it up to the client to only request the klassUid if the optimization is being performed client-side.

Note: I just thought of C as I was writing this response.

One issue I can see with option C though is that in the case of the game server example above, I don't have an active connection to Mongo -- only Redis.

thoughts?

@swalkinshaw
Copy link
Author

@albertorestifo graphql-ruby has one: http://graphql-ruby.org/mutations/mutation_classes.html#example-mutation-class

Those fields get added to an automatically generated MutationNamePayload type which is returned from the mutation field.

@swalkinshaw
Copy link
Author

@stephencorwin option C seems good (at first glance). It actually kind of fits in with our Rule #13.

@inbararan
Copy link

inbararan commented Jul 23, 2018

@stephencorwin How about enabling the whole class data in the api and in the client using only the uid?
I mean using (inside the user query) klass { uid }?

(Option B as much as the api is concerned)

@jshowalter
Copy link

"follow the same design patterns layed out here" should be "follow the same design patterns laid out here"

@stephencorwin
Copy link

stephencorwin commented Jul 29, 2018

@swalkinshaw @inbararan I ended up opting for option C and it seems to be working great! I am already storing only the klassUid on the Hero anyway in the database, which means that I can provide this property in addition to the embedded Klass as siblings.

What has been truly great though, is that with only a minor amount of effort, I implemented Apollo's @client directive. If I am doing this optimization anywhere in the client, I can write a client-side resolver which resolves this fragment of the data from a previously cache result. In this case, I have a MetadataProvider which queries all the Klasses ahead of time.

Below are the results of this optimization for a User with 15 Heroes:

Test Name Duration
Vanilla Embed 23918966 (~0.023)
@client directive 1075378 (~0.001)

embedded
with-client

I think the thing I like most about this approach is that the API still provides both the Klass and User queries separately and any consumer can utilize it as they would expect (embed approach) while the client can keep the same structure and pivot with only adding the @client directive.

Note: obviously, this is a small contrived example, but I believe the more Heroes requested, the more benefit you gain from this optimization.

@smolinari
Copy link

If business decides it needs a new field, does a developer have to go in and program it...everywhere? What I am asking is, for every change in the business, does a developer need to dig into the schema? Couldn't all this be automated in some way?

Scott

@willcosgrove
Copy link

I'm not sure I understand your question @smolinari. You add a field to a type definition. That field then becomes available everywhere that type is being returned. So adding a field only requires adding it in one place.

@tim-phillips
Copy link

@smolinari You only need to define types on the server, the front-end dev can simply reference the types when writing queries and mutations on the client. Your graphql server is the source of truth.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment