Polyglot GraphQL Federation: Part 2 - Three Languages, One Schema

March 22, 202628 min readNew

How Java, Go, and TypeScript each implement GraphQL Federation 2 subgraphs with entity resolution, schema directives, and database-per-service isolation in a real e-commerce platform.

Polyglot GraphQL Federation: Part 2 - Three Languages, One Schema
React to this article

In Part 1, we mapped out why monolithic GraphQL APIs break down and how federation distributes ownership across services. The architecture diagram looked clean — four subgraphs, one router, unified schema. But architecture diagrams don't ship.

This article gets into the code. We'll examine how three different language ecosystems implement the same Federation 2 specification, where the ergonomics diverge, and what entity resolution actually looks like when the router sends a __resolveReference call to your service.

The Federation Contract

Every subgraph in a Federation 2 supergraph must fulfill a specific contract. Regardless of language, each service must:

  1. Declare federation support via a schema @link directive
  2. Mark entities with @key(fields: "...") to enable cross-service resolution
  3. Implement __resolveReference for each entity it owns
  4. Expose the _service and _entities root fields for introspection and entity resolution

The federation spec doesn't care what language implements these requirements. It only cares about the GraphQL responses. This is what makes polyglot federation possible — and what makes comparing implementations instructive.

Loading diagram...

All three implementations fulfill the same federation contract. The libraries differ in how much they automate versus require manual wiring.

TypeScript: The Path of Least Resistance

Apollo Server with @apollo/subgraph is the reference implementation for federation. The ergonomics reflect this — federation support is built into the schema builder with minimal configuration.

Schema Definition

The User/Auth service owns User and Review entities and extends Product with reviews:

// services/user-ts/src/schema/typeDefs.ts
import gql from 'graphql-tag';
 
export const typeDefs = gql`
  extend schema @link(
    url: "https://specs.apollo.dev/federation/v2.9"
    import: ["@key", "@external", "@extends"]
  )
 
  type User @key(fields: "id") {
    id: ID!
    email: String!
    name: String!
    role: String!
    createdAt: String!
  }
 
  type Review @key(fields: "id") {
    id: ID!
    productId: String!
    user: User!
    rating: Int!
    title: String!
    body: String
    createdAt: String!
  }
 
  type Product @key(fields: "id") {
    id: ID! @external
    reviews: [Review!]!
    averageRating: Float
  }
 
  type Query {
    me: User
    user(id: ID!): User
    users: [User!]!
    review(id: ID!): Review
    reviewsByProduct(productId: ID!): [Review!]!
  }
`;

Entity Resolution

The __resolveReference function is defined as a resolver on each entity type. When the router needs to resolve a User stub (e.g., { __typename: "User", id: "abc" }), it calls this function:

// services/user-ts/src/schema/resolvers.ts
export const resolvers = {
  User: {
    __resolveReference: async (ref: { id: string }, context: Context) => {
      return context.db.query.users.findFirst({
        where: eq(users.id, ref.id),
      });
    },
  },
 
  Product: {
    reviews: async (product: { id: string }, _args: unknown, context: Context) => {
      return context.db.query.reviews.findMany({
        where: eq(reviews.productId, product.id),
      });
    },
    averageRating: async (product: { id: string }, _args: unknown, context: Context) => {
      const result = await context.db
        .select({ avg: sql<number>`avg(${reviews.rating})` })
        .from(reviews)
        .where(eq(reviews.productId, product.id));
      return result[0]?.avg ?? null;
    },
  },
 
  Query: {
    me: async (_: unknown, __: unknown, context: Context) => {
      if (!context.userId) return null;
      return context.db.query.users.findFirst({
        where: eq(users.id, context.userId),
      });
    },
  },
};

Server Setup

Building a federated subgraph with Apollo Server requires buildSubgraphSchema:

import { ApolloServer } from '@apollo/server';
import { buildSubgraphSchema } from '@apollo/subgraph';
 
const server = new ApolloServer({
  schema: buildSubgraphSchema([{ typeDefs, resolvers }]),
});

That's it. buildSubgraphSchema handles the _entities and _service root fields automatically. The resolver map follows standard Apollo conventions. If you've written an Apollo Server before, the federation additions are minimal.

Friction points: Drizzle ORM requires mapping database rows to GraphQL types manually when field names don't match. The context typing needs explicit declaration for user headers propagated by the router.

Java: Manual Wiring, Maximum Control

The Java ecosystem uses graphql-java with federation-jvm for federation support, typically paired with Micronaut or Spring as the application framework. The wiring is more explicit than TypeScript — you register each data fetcher individually.

Schema Definition

The Product Catalog service defines its schema in a .graphqls file:

# services/product-catalog-java/src/main/resources/schema/schema.graphqls
extend schema @link(
  url: "https://specs.apollo.dev/federation/v2.9"
  import: ["@key", "@external"]
)
 
