GraphQL Federation Over gRPC: Type-Safe Subgraphs Explained - WunderGraph

Problem: Subgraph Entity Representation Fetches Are Not Type-Safe

The way Apollo distributes Entities across Subgraphs looks like this:

One Subgraph defines a root field that returns an Entity, which is defined by adding a @key directive to the type. Then, another Subgraph defines the same Entity and adds an additional field. Once we compose the two Subgraphs together, we get a Schema that incorporates all the fields from both Subgraphs.

The first Subgraph could look like this, defining a User Entity:

# User Subgraph

type Query {
  me: User!
}

type User @key(fields: "id") {
  id: ID!
  name: String!
}

The second Subgraph could look like this, defining a User Entity with an additional field:

# Posts Subgraph

type User @key(fields: "id") {
  id: ID!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
}

Now, when we compose the two Subgraphs together, we get a Schema that combines all the fields from both Subgraphs.

# Combined Schema

type Query {
  me: User!
}

type User {
  id: ID!
  name: String!
}

type Post {
  id: ID!
  title: String!
}

Now, let's say we make the following Query:

query {
  me {
    id
    name
    posts {
      id
      title
    }
  }
}

The Router would make the following request to the User Subgraph:

query {
  me {
    id
    name
  }
}

Let's assume the response from the User Subgraph is looking like this:

{
  "data": {
    "me": {
      "id": "1",
      "name": "Graf Cue El"
    }
  }
}

The Router would then follow up with the following fetch to the Posts Subgraph:

query Query($representations: [_Any!]!) {
  _entities(representations: $representations) {
    ... on User {
      posts {
        id
        title
      }
    }
  }
}

The full request, including the variables, would look like this:

{
  "query": "query Query($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { posts { id title } } } }",
  "variables": {
    "representations": [
      {
        "__typename": "User",
        "id": "1"
      }
    ]
  }
}

The main problem with this approach is that the Subgraph must implement an _entities field that accepts a list of _Any objects. There's not much you can do at compile time to ensure that the Subgraph is implemented correctly. Discussions with our customers show that this "hack" is a common source of bugs. We've been asked more than once to advise customers on solutions to "prove" the correctness of a Subgraph implementation.

Solution: Replacing Apollo Subgraphs with gRPC Services

We have been in the market of GraphQL Federation for a few years now, so we've been able to build a lot of relationships with Federation users and learn about their Architecture, processes, tooling, and workflows.

One very common pattern we've seen is that many organizations build GraphQL Subgraph shim services that sit on top of gRPC services. They felt like gRPC was the more mature technology to implement internal APIs, while GraphQL was more like a frontend-facing technology that sits on top of the internal APIs. For backend engineers, gRPC seems like an approach that is easy for them to reason about and implement.

All of this made us think about removing the "intermediate" layer of GraphQL Subgraph shim services. If frontend engineers prefer to work with GraphQL, and backend engineers want to work with gRPC, why can't the Router take the responsibility of directly fetching data from the gRPC services and translating between GraphQL Queries and gRPC Messages? This is the same model we use for Cosmo Connect — and it applies equally well here.

So that's what we've been working on. A Subgraph to gRPC Compiler and an Adapter in the Router that translates between the two API styles.

The Subgraph GraphQL SDL to gRPC Compiler

Let's tackle the problem step by step. First, we needed to build a compiler that takes a GraphQL SDL and compiles it into a gRPC proto document.

Let's take a look at how the User Subgraph would look like:

# User Subgraph

type Query {
  me: User!
}

type User @key(fields: "id") {
  id: ID!
  name: String!
}

If we run this through the compiler, the proto document would look like this:

syntax = "proto3";
package service;

option go_package = "github.com/wundergraph/cosmo/plugin";

// Service definition for UsersService
service UsersService {
  // Lookup User entity by id
  rpc LookupUserById(LookupUserByIdRequest) returns (LookupUserByIdResponse) {}
  rpc QueryMe(QueryMeRequest) returns (QueryMeResponse) {}
}

// Key message for User entity lookup
message LookupUserByIdRequestKey {
  // Key field for User entity lookup.
  string id = 1;
}

// Request message for User entity lookup.
message LookupUserByIdRequest {
  /*
   * List of keys to look up User entities.
   * Order matters - each key maps to one entity in LookupUserByIdResponse.
   */
  repeated LookupUserByIdRequestKey keys = 1;
}

// Response message for User entity lookup.
message LookupUserByIdResponse {
  /*
   * List of User entities in the same order as the keys in LookupUserByIdRequest.
   * Always return the same number of entities as keys. Use null for entities that cannot be found.
   */
  repeated User result = 1;
}

// Request message for my operation.
message QueryMeRequest {
}
// Response message for my operation.
message QueryMeResponse {
  User me = 1;
}

message User {
  string id = 1;
  string name = 2;
}

You'll notice two RPC methods, LookupUserById and QueryMe. These are the two entry points we need to implement. The QueryMe method is responsible for returning the me root field. The LookupUserById method was generated to satisfy the @key directive by creating an entry point to extend the User type.

One observation you might have is that the LookupUserByIdRequestKey field is repeated. We're not sending a single key, which would create an N+1 problem. Instead, we're implementing the data loader pattern at the Router level, which automatically batches "lookups" for the same entity and Subgraph. If you're curious how the Router decides which fetches to batch, this post covers the underlying mechanism.

