Polyglot GraphQL Federation: Part 3 - When GraphQL Meets gRPC and REST

April 6, 202624 min readNew

GraphQL is not the only protocol in a federated platform. This article explores how gRPC handles internal Java-to-Java communication while REST powers Stripe payment integration in Go.

Polyglot GraphQL Federation: Part 3 - When GraphQL Meets gRPC and REST
React to this article

In Part 2, we built four federated subgraphs across three languages. Every subgraph speaks GraphQL to the Apollo Router. But internally, not every service boundary benefits from GraphQL's flexibility.

The Product Catalog needs real-time stock counts from the Inventory service for every product listing. Sending a GraphQL query for each stock check would mean HTTP/1.1 overhead, JSON parsing, and schema validation for what is fundamentally a key-value lookup. Instead, the two Java services communicate over gRPC — binary protobuf over HTTP/2, with connection multiplexing and zero-copy deserialization.

Meanwhile, the Order Service processes payments through Stripe's REST API. Stripe doesn't offer a GraphQL endpoint, and wrapping a REST client in a GraphQL layer would add complexity without benefit.

This article examines both protocol boundaries and the reasoning behind each choice.

Protocol Selection by Use Case

Loading diagram...

Three protocols serve three different purposes. GraphQL faces the client. gRPC connects internal Java services. REST integrates with Stripe's external API.

The decision matrix is straightforward:

BoundaryProtocolWhy
Client → PlatformGraphQLFlexible queries, strong typing, client-driven data fetching
Product Catalog ↔ InventorygRPCInternal, high-frequency, latency-sensitive, same-language (JVM)
Order Service → StripeRESTThird-party API, no alternative protocol available
Router ↔ SubgraphsGraphQLFederation protocol requirement

gRPC: Product Catalog to Inventory

The Case for gRPC

When a client queries products with inventory data, the Product Catalog subgraph can resolve inventory through two paths:

  1. Through the router: Return a Product entity stub, let the router fetch inventory from the Inventory subgraph via federation. This works but adds a network hop through the router for every product.

  2. Direct gRPC call: The Product Catalog calls the Inventory service directly over gRPC, bypassing the router entirely. This is faster for batch lookups and avoids the query planning overhead for a simple key-value fetch.

The platform uses path 2 for the gRPC connection, while path 1 remains available through federation for queries that originate from other subgraphs.

Protobuf Definition

The service contract lives in a shared proto file:

// proto/inventory/v1/inventory.proto
syntax = "proto3";
 
package inventory.v1;
 
option java_multiple_files = true;
option java_package = "com.ecommerce.inventory.grpc";
 
service InventoryService {
  rpc GetInventory(GetInventoryRequest) returns (GetInventoryResponse);
  rpc GetInventoryBatch(GetInventoryBatchRequest) returns (GetInventoryBatchResponse);
  rpc ReserveStock(ReserveStockRequest) returns (ReserveStockResponse);
  rpc ReleaseStock(ReleaseStockRequest) returns (ReleaseStockResponse);
  rpc UpdateStock(UpdateStockRequest) returns (UpdateStockResponse);
}
 
message GetInventoryRequest {
  string product_id = 1;
}
 
message GetInventoryResponse {
  InventoryItem inventory = 1;
}
 
message GetInventoryBatchRequest {
  repeated string product_ids = 1;
}
 
message GetInventoryBatchResponse {
  repeated InventoryItem inventories = 1;
}
 
message InventoryItem {
  string product_id = 1;
  int32 available = 2;
  int32 reserved = 3;
  string warehouse_location = 4;
  bool in_stock = 5;
}
 
message ReserveStockRequest {
  string product_id = 1;
  int32 quantity = 2;
  string order_id = 3;
}
 
message ReserveStockResponse {
  bool success = 1;
  string reservation_id = 2;
  int32 remaining_available = 3;
  string error_message = 4;
}

Five RPCs cover the full inventory lifecycle. GetInventoryBatch is critical — when the Product Catalog resolves a list of 20 products, it makes one gRPC call to fetch all 20 inventory records instead of 20 individual calls.

gRPC Server (Inventory Service)

The Inventory service implements the gRPC server using Micronaut's gRPC support:

// services/inventory-java/src/main/java/.../grpc/InventoryGrpcService.java
@Singleton
public class InventoryGrpcService extends InventoryServiceGrpc.InventoryServiceImplBase {
 
    private final InventoryService inventoryService;
 
    @Override
    public void getInventory(GetInventoryRequest request,
                             StreamObserver<GetInventoryResponse> responseObserver) {
        UUID productId = UUID.fromString(request.getProductId());
        var inventory = inventoryService.findByProductId(productId);
 
        if (inventory.isPresent()) {
            responseObserver.onNext(GetInventoryResponse.newBuilder()
                .setInventory(toProto(inventory.get()))
                .build());
        } else {
            responseObserver.onNext(GetInventoryResponse.getDefaultInstance());
        }
        responseObserver.onCompleted();
    }
 
