Nested GraphQL Resolvers & Separating Concerns

Last updated Invalid date
Two approaches to implementing separation of concerns and better cohesion of operations in your GraphQL schema.

Unfortunately, in GraphQL, you can't nest operations to apply better grouping (namespacing / separation of concerns) to your services.

Here's an example. I was building my own personal data graph with all kinds of cool things on it like my spotify, my Google calendar, and my github activity.

You can check out my personal data graph at stemmlerjs-graph.netlify.com

I wanted to design the schema where each service lived at the top-level. That would enable me to separate concerns and place all operations within the services like so:

schema.graphql
type Spotify {
  getCurrentSong (): Song
  getRecentlyListenedToSongs (): [Song]!
  ...
}

type Calendar {
  getCalendarForMonth (month: String!, year: Integer): CalendarResult!
  ...
}

type GitHub {
  getRecentActivity (): GitHubActivityResult!
  ... 
}

type Query {
  spotify: Spotify
  calendar: Calendar
  github: GitHub
}

Looking at the structure of the root Query type, you'd assume that the resolvers object would assume the same shape of the Query type, like so.

resolvers.ts
const resolvers = {
  Query: {
    spotify: {
      // Will not get invoked.
      spotifyGetCurrentSongPlaying: async () => {
        const currentSongResult = await getCurrentSong.execute();
        return currentSongResult.isRight() ? currentSongResult.value : null
      }
    }
  }
}

Unfortunately, this won't work. It's not that it isn't valid GraphQL, but it's just that if we were to do this, none of our nested resolvers will ever get invoked.

With ApolloServer, if we want to refer to a type nested deeper than one level, that type needs to be defined as its own attribute on the resolvers object like the following.

resolvers.ts
const resolvers = {
  Query: {
    spotify: () => ({}) // Return nothing
  },

  Spotify: {
    spotifyGetCurrentSongPlaying: async () => {
      const currentSongResult = await getCurrentSong.execute();
      return currentSongResult.isRight() ? currentSongResult.value : null
    }
  }
}

This works!

Although on a larger application, we'd like to be able to enforce some sort of namespacing. Some separation of concerns.

Take the following example of a GraphQL schema with the users and movies subdomains. Without namespacing, we end up with large schemas that look like the following.

schema.graphql
type Query {
  Movie(_id: String, movieId: ID, title: String, year: Int, description: String, first: Int, offset: Int, orderBy: [_MovieOrdering]): [Movie]
  Actor(actorId: ID, name: String, _id: String, first: Int, offset: Int, orderBy: [_ActorOrdering]): [Actor]
  User(userId: ID, name: String, _id: String, first: Int, offset: Int, orderBy: [_UserOrdering]): [User]
}

type Mutation {
  CreateMovie(movieId: ID, title: String, year: Int, description: String): Movie
  UpdateMovie(movieId: ID!, title: String, year: Int, description: String): Movie
  DeleteMovie(movieId: ID!): Movie
  AddMovieActors(from: _ActorInput!, to: _MovieInput!): _AddMovieActorsPayload
  RemoveMovieActors(from: _ActorInput!, to: _MovieInput!): _RemoveMovieActorsPayload
  AddMovieRatings(from: _UserInput!, to: _MovieInput!, data: _RatingInput!): _AddMovieRatingsPayload
  CreateActor(actorId: ID, name: String): Actor
  UpdateActor(actorId: ID!, name: String): Actor
  DeleteActor(actorId: ID!): Actor
  AddActorMovies(from: _ActorInput!, to: _MovieInput!): _AddActorMoviesPayload
  RemoveActorMovies(from: _ActorInput!, to: _MovieInput!): _RemoveActorMoviesPayload
  CreateUser(userId: ID, name: String): User
  UpdateUser(userId: ID!, name: String): User
  DeleteUser(userId: ID!): User
  AddUserRating(from: _UserInput!, to: _MovieInput!, data: _RatingInput!): _AddUserRatingPayload
  RemoveUserRating(from: _UserInput!, to: _MovieInput!): _RemoveUserRatingPayload
}

A poorly namespaced GraphQL schema.

You may be able to see that the grouping and cohesion between related operations are not present here. It's visually challenging to group all operations related to users, actors, and movies into units. Everything is mixed together.

In Domain-Driven Design, each subdomain contains only the operations that are related to that subdomain. Subdomains are well encapsulated.

For example, looking at these operations, I can deduce that we have two subdomains- a users one and a movies one (actor is a concept that would also belong to movies).

Therefore,

  • All the operations for users go into the users subdomain.
  • All the operations for movies go into the movies subdomain.
users.schema
type Query {
  User(userId: ID, name: String, _id: String, first: Int, offset: Int, orderBy: [_UserOrdering]): [User]
}

