Seamless gRPC to GraphQL Federation with Grafbase Extensions and the Composite Schemas spec

Tom HouléTom Houlé

Seamless gRPC to GraphQL Federation

GraphQL Federation offers a powerful vision: a single, unified API over a decoupled multi-service architecture. However, traditional approaches often come with a significant architectural tax. They require every underlying service to be a GraphQL server, forcing teams to build and maintain new GraphQL wrappers around their existing, perfectly functional services, like for example gRPC services. This introduces an unnecessary layer, requiring new skill sets (GraphQL server implementation) and adding maintenance overhead for code that only serves as a translation layer.

This raises a crucial question: What if you could achieve true federation without adding these costly architectural layers? What if you could federate your existing gRPC services directly, keeping them as high-performance, native services?

With Grafbase Extensions, this is possible. By combining the power of the emerging Composite Schemas specification with the open source Grafbase gRPC extension, you can declaratively map your existing gRPC services into a high-performance federated graph. This approach makes adopting federation an easier, faster, and less risky proposition, allowing you to leverage the infrastructure you've already built.

The composite schemas specification is the next evolution of GraphQL Federation, being developed by an open GraphQL Foundation working group with the most important actors in the federation space.

The core principle of this approach is reuse. You don't need to rewrite your services or create new, purpose-built GraphQL subgraphs. You can take the gRPC services you already have and, with a few simple annotations, make them part of your federated graph. The value of your existing, battle-tested services is preserved and enhanced, not replaced.

The developer workflow is straightforward. You add declarative annotations directly into your .proto files and use our protoc-gen-grafbase-subgraph protobuf compiler plugin. It also works with buf, of course, if you prefer that. This tool reads your Protobuf definitions and the embedded options to automatically generate a complete GraphQL subgraph schema that the Grafbase Gateway with the gRPC extension can understand. The gRPC service themselves remain unchanged; no new code needs to be written or deployed.

For example, to mark an existing Location message as a federated entity that can be referenced by other services, you simply add the grafbase.graphql.key option to its definition:

// from locations.proto message Location { // This simple option marks Location as an entity // that can be referenced by other services. option (grafbase.graphql.key) = { fields: "id" }; string id = 1; string name = 2; // ... other fields }

The companion protoc-gen-grafbase-subgraph protocol buffer plugin will generate the following GraphQL schema snippet for the Location entity:

type Location @key(fields: "id") { id: ID! name: String! # ... other fields }

The protobuf options, defined here, map directly and intuitively to directives in the generated GraphQL schema.

The magic that connects these services is a set of powerful, declarative directives from the Composite Schemas specification. These directives are instructions for the Grafbase Gateway, telling it how to resolve and join data across services. The combination of the right schema annotations and the advanced query planner in the gateway is what keeps your backend services simple and focused on their core logic.

In the following sections, we'll explore some of these directives in a concrete federated graph composed from three gRPC services. The services represent a very simple logistics domain, with three services: locations, products and parts. Each product is made of parts, and products and parts are stored in locations (warehouses).

You can check out the full example project in examples/grpc-composite-schemas in the Grafbase repository.

Instead of requiring specially-named entity resolvers with special signatures (as in Apollo Federation v1 and 2), the @lookup directive can turn any existing RPC method into a powerful, key-based resolver for the gateway. The only requirement is that your method takes an identifier (matching the @key definition of an entity, typically an id field) or a list of identifiers, and returns, respectively, one entity or a list of them. This flexibility is a significant advantage, as it allows you to use your services as they are, without writing new methods just for federation. Most importantly, to solve the classic N+1 query problem and ensure high performance, Grafbase supports and encourages batching for @lookup:

// from locations.proto service LocationService { // This existing RPC is now a batch-capable entity resolver // for the federated graph. rpc BatchGetLocations(BatchGetLocationsRequest) returns (BatchGetLocationsResponse) { option (grafbase.graphql.lookup) = {}; option (grafbase.graphql.argument_is) = "{ ids: [id] }"; } }

The plugin translates this into a root field on the Query type, decorated with @lookup to mark it as the resolver for locations_Location entities:

# from locations.graphql type Query { locations_LocationService_BatchGetLocations( input: locations_BatchGetLocationsRequestInput @is(field: "{ ids: [id] }") ): locations_BatchGetLocationsResponse @grpcMethod(service: "locations.LocationService", method: "BatchGetLocations") @lookup }

The @derive directive makes it trivial to create references to entities in other services. It essentially creates a "virtual field" in the GraphQL schema, that isn't resolved by your subgraph (service), but that the Gateway knows how to interpret. To take a concrete example, you can turn a userId: ID! field into a User entity with just the id field, enabling entity joins across subgraphs, with a single user: User! @derive field. The @derive annotation will work automatically for fields with an Id or _id suffix, but you can map them to arbitrary fields using @is (see below).

When a client requests this field, the directive tells the gateway how to use a key from the current service to fetch the related data from another service. The protoc plugin automatically generates the necessary "stub" types in the GraphQL schema, enabling seamless joins with minimal annotations. Here, the parts service can resolve a part's warehouse field—which is a Location entity from a completely different service—without having any knowledge of the Location object itself, just from a warehouse_id.

// from parts.proto message Part { option (grafbase.graphql.key) = { fields: "id" }; // This line creates a `warehouse: Location` field in the GraphQL schema, // joinable via the warehouse_id. option (grafbase.graphql.derive_field) = { entity: "locations_Location", name: "warehouse", is: "{ id: warehouse_id }" }; string id = 1; string warehouse_id = 7; // ... other fields }

This generates a warehouse field on the parts_Part type, which resolves to a locations_Location entity. The @derive and @is directives provide the gateway with the instructions for this join.

# from parts.graphql type parts_Part @key(fields: "id") { id: String! warehouse_id: String! warehouse: locations_Location @derive @is(field: "{ id: warehouse_id }") # ... other fields } # A stub is also created so the schema is valid. It won't be created if the type is already defined. type locations_Location @key(fields: "id") { id: String! }

In this example, each part is in one warehouse, but @derive also works on list fields, if you have a list of identifiers. For example:

message Pizza { option (grafbase.graphql.derive_field) = { entity: "Ingredient", name: "ingredients", is: "{ id: [ingredient_ids] }" }; repeated string ingredient_ids = 1; // ... other fields }

This would result in a GraphQL schema with a Pizza.ingredients list field that is resolved by calling the GetIngredients RPC, passing the pizza's ingredient ids as an argument. Batching between pizzas, and between pizzas and other types with ingredients would apply here.

Not all data relationships are simple key-based lookups. For more complex joins, such as resolving a list of components for a product, you can use the @require directive in combination with the @grpcMethod directive from the gRPC extension — or just do both at once with the grafbase.graphql.join_field protobuf option. This instructs the gateway to resolve a field by calling a specific gRPC method and passing in required data from the parent object. In this example, the Product.parts field is resolved by calling the GetProductParts RPC, passing the product's own ID as an argument, all transparently to the client.

// from products.proto message Product { option (grafbase.graphql.key) = { fields: "id" }; // This defines a "parts" field on Product, // resolved by calling a specific gRPC method. option (grafbase.graphql.join_field) = { name: "parts", service: "products.ProductService", method: "GetProductParts", require: "{ product_id: id }" }; string id = 1; // ... other fields }

The result is a parts field on the products_Product type. The @require directive tells the gateway to take the id from the parent Product and use it to populate the product_id field in the input for the GetProductParts gRPC method.

# from products.graphql type products_Product @key(fields: "id") { id: String! parts( input: products_GetProductPartsRequestInput @require(field: "{ product_id: id }") ): products_GetProductPartsResponse @grpcMethod(service: "products.ProductService", method: "GetProductParts") # ... other fields }

These joins also work with batch methods, when the @require mapping maps list fields. See the FieldSelectionMap scalar in the composite schemas spec.

The true power of this declarative approach becomes evident when a client sends a query. All the individual directives (@key, @lookup, @derive, @require) work in concert, enabling the Grafbase Gateway to construct a sophisticated query plan that fetches data from multiple services. The client interacts with a simple, unified graph, completely unaware of the query planning and orchestration happening behind the scenes.

Consider the following GraphQL query, which fetches products, their associated parts, and the warehouse location for each part:

query { products_ProductService_SearchProducts(input: {}) { products { name id warehouse_id # This will use @lookup to resolve locations via BatchGetLocations warehouse { name address city } # This field calls out to `GetProductParts` explicitly, # not through an @lookup directive parts { product_parts { # Batch lookup to the parts service part { id category name # This will use @lookup to resolve locations via BatchGetLocations. # The gateway will batch all the id lookups for all locations from # parts _and_ products, minimizing the number of roundtrips. warehouse { name address city } } } } } } }

While the gateway keeps calls to the gRPC services as few and as batched as possible, you can see how this query will lead to multiple gRPC method calls to different services. This is completely transparent to the client, and each gRPC service only has to declare which part of the API it implements, without needing to think about any other part. All requests originate from the gateway, which handles all the planning and orchestration.

This powerful capability is enabled by the Grafbase Gateway's underlying architecture, which uses extensions—secure, sandboxed WebAssembly (Wasm + WASI) modules—to connect to data sources.

The gRPC extension allows the gateway to communicate directly with your gRPC services in their native protocol. This solves two fundamental problems in federation:

  • Performance: By eliminating the overhead of translating requests to GraphQL-over-HTTP between the gateway and subgraphs, you get a much faster and more efficient system that communicates natively.
  • Simplicity: Your subgraph developers are freed from implementing complex, federation-aware logic. They can focus on writing standard RPC handlers, while the gateway manages all the complexities of query planning and data joining.

While this post focuses on gRPC, the extension model is a core feature of the gateway. It means you can compose a single, unified graph from any source — gRPC, REST, other GraphQL APIs, and even directly from databases — all with the same model, in the same API, without the need for additional layers or rewrites. All the extensions on the Marketplace are open source, but you can also build your own, private extensions — you don't have to use the Marketplace. All the join strategies we described in this post also apply for joins between GraphQL and non GraphQL subgraphs, and between subgraphs backed by arbitrary data sources or API flavours.

The future of GraphQL federation is declarative and built on the services you already have, not on rewrites and new architectural layers. By combining Composite Schemas with the Grafbase Gateway, you can reuse existing services, gain high performance through native protocols, reduce complexity in your services by avoiding federation-specific logic like entity resolvers, and achieve unmatched flexibility.

This pragmatic approach empowers teams to build a powerful, unified graph without the traditional burden of forcing every service to become a GraphQL server. It respects your existing investments and provides a clear, efficient path to modern data federation.

In this post, we took a deep dive into how it works with the gRPC extension, but the same core value proposition of gateway, composite schemas and extensions applies to the REST, Postgres, Kafka and other extensions, in any combination, just the same.

To see it in action, explore the complete example project on GitHub.

And if you have questions or ideas, please reach out!

Get Started

Start building your federated graph now.