    @Override
    public void getInventoryBatch(GetInventoryBatchRequest request,
                                   StreamObserver<GetInventoryBatchResponse> responseObserver) {
        List<UUID> productIds = request.getProductIdsList().stream()
            .map(UUID::fromString)
            .toList();
 
        List<Inventory> inventories = inventoryService.findByProductIds(productIds);
 
        GetInventoryBatchResponse.Builder response = GetInventoryBatchResponse.newBuilder();
        inventories.forEach(inv -> response.addInventories(toProto(inv)));
 
        responseObserver.onNext(response.build());
        responseObserver.onCompleted();
    }
 
    @Override
    public void reserveStock(ReserveStockRequest request,
                              StreamObserver<ReserveStockResponse> responseObserver) {
        try {
            var result = inventoryService.reserveStock(
                UUID.fromString(request.getProductId()),
                request.getQuantity(),
                UUID.fromString(request.getOrderId())
            );
            responseObserver.onNext(ReserveStockResponse.newBuilder()
                .setSuccess(true)
                .setReservationId(result.reservationId().toString())
                .setRemainingAvailable(result.remainingAvailable())
                .build());
        } catch (InsufficientStockException e) {
            responseObserver.onNext(ReserveStockResponse.newBuilder()
                .setSuccess(false)
                .setErrorMessage(e.getMessage())
                .build());
        }
        responseObserver.onCompleted();
    }
 
    private InventoryItem toProto(Inventory inv) {
        return InventoryItem.newBuilder()
            .setProductId(inv.getProductId().toString())
            .setAvailable(inv.getAvailable())
            .setReserved(inv.getReserved())
            .setWarehouseLocation(inv.getWarehouseLocation() != null
                ? inv.getWarehouseLocation() : "")
            .setInStock(inv.getAvailable() > 0)
            .build();
    }
}

The gRPC server runs on port 50051, separate from the HTTP/GraphQL endpoint on port 4004. This is intentional — the gRPC port is internal-only (not exposed outside the Docker network), while the GraphQL port is accessible to the router.

gRPC Client (Product Catalog)

The Product Catalog consumes inventory data through a gRPC client managed by Micronaut's dependency injection:

// services/product-catalog-java/src/main/java/.../grpc/InventoryGrpcClient.java
@Singleton
public class InventoryGrpcClient {
 
    private final InventoryServiceGrpc.InventoryServiceBlockingStub stub;
 
    public InventoryGrpcClient(@GrpcChannel("inventory") ManagedChannel channel) {
        this.stub = InventoryServiceGrpc.newBlockingStub(channel);
    }
 
    public Optional<InventoryItem> getInventory(String productId) {
        var response = stub.getInventory(
            GetInventoryRequest.newBuilder()
                .setProductId(productId)
                .build()
        );
 
        return response.hasInventory()
            ? Optional.of(response.getInventory())
            : Optional.empty();
    }
 
    public List<InventoryItem> getInventoryBatch(List<String> productIds) {
        var response = stub.getInventoryBatch(
            GetInventoryBatchRequest.newBuilder()
                .addAllProductIds(productIds)
                .build()
        );
        return response.getInventoriesList();
    }
}

Micronaut's @GrpcChannel("inventory") annotation resolves the channel configuration from application.yml:

grpc:
  channels:
    inventory:
      address: inventory:50051
      plaintext: true  # No TLS within Docker network

Why gRPC Over REST for This Boundary

The inventory lookup is a hot path — called for every product list, product detail page, and search result. The performance characteristics of gRPC matter here. Binary serialization with Protobuf produces messages 3-10x smaller than equivalent JSON. HTTP/2 multiplexing lets multiple concurrent requests share a single TCP connection. Streaming support means GetInventoryBatch sends one request and receives one response with all items. Code generation from the same .proto file eliminates serialization bugs on both client and server. And connection management through Micronaut handles the gRPC channel lifecycle, including reconnection and load balancing.

For a boundary between two JVM services on the same Docker network, these advantages compound. The overhead of REST (JSON serialization, HTTP/1.1 connection management, no type safety) would be unnecessary friction.

REST: Order Service to Stripe

The Stripe Integration

On the other end of the protocol spectrum, the Order Service integrates with Stripe for payment processing. Stripe provides a REST API and official SDKs. There's no protobuf definition to share, no internal service to control — this is a third-party integration.

// services/order-go/internal/stripe/client.go
package stripe
 
import (
    "fmt"
    "github.com/stripe/stripe-go/v82"
    "github.com/stripe/stripe-go/v82/paymentintent"
)
 