This is not just an improvement in terms of performance but it also makes the implementation much easier to reason about. It's best practice to always implement the data loader pattern at the Subgraph level. With gRPC replacing GraphQL Subgraphs, data loading is one less problem backend engineers have to worry about. It just works, allowing the backend engineer to focus on the business logic.

Implementing the gRPC to GraphQL Adapter at the Router Level

Mapping between GraphQL and gRPC isn't trivial due to fundamental differences in how both ecosystems model data and APIs. Both styles are schema-first, but GraphQL is way more flexible and dynamic, inheriting the good and bad parts of JSON. At the same time, gRPC is way more rigid and static. It's optimized for performance and memory usage, and it comes with data types that aren't available in JSON, such as differentiating between floats, doubles, and integers, while JSON only has numbers.

Core Mapping Rules Between GraphQL and gRPC

Types:

Every GraphQL type becomes a message in Protocol Buffers. Each field in a GraphQL type gets a corresponding field in the message, annotated with a unique tag number (like field1 = 1).

Scalars:

Built-in GraphQL scalars are mapped to their Protocol Buffers equivalents:

Custom scalars require explicit definitions or fallback types.

Enums:

GraphQL enums are directly mapped to protobuf enums, with values given numeric identifiers.

Input Types:

GraphQL input objects are mapped in the same way as regular messages, since both are essentially request payloads.

Challenges of Mapping GraphQL to gRPC

One major challenge is the loss of expressiveness. GraphQL's optionality and unions don't translate directly into Protobuf's more rigid model. The mapping must enforce stricter typing (e.g., no null vs. nullable fields) and resolve things like default values and repeated fields carefully. Tag numbering in protobuf also demands discipline and uniqueness, which is absent in GraphQL.

Proto Lock File: Preventing Breaking Changes

Another challenge we faced was the significance of field numbers in Protobuf. In a GraphQL SDL, the order of fields doesn't matter. You can add new fields to the end of the type definition and later move them to the top, without affecting the API at all. In comparison, each field in a Protobuf message has a unique field number.

Imagine the following scenario:

You create a field in a proto file to represent the id and name of a User type.

Now, you want to remove the name field. Your proto file now looks like this:

Now, you want to add a new field to the User type to represent the age of the user.

We now have one version of the message where the number 2 is assigned to the age field and a previous version where the number 2 was assigned to the name field. Both fields are not just semantically different; they are also incompatible types.

To solve this problem, we've introduced a "proto lock file" that will keep track of previous versions of the proto file. This way, we can use the reserved keyword to block number reuse.

Conclusion

As a next step, you can learn more about gRPC Services in the documentation. If you want to see how this compares to the Apollo approach, Cosmo Connect vs Apollo Federation covers the differences side by side. And if you'd rather keep everything in-process and let an LLM generate the adapter code instead of hand-rolling a gRPC service, Router Plugins are the lighter-weight alternative.

We've also prepared a quickstart tutorial so you can try out the new approach yourself.

This is our first step towards improving the Federation experience and making it less dependent on GraphQL.

Frequently Asked Questions (FAQ)

What problem does the Subgraph to gRPC Compiler solve?

It replaces Apollo’s _entities resolution mechanism with a strictly typed gRPC interface. Apollo’s _entities field accepts a list of untyped _Any objects, so a mismatch between the SDL and the implementation can only be caught at runtime. Discussions with customers show this is a common source of bugs. The compiler eliminates the untyped payload and makes entity fetches compile-time safe.

How does this approach fix the N+1 problem in Federation?

The compiler generates LookupById RPC methods that accept repeated keys, a list rather than a single key, so the Cosmo Router can automatically batch entity lookups for the same entity and subgraph. This applies the dataloader pattern at the router level by default, so backend engineers do not need to implement it themselves.

Why use gRPC instead of GraphQL for Subgraph services?

Many backend teams already use gRPC internally and treat it as the more mature technology for internal APIs, with GraphQL as a frontend-facing layer on top. By compiling GraphQL SDL to gRPC, Cosmo lets the Router act as that bridge directly: frontend teams keep GraphQL, backend teams get type-safe gRPC services, and the GraphQL shim layer in between disappears.

What does the gRPC to GraphQL adapter in the Router do?

It translates GraphQL queries into gRPC messages using schema-first mapping rules. The adapter handles field types, enum conversion, nullability, and input/output schemas while preserving strict typing via deterministic proto generation.

How does the proto lock file prevent breaking changes?

Protocol Buffers assign a unique field number to each field. If a field is removed and its number gets reused for a different field later, the two versions become incompatible types. The proto lock file tracks field numbers across schema generations and uses the reserved keyword to block number reuse, preventing that kind of breaking change.

Can gRPC subgraphs be written in languages other than Go?

Yes, for gRPC services deployed independently of the router: the protocol between the router and a gRPC service is gRPC, not GraphQL, so any language with gRPC support works, including Python, Java, C#, Node.js, and Rust. Router plugins, which run as local processes managed by the router, currently support Go and TypeScript (via Bun), with more languages planned.

Do frontend teams need to change how they use GraphQL?

No. GraphQL stays on the client side. The gRPC replacement happens between the Router and backend services, so frontend teams keep querying a single unified supergraph exactly as before.

Where can I try the Subgraph to gRPC Compiler?

You can follow the gRPC Services Quickstart tutorial or read the gRPC Services documentation to get started building gRPC-backed Subgraphs.