type Product @key(fields: "id") {
  id: ID!
  name: String!
  slug: String!
  description: String
  price: Float!
  currency: String!
  imageUrl: String
  status: String!
  category: Category
  createdAt: String!
}
 
type Category @key(fields: "id") {
  id: ID!
  name: String!
  slug: String!
  description: String
  products: [Product!]!
}
 
type SearchResult {
  products: [Product!]!
  totalHits: Int!
  processingTimeMs: Int!
}
 
type Query {
  product(id: ID!): Product
  productBySlug(slug: String!): Product
  products(categoryId: ID, limit: Int, offset: Int): [Product!]!
  categories: [Category!]!
  searchProducts(query: String!, limit: Int): SearchResult!
}

Entity Resolution with federation-jvm

Federation entity resolution in Java requires implementing a TypeResolver and registering data fetchers for the _entities field:

// services/product-catalog-java/src/main/java/com/ecommerce/catalog/graphql/GraphQLFactory.java
@Factory
public class GraphQLFactory {
 
    @Singleton
    public GraphQL graphQL(ResourceResolver resourceResolver,
                           ProductDataFetcher productFetcher,
                           CategoryDataFetcher categoryFetcher) {
 
        SchemaParser schemaParser = new SchemaParser();
        TypeDefinitionRegistry registry = schemaParser.parse(
            resourceResolver.getResourceAsStream("classpath:schema/schema.graphqls").get()
        );
 
        RuntimeWiring wiring = RuntimeWiring.newRuntimeWiring()
            .type("Query", builder -> builder
                .dataFetcher("product", productFetcher.getById())
                .dataFetcher("products", productFetcher.getAll())
                .dataFetcher("productBySlug", productFetcher.getBySlug())
                .dataFetcher("categories", categoryFetcher.getAll())
                .dataFetcher("searchProducts", productFetcher.search()))
            .type("Product", builder -> builder
                .dataFetcher("category", productFetcher.getCategory()))
            .type("Category", builder -> builder
                .dataFetcher("products", categoryFetcher.getProducts()))
            .build();
 
        // Federation transform adds _entities and _service fields
        GraphQLSchema schema = Federation.transform(registry, wiring)
            .fetchEntities(env -> {
                // Entity resolution: router sends List<Map<String, Object>>
                List<Map<String, Object>> representations = env.getArgument("representations");
                return representations.stream()
                    .map(ref -> resolveEntity(ref, productFetcher, categoryFetcher))
                    .collect(Collectors.toList());
            })
            .resolveEntityType(env -> {
                Object source = env.getObject();
                if (source instanceof Product) return env.getSchema().getObjectType("Product");
                if (source instanceof Category) return env.getSchema().getObjectType("Category");
                return null;
            })
            .build();
 
        return GraphQL.newGraphQL(schema).build();
    }
 
    private Object resolveEntity(Map<String, Object> reference,
                                  ProductDataFetcher productFetcher,
                                  CategoryDataFetcher categoryFetcher) {
        String typeName = (String) reference.get("__typename");
        String id = (String) reference.get("id");
 
        return switch (typeName) {
            case "Product" -> productFetcher.findById(id);
            case "Category" -> categoryFetcher.findById(id);
            default -> null;
        };
    }
}

Data Fetchers

Each query and field resolution is an explicit data fetcher:

@Singleton
public class ProductDataFetcher {
    private final ProductService productService;
 
    public DataFetcher<Product> getById() {
        return env -> {
            String id = env.getArgument("id");
            return productService.findById(UUID.fromString(id)).orElse(null);
        };
    }
 
    public DataFetcher<List<Product>> getAll() {
        return env -> {
            String categoryId = env.getArgument("categoryId");
            Integer limit = env.getArgumentOrDefault("limit", 20);
            Integer offset = env.getArgumentOrDefault("offset", 0);
            return productService.findAll(categoryId, limit, offset);
        };
    }
 
    public Product findById(String id) {
        return productService.findById(UUID.fromString(id)).orElse(null);
    }
}

Friction points: The Federation.transform() API requires manual entity dispatch — you write the switch on __typename yourself. Every field resolver is an explicit registration. There's no convention-over-configuration; you wire everything by hand. The upside is full visibility into the resolution pipeline.

Go: Code Generation Meets Federation

Go's gqlgen takes a fundamentally different approach. You write the GraphQL schema, run go generate, and gqlgen produces Go types and resolver interfaces. You implement the interfaces. The federation plugin handles the entity resolution boilerplate.

Schema Definition

The Order Service schema uses federation directives like the others:

# services/order-go/graph/schema.graphqls
extend schema @link(
  url: "https://specs.apollo.dev/federation/v2.9"
  import: ["@key", "@external", "@extends"]
)
 