type Mutation {
  CreateUser(userId: ID, name: String): User
  UpdateUser(userId: ID!, name: String): User
  DeleteUser(userId: ID!): User
  AddUserRating(from: _UserInput!, to: _MovieInput!, data: _RatedInput!): _AddUserRatedPayload
  RemoveUserRating(from: _UserInput!, to: _MovieInput!): _RemoveUserRatedPayload
}
movies.schema
type Query {
  Movie(_id: String, movieId: ID, title: String, year: Int, description: String, first: Int, offset: Int, orderBy: [_MovieOrdering]): [Movie]
  Actor(actorId: ID, name: String, _id: String, first: Int, offset: Int, orderBy: [_ActorOrdering]): [Actor]
}

type Mutation {
  CreateMovie(movieId: ID, title: String, year: Int, description: String): Movie
  UpdateMovie(movieId: ID!, title: String, year: Int, description: String): Movie
  DeleteMovie(movieId: ID!): Movie
  AddMovieActors(from: _ActorInput!, to: _MovieInput!): _AddMovieActorsPayload
  RemoveMovieActors(from: _ActorInput!, to: _MovieInput!): _RemoveMovieActorsPayload
  AddMovieRatings(from: _UserInput!, to: _MovieInput!, data: _RatingInput!): _AddMovieRatingsPayload
  CreateActor(actorId: ID, name: String): Actor
  UpdateActor(actorId: ID!, name: String): Actor
  DeleteActor(actorId: ID!): Actor
  AddActorMovies(from: _ActorInput!, to: _MovieInput!): _AddActorMoviesPayload
  RemoveActorMovies(from: _ActorInput!, to: _MovieInput!): _RemoveActorMoviesPayload
}

There's still references between subdomains, and that's ok, because in software, we eventually need to connect pieces together.

That said, it's a good idea to be explicit about those relationships- this is where tools like Apollo Federation come in handy because we can compose schemas and be precise about where fields are resolved from between services.

Mutations

Unfortunately, the story of nested resolvers for mutations is a lot shorter. See here and here.

Solutions

Let's look at two approaches to remedy our design issue.

1. Enforcing a GraphQL-operation naming pattern for both queries and mutations

Best for modular monolith applications where several subdomains are housed from within the same project (or in fancy DDD-talk, the same bounded context).

With a modular monolith, users might be folder with everything related to users, where movies is its own folder with everything related to movies, as per screaming architecture.

DDDForum, the app that we build at the end of solidbook.io using Domain-Driven Design, is a modular monolith.

Suggested by @dncrews on GitHub, he suggests using the following GraphQL-operation naming pattern.

<primaryResource><Action><SecondaryResource>

Updating the previous schema with this pattern may make it look like this.

graphql.schema
type Mutation {
  # Movies subdomain
  movieCreate
  movieUpdate
  movieDelete
  movieAddActor
  movieAddActors
  movieRemoveActor
  movieRemoveActors
  movieAddRatings
  actorCreate
  actorUpdate
  actorDelete

  # Users subdomain
  userCreate
  userUpdate
  userDelete
  userAddRating
  userRemoveRating
}

It's not the perfect solution, but it's good enough for many use cases.

Modularizing your schema: If you're not into having everything in one file, there are several ways you can modularize your GraphQL schema. Two popular tools are merge-graphql-schemas and graphql-modules, though I've also resorted to simple string interpolation with Apollo Server.

2. Federated services

If you're part of a larger organization, and other teams are can take ownership of their own GraphQL endpoints for their respective services, Apollo Federation is a good idea.

With Federation, we can get that separation of concerns at the service level by delegating operations to the appropriate GraphQL endpoint in the organization using an Apollo Gateway. Here's the configuration of an Apollo Gateway.

gateway.ts
const gateway = new ApolloGateway({
  serviceList: [
    { name: 'accounts', url: 'http://localhost:4001' },
    { name: 'products', url: 'http://localhost:4002' },
    { name: 'reviews', url: 'http://localhost:4003' }
  ]
});

const server = new ApolloServer({ gateway });
server.listen();

Configuration of an Apollo Gateway to implement a federated GraphQL architecture.

In a Federated architecture, we can use the @provides, @key, and @external directives to compose schemas, be explicit about the relationships between services, and define which service is responsible for resolving a field in question.

reviews.schema
type Review {
  body: String
  author: User @provides(fields: "username")
  product: Product
}

extend type User @key(fields: "id") {
  id: ID! @external
  reviews: [Review]
}

extend type Product @key(fields: "upc") {
  upc: String! @external
  reviews: [Review]
}

Clone and try out the Apollo Federation Demo if you're interested in this approach. To learn more about how it works and how to get started, check out the docs.

Conclusion

In order to achieve true separation of concerns at the service level, try out Apollo Federation.

If you're working on a smaller project or working in a monolith, consider enforcing a naming pattern with the namespace (subdomain) at the front of the GraphQL operation.



Stay in touch!



View more in GraphQL