type Client struct{}
 
func NewClient(apiKey string) *Client {
    stripe.Key = apiKey
    return &Client{}
}
 
func (c *Client) CreatePaymentIntent(amount int64, currency string,
    metadata map[string]string) (*stripe.PaymentIntent, error) {
 
    params := &stripe.PaymentIntentParams{
        Amount:   stripe.Int64(amount),
        Currency: stripe.String(currency),
        AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
            Enabled: stripe.Bool(true),
        },
    }
 
    for k, v := range metadata {
        params.AddMetadata(k, v)
    }
 
    return paymentintent.New(params)
}
 
func (c *Client) ConfirmPaymentIntent(id string,
    paymentMethodID string) (*stripe.PaymentIntent, error) {
 
    params := &stripe.PaymentIntentConfirmParams{
        PaymentMethod: stripe.String(paymentMethodID),
    }
    return paymentintent.Confirm(id, params)
}

Order Creation Flow

When a client sends a createOrder mutation, the resolver orchestrates database writes and Stripe calls:

func (r *mutationResolver) CreateOrder(ctx context.Context,
    input model.CreateOrderInput) (*model.Order, error) {
 
    // Extract user from headers propagated by the router
    userID := ctx.Value("x-user-id").(string)
 
    // Calculate total from line items
    var totalAmount float64
    for _, item := range input.Items {
        totalAmount += item.UnitPrice * float64(item.Quantity)
    }
 
    // Create Stripe PaymentIntent (REST call)
    amountCents := int64(totalAmount * 100)
    pi, err := r.StripeClient.CreatePaymentIntent(amountCents, "usd",
        map[string]string{"user_id": userID})
    if err != nil {
        return nil, fmt.Errorf("payment failed: %w", err)
    }
 
    // Confirm payment with the provided payment method
    confirmed, err := r.StripeClient.ConfirmPaymentIntent(
        pi.ID, input.PaymentMethodID)
    if err != nil {
        return nil, fmt.Errorf("payment confirmation failed: %w", err)
    }
 
    // Persist order to PostgreSQL
    order, err := r.insertOrder(ctx, userID, input, totalAmount, confirmed.ID)
    if err != nil {
        return nil, err
    }
 
    return order, nil
}

Why REST Is the Right Choice Here

Using REST for Stripe isn't a compromise — it's the only sensible option. Stripe's API is REST-only with no gRPC or GraphQL endpoint available. The official SDK (stripe-go) handles authentication, retries, idempotency keys, and webhook signature verification. The low frequency of payment operations — once per checkout, not per page load — makes serialization overhead negligible. And the error semantics of Stripe's error codes and HTTP status codes map cleanly to GraphQL error responses.

Wrapping Stripe in a gRPC service would add a translation layer between two different error models. Wrapping it in a GraphQL subgraph would be worse — federation entity resolution doesn't map to payment intent creation.

The Dual Protocol Pattern in Java

The Inventory service is the most interesting case because it serves both protocols simultaneously:

Loading diagram...

The Inventory service exposes two entry points — GraphQL for the federation router and gRPC for the Product Catalog — both delegating to the same business logic layer.

Both the GraphQL data fetchers and the gRPC service handlers call the same InventoryService class. The transport protocol is an adapter; the domain logic is shared. This avoids duplicating business rules across protocol boundaries while letting each caller use the most appropriate protocol.

@Singleton
public class InventoryService {
    private final InventoryRepository repository;
 
    // Used by both GraphQL and gRPC layers
    public Optional<Inventory> findByProductId(UUID productId) {
        return repository.findByProductId(productId);
    }
 
    public List<Inventory> findByProductIds(List<UUID> productIds) {
        return repository.findByProductIds(productIds);
    }
 
    public ReservationResult reserveStock(UUID productId, int quantity, UUID orderId) {
        // Transactional stock reservation
        return repository.reserveWithinTransaction(productId, quantity, orderId);
    }
}

Looking Forward

With the subgraphs built and the internal protocols wired, the remaining piece is the gateway layer that makes it all look like a single API. Kong validates JWTs and enforces rate limits. Apollo Router composes the supergraph and plans queries across subgraph boundaries.

In Part 4, we'll examine how these two gateways interact, how the router builds query plans for cross-service queries, and how authentication context flows from the browser through Kong to every subgraph.


This article is part of the Polyglot GraphQL Federation series. Continue to Part 4: The Gateway Layer to see how Kong and Apollo Router compose a secure, federated API.

Arthur CostaA

Arthur Costa

Senior Full-Stack Engineer & Tech Lead

Senior Full-Stack Engineer with 8+ years in React, TypeScript, and Node.js. Expert in performance optimization and leading engineering teams.

View all articles →