type Order @key(fields: "id") {
  id: ID!
  userId: String!
  user: User!
  status: OrderStatus!
  items: [OrderItem!]!
  totalAmount: Float!
  paymentIntentId: String
  shippingAddress: Address
  createdAt: String!
  updatedAt: String!
}
 
type OrderItem {
  id: ID!
  product: Product!
  quantity: Int!
  unitPrice: Float!
  totalPrice: Float!
}
 
type User @key(fields: "id") {
  id: ID! @external
}
 
type Product @key(fields: "id") {
  id: ID! @external
}
 
enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

Generated Code and Resolver Implementation

After running go generate ./..., gqlgen creates resolver interfaces that you implement:

// services/order-go/graph/schema.resolvers.go (implemented by developer)
func (r *queryResolver) Order(ctx context.Context, id string) (*model.Order, error) {
    orderUUID, err := uuid.Parse(id)
    if err != nil {
        return nil, fmt.Errorf("invalid order ID: %w", err)
    }
 
    row := r.DB.QueryRow(ctx,
        `SELECT id, user_id, status, total_amount, payment_intent_id,
                shipping_street, shipping_city, shipping_state,
                shipping_zip, shipping_country, created_at, updated_at
         FROM orders WHERE id = $1`, orderUUID)
 
    var order model.Order
    var street, city, state, zip, country sql.NullString
    err = row.Scan(&order.ID, &order.UserID, &order.Status,
        &order.TotalAmount, &order.PaymentIntentID,
        &street, &city, &state, &zip, &country,
        &order.CreatedAt, &order.UpdatedAt)
    if err != nil {
        return nil, err
    }
 
    if street.Valid {
        order.ShippingAddress = &model.Address{
            Street:  street.String,
            City:    city.String,
            State:   state.String,
            ZipCode: zip.String,
            Country: country.String,
        }
    }
 
    return &order, nil
}

Entity Resolution in gqlgen

The federation plugin generates entity resolution scaffolding. You implement a function per entity:

// services/order-go/graph/entity.resolvers.go
func (r *entityResolver) FindOrderByID(ctx context.Context, id string) (*model.Order, error) {
    // Called by the router when resolving Order entity references
    return r.Query().Order(ctx, id)
}

That's the entire entity resolver. The generated code handles the _entities root field, the __typename dispatch, and the batch resolution. You provide the lookup function.

Friction points: gqlgen's code generation creates a generated.go file that can be thousands of lines long. Build times increase with schema size. The model types are generated, so customizing serialization requires gqlgen.yml directives. On the positive side, the federation plugin is mature and handles edge cases like batch entity resolution automatically.

Comparing the Ecosystems

After implementing federation across all three languages, patterns emerge:

Loading diagram...
AspectTypeScriptJavaGo
Schema locationInline (tagged template)External .graphqls fileExternal .graphqls file
Entity resolutionResolver function on typeManual switch on __typenameGenerated function per entity
Type safetyRuntime (zod validation)Compile-time (strong types)Compile-time (generated types)
Framework couplingTight (Apollo Server)Loose (graphql-java is library)Medium (gqlgen conventions)
Hot reloadSub-second (tsx watch)30-60s (Gradle rebuild)5-10s (codegen + go build)
Federation overhead~10 lines~50 lines~5 lines (rest generated)

TypeScript wins on iteration speed. Java wins on explicit control. Go wins on type-safe code generation with minimal manual wiring.

Database-Per-Service Isolation

Each subgraph connects to its own PostgreSQL database. This isn't just organizational — it enforces domain boundaries at the data level:

-- infra/docker/postgres/init-databases.sql
CREATE DATABASE product_db;
CREATE DATABASE inventory_db;
CREATE DATABASE order_db;
CREATE DATABASE user_db;

Each service manages its own schema migrations. TypeScript uses Drizzle ORM with drizzle-kit push. Java uses Flyway with versioned SQL files (V1__create_tables.sql). Go uses golang-migrate with timestamped up/down migrations.

Cross-service data access happens exclusively through federation entity resolution — never through shared database access. If the Order Service needs a product name, it returns a Product stub with just the id, and the router fetches the name from the Product Catalog subgraph.

Looking Forward

With all four subgraphs running, the router can compose them into a supergraph. But this platform doesn't stop at GraphQL. The Product Catalog talks to Inventory over gRPC for high-performance stock checks. The Order Service calls Stripe's REST API for payment processing.

In Part 3, we'll examine why certain service boundaries call for different protocols and how gRPC and REST coexist alongside the federated GraphQL layer.


This article is part of the Polyglot GraphQL Federation series. Continue to Part 3: Hybrid Protocols to see how GraphQL, gRPC, and REST coexist in one platform.

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 →