Go GraphQLフェデレーション実践:サブグラフからスーパーグラフまで6つのプロダクションパターン

技术架构

モノリシックGraphQLスキーマがチーム境界に直面する時:マイクロサービスのGraphQLジレンマ

あるECプラットフォームのGraphQLスキーマが3000行に膨張。ユーザーチーム、商品チーム、注文チームがすべて同じスキーマを変更。リリースのたびに調整が必要で、あるチームの破壊的変更がAPI全体をダウンさせる。さらに悪いことに、サービス間のN+1クエリにより応答時間が50msから3sに急増——ユーザーが注文一覧を照会し、各注文で商品詳細を取得し、各商品で在庫状況を確認。100件の注文で300回のダウンストリーム呼び出し。

これは仮定の話ではない。マイクロサービスアーキテクチャが既に分割されているのに、GraphQLレイヤーがモノリスのままだと、チームの自律性とAPIパフォーマンスが和解できない矛盾になる。GraphQL Federationはこの問題を解決するために存在する——各サービスが独自のGraphQLスキーマ(サブグラフ)を持ち、ゲートウェイを通じて統一API(スーパーグラフ)に構成し、N+1クエリとチーム間の結合を防ぐ。


コア概念リファレンス

概念 用途 主な特徴 典型的なユースケース
Federation 複数のGraphQLサービスを統一APIに構成 クライアントに透過的、各サービスが独立デプロイ マイクロサービスアーキテクチャの統一APIレイヤー
Subgraph 単一サービスのGraphQLスキーマ 独立した型とリゾルバを持ち、@keyでエンティティを宣言 ユーザーサービス、商品サービス、注文サービス
Supergraph 全サブグラフ構成後の完全スキーマ ゲートウェイが自動合成、クライアントはスーパーグラフのみ認識 統一APIエントリポイント
Entity サブグラフ間で共有される型 @keyで識別、複数サブグラフがフィールドを提供可能 User、Product、Order等のコアドメインオブジェクト
@key エンティティの一意識別フィールドを宣言 複合キー対応、複数@keyで代替識別子を表現 @key(fields: "id")@key(fields: "sku warehouseId")
Gateway フェデレーションクエリルーティングと実行エンジン クエリプランニング、エンティティ一括解決、キャッシュ Apollo Router、Apollo Gateway
Schema Stitching 手動で複数GraphQLスキーマを構成 より柔軟だが手動での競合解決が必要 カスタム構成ロジック、非標準フェデレーションシナリオ

GraphQLフェデレーションアーキテクチャの5つの課題

課題1:エンティティ境界の不明確な分割

ユーザーサービスにはUserの名前とメールがあり、注文サービスにもUserがあるがidと注文一覧のみ関心がある。Userの全フィールドをユーザーサービスに置くと、注文サービスは毎回クロスサービス呼び出しが必要。複数サービスに分散させると、エンティティの帰属と一貫性が問題になる。

課題2:フェデレーションレイヤーでのN+1クエリの増幅

クライアントが { orders { user { name } } } をクエリ。ゲートウェイは注文サービスから注文一覧を取得し、各注文のuserIdでユーザーサービスからUserエンティティを解決。100件の注文で100回のUserエンティティ解決リクエスト——パフォーマンス災害。

課題3:スキーマの進化と互換性

商品サービスがProductにrequiredフィールドを追加したいが、注文サービスのProduct参照が非互換になる可能性がある。サブグラフの破壊的変更がスーパーグラフ全体に影響するが、誰がグローバルな互換性チェックを行うのか?

課題4:認証と認可のパススルー

JWTトークンをゲートウェイから各サブグラフに伝播させる必要がある。異なるサブグラフに異なる権限モデルがある可能性がある。ユーザーサービスにはuser:read権限、注文サービスにはorder:read権限が必要——ゲートウェイレイヤーでどう統一的に処理するか?

課題5:オブザーバビリティとエラートレーシング

1つのクエリが3つのサブグラフに関与する可能性がある。クエリが失敗した時、どのサブグラフがエラーを生成したのか?レイテンシのボトルネックはどのサービスにあるのか?分散トレーシングがGraphQLレイヤーで正しく伝播するには?


6つのプロダクション級フェデレーションパターン

パターン1:サブグラフサービス定義——gqlgen基盤構築

サブグラフはフェデレーションの基本単位。gqlgenでGraphQLサービスを生成し、フェデレーションディレクティブを宣言し、エンティティ型を定義する。

GraphQLスキーマ(users.graphqls)

extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.0",
        import: ["@key", "@shareable", "@external", "@requires"])

type User @key(fields: "id") @key(fields: "email") {
  id: ID!
  email: String!
  name: String!
  avatar: String
  createdAt: String!
  orders: [Order!]!
}

type Order @key(fields: "id") @shareable {
  id: ID!
  userId: ID!
  items: [OrderItem!]!
  total: Float!
  status: OrderStatus!
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

type OrderItem {
  productId: ID!
  quantity: Int!
  price: Float!
}

Goリゾルバ実装

package graph

import (
	"context"
	"fmt"

	"github.com/99designs/gqlgen/graphql"
	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/handler/extension"
	"github.com/99designs/gqlgen/graphql/handler/transport"
)

type User struct {
	ID        string `json:"id"`
	Email     string `json:"email"`
	Name      string `json:"name"`
	Avatar    string `json:"avatar,omitempty"`
	CreatedAt string `json:"createdAt"`
}

type Order struct {
	ID     string     `json:"id"`
	UserID string     `json:"userId"`
	Items  []OrderItem `json:"items"`
	Total  float64    `json:"total"`
	Status string     `json:"status"`
}

type OrderItem struct {
	ProductID string  `json:"productId"`
	Quantity  int     `json:"quantity"`
	Price     float64 `json:"price"`
}

type Resolver struct {
	userRepo  UserRepository
	orderRepo OrderRepository
}

func NewResolver(userRepo UserRepository, orderRepo OrderRepository) *Resolver {
	return &Resolver{userRepo: userRepo, orderRepo: orderRepo}
}

func (r *Resolver) User(ctx context.Context, id string) (*User, error) {
	user, err := r.userRepo.FindByID(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("user not found: %w", err)
	}
	return user, nil
}

func (r *Resolver) Users(ctx context.Context, limit int, offset int) ([]*User, error) {
	return r.userRepo.List(ctx, limit, offset)
}

func (r *entityResolver) FindUserByID(ctx context.Context, id string) (*User, error) {
	return r.userRepo.FindByID(ctx, id)
}

func (r *entityResolver) FindUserByEmail(ctx context.Context, email string) (*User, error) {
	return r.userRepo.FindByEmail(ctx, email)
}

func NewGraphQLServer(resolver *Resolver) *handler.Server {
	srv := handler.New(NewExecutableSchema(Config{Resolvers: resolver}))
	srv.AddTransport(transport.POST{})
	srv.AddTransport(transport.GET{})
	srv.Use(extension.Introspection{})
	return srv
}

gqlgen設定(gqlgen.yml)

schema:
  - users.graphqls
exec:
  filename: graph/generated.go
model:
  filename: graph/model/models_gen.go
resolver:
  filename: graph/resolver.go
  type: Resolver
federation:
  filename: graph/federation.go
  package: graph

パターン2:エンティティ解決と@keyディレクティブ——クロスサービスタイプスティッチング

@keyはエンティティの識別フィールドを宣言する。ゲートウェイは__resolveReference関数を通じて異なるサブグラフ間でエンティティを解決する。これがフェデレーションの中核メカニズムである。

商品サブグラフスキーマ(products.graphqls)

extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.0",
        import: ["@key", "@shareable", "@external", "@requires", "@provides"])

type Product @key(fields: "id") @key(fields: "sku") {
  id: ID!
  sku: String!
  name: String!
  description: String
  price: Float!
  inventory: Int!
  category: Category!
  reviews: [Review!]! @provides(fields: "rating")
}

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

type Review {
  id: ID!
  userId: ID!
  productId: ID!
  rating: Int!
  comment: String
}

type Query {
  product(id: ID!): Product
  products(categoryId: ID, limit: Int, offset: Int): [Product!]!
  category(id: ID!): Category
}

Goエンティティリゾルバ

package graph

import (
	"context"
	"fmt"
)

type Product struct {
	ID          string  `json:"id"`
	SKU         string  `json:"sku"`
	Name        string  `json:"name"`
	Description string  `json:"description,omitempty"`
	Price       float64 `json:"price"`
	Inventory   int     `json:"inventory"`
	CategoryID  string  `json:"categoryId"`
}

type Category struct {
	ID       string `json:"id"`
	Name     string `json:"name"`
	ParentID string `json:"parentId,omitempty"`
}

type ProductRepository interface {
	FindByID(ctx context.Context, id string) (*Product, error)
	FindBySKU(ctx context.Context, sku string) (*Product, error)
	ListByCategory(ctx context.Context, categoryID string, limit, offset int) ([]*Product, error)
}

type entityResolver struct {
	productRepo  ProductRepository
	categoryRepo CategoryRepository
}

func (r *entityResolver) FindProductByID(ctx context.Context, id string) (*Product, error) {
	product, err := r.productRepo.FindByID(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("product entity resolution failed for id=%s: %w", id, err)
	}
	return product, nil
}

func (r *entityResolver) FindProductBySKU(ctx context.Context, sku string) (*Product, error) {
	product, err := r.productRepo.FindBySKU(ctx, sku)
	if err != nil {
		return nil, fmt.Errorf("product entity resolution failed for sku=%s: %w", sku, err)
	}
	return product, nil
}

func (r *entityResolver) FindCategoryByID(ctx context.Context, id string) (*Category, error) {
	category, err := r.categoryRepo.FindByID(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("category entity resolution failed for id=%s: %w", id, err)
	}
	return category, nil
}

func (r *Resolver) Product(ctx context.Context, id string) (*Product, error) {
	return r.productRepo.FindByID(ctx, id)
}

func (r *Resolver) Products(ctx context.Context, categoryId *string, limit *int, offset *int) ([]*Product, error) {
	lim := 20
	off := 0
	if limit != nil {
		lim = *limit
	}
	if offset != nil {
		off = *offset
	}
	if categoryId != nil {
		return r.productRepo.ListByCategory(ctx, *categoryId, lim, off)
	}
	return r.productRepo.List(ctx, lim, off)
}

複合キーエンティティ

type WarehouseStock @key(fields: "sku warehouseId") {
  sku: String!
  warehouseId: ID!
  quantity: Int!
  reservedQuantity: Int!
  location: String!
}
type WarehouseStock struct {
	SKU              string `json:"sku"`
	WarehouseID      string `json:"warehouseId"`
	Quantity         int    `json:"quantity"`
	ReservedQuantity int    `json:"reservedQuantity"`
	Location         string `json:"location"`
}

type WarehouseStockRef struct {
	SKU         string `json:"sku"`
	WarehouseID string `json:"warehouseId"`
}

func (r *entityResolver) FindWarehouseStockBySkuAndWarehouseId(
	ctx context.Context,
	sku string,
	warehouseId string,
) (*WarehouseStock, error) {
	stock, err := r.stockRepo.FindBySKUAndWarehouse(ctx, sku, warehouseId)
	if err != nil {
		return nil, fmt.Errorf("warehouse stock resolution failed: %w", err)
	}
	return stock, nil
}

パターン3:Apollo Federation v2構成——サブグラフからスーパーグラフへ

Federation v2は@link、@shareable、@overrideなどの新しいディレクティブを導入し、スキーマ構成をより柔軟にする。rover CLIでスキーマチェックと公開を行う。

スーパーグラフ設定(supergraph.yaml)

federation_version: =2.8.0
subgraphs:
  users:
    routing_url: http://users-service:4001/graphql
    schema:
      file: ./schemas/users.graphqls
  products:
    routing_url: http://products-service:4002/graphql
    schema:
      file: ./schemas/products.graphqls
  orders:
    routing_url: http://orders-service:4003/graphql
    schema:
      file: ./schemas/orders.graphqls
  reviews:
    routing_url: http://reviews-service:4004/graphql
    schema:
      file: ./schemas/reviews.graphqls

注文サブグラフスキーマ(orders.graphqls)

extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.0",
        import: ["@key", "@shareable", "@external", "@requires"])

type Order @key(fields: "id") {
  id: ID!
  userId: ID!
  items: [OrderItem!]!
  total: Float!
  status: OrderStatus!
  shippingAddress: Address
  createdAt: String!
  user: User @requires(fields: "userId")
}

type OrderItem {
  productId: ID!
  quantity: Int!
  unitPrice: Float!
  product: Product
}

type Address @shareable {
  street: String!
  city: String!
  state: String!
  zipCode: String!
  country: String!
}

type User @key(fields: "id") @shareable {
  id: ID! @external
  orders: [Order!]!
}

type Product @key(fields: "id") @shareable {
  id: ID! @external
  orderItems: [OrderItem!]!
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

type Query {
  order(id: ID!): Order
  orders(userId: ID, status: OrderStatus, limit: Int, offset: Int): [Order!]!
}

スキーマチェックと公開

# サブグラフスキーマの互換性チェック
rover subgraph check my-graph \
  --name users \
  --schema ./schemas/users.graphqls

# サブグラフスキーマの公開
rover subgraph publish my-graph@production \
  --name users \
  --schema ./schemas/users.graphqls \
  --routing-url http://users-service:4001/graphql

# スーパーグラフの構成
rover supergraph compose --config supergraph.yaml > supergraph.graphqls

GoサブグラフHTTPサービス

package main

import (
	"log"
	"net/http"
	"os"

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/playground"
	"github.com/go-chi/chi/v5"
)

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = "4001"
	}

	router := chi.NewRouter()

	userRepo := NewPostgresUserRepository(os.Getenv("DATABASE_URL"))
	orderRepo := NewPostgresOrderRepository(os.Getenv("DATABASE_URL"))
	resolver := graph.NewResolver(userRepo, orderRepo)

	srv := handler.NewDefaultServer(graph.NewExecutableSchema(
		graph.Config{Resolvers: resolver},
	))

	router.Handle("/", playground.Handler("GraphQL Playground", "/query"))
	router.Handle("/query", srv)

	log.Printf("🚀 Users subgraph running on :%s", port)
	log.Fatal(http.ListenAndServe(":"+port, router))
}

パターン4:ゲートウェイルーティングとクエリプランニング——Apollo Router

Apollo RouterはRustで書かれた高性能ゲートウェイで、クエリプランニング、エンティティ一括解決、キャッシュ、オブザーバビリティをサポートする。

Router設定(router.yaml)

supergraph:
  listen: 0.0.0.0:4000
  path: /graphql
  introspection: true

health_check:
  listen: 0.0.0.0:8088

cors:
  origins:
    - https://app.example.com
    - http://localhost:3000
  methods:
    - GET
    - POST
  headers:
    - Authorization
    - Content-Type
    - X-Request-ID

headers:
  all:
    request:
      - propagate:
          matching: "^X-.*"
      - propagate:
          named: Authorization
  subgraphs:
    users:
      request:
        - propagate:
            named: Authorization
        - set:
            name: X-User-Service-Key
            value: "${USERS_SERVICE_KEY}"
    orders:
      request:
        - propagate:
            named: Authorization

traffic_shaping:
  all:
    rate_limit:
      capacity: 1000
      interval: 1s
  subgraphs:
    users:
      timeout: 5s
      rate_limit:
        capacity: 500
        interval: 1s
    products:
      timeout: 3s
    orders:
      timeout: 10s

telemetry:
  tracing:
    common:
      service_name: apollo-router
    otlp:
      endpoint: http://otel-collector:4317
      protocol: grpc
  metrics:
    common:
      service_name: apollo-router
    otlp:
      endpoint: http://otel-collector:4317
      protocol: grpc
  logging:
    format: json

Docker Composeデプロイ

version: "3.9"

services:
  router:
    image: ghcr.io/apollographql/router:v1.45.0
    ports:
      - "4000:4000"
      - "8088:8088"
    volumes:
      - ./router.yaml:/dist/configuration/router.yaml:ro
      - ./supergraph.graphqls:/dist/schema/supergraph.graphqls:ro
    environment:
      - USERS_SERVICE_KEY=${USERS_SERVICE_KEY}
      - APOLLO_KEY=${APOLLO_KEY}
      - APOLLO_GRAPH_REF=${APOLLO_GRAPH_REF}
    depends_on:
      - users-service
      - products-service
      - orders-service
      - reviews-service
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8088/health"]
      interval: 10s
      timeout: 5s
      retries: 3

  users-service:
    build:
      context: ./services/users
      dockerfile: Dockerfile
    ports:
      - "4001:4001"
    environment:
      - DATABASE_URL=postgres://users:password@postgres:5432/users?sslmode=disable
      - PORT=4001
    depends_on:
      - postgres

  products-service:
    build:
      context: ./services/products
      dockerfile: Dockerfile
    ports:
      - "4002:4002"
    environment:
      - DATABASE_URL=postgres://products:password@postgres:5432/products?sslmode=disable
      - PORT=4002

  orders-service:
    build:
      context: ./services/orders
      dockerfile: Dockerfile
    ports:
      - "4003:4003"
    environment:
      - DATABASE_URL=postgres://orders:password@postgres:5432/orders?sslmode=disable
      - PORT=4003

  reviews-service:
    build:
      context: ./services/reviews
      dockerfile: Dockerfile
    ports:
      - "4004:4004"
    environment:
      - DATABASE_URL=postgres://reviews:password@postgres:5432/reviews?sslmode=disable
      - PORT=4004

  postgres:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      - POSTGRES_MULTIPLE_DATABASES=users,products,orders,reviews
      - POSTGRES_PASSWORD=password
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

クエリプランニング例

query GetUserWithOrders {
  user(id: "user-123") {
    name
    email
    orders(limit: 10) {
      id
      total
      status
      items {
        product {
          name
          price
        }
        quantity
      }
    }
  }
}

ゲートウェイのクエリプランナーは次の実行計画を生成する:

  1. usersサブグラフからUserのname、emailを取得
  2. ordersサブグラフからUser(id="user-123")のordersを取得
  3. productsサブグラフからOrderItem内のProductエンティティを一括解決
  4. 結果をマージしてクライアントに返却

パターン5:クロスサービスデータフェッチとN+1防止——DataLoaderパターン

N+1はGraphQLフェデレーションで最も深刻なパフォーマンス問題である。DataLoaderはバッチロードと重複排除により、N回のエンティティ解決を1回のバッチクエリに統合する。

Go DataLoader実装

package dataloader

import (
	"context"
	"fmt"
	"sync"
	"time"
)

type BatchFunc[K comparable, V any] func(ctx context.Context, keys []K) (map[K]V, error)

type Loader[K comparable, V any] struct {
	batchFn  BatchFunc[K, V]
	cache    map[K]V
	pending  map[K]chan result[V]
	mu       sync.Mutex
	maxBatch int
	wait     time.Duration
}

type result[V any] struct {
	value V
	err   error
}

func NewLoader[K comparable, V any](batchFn BatchFunc[K, V], opts ...Option[K, V]) *Loader[K, V] {
	l := &Loader[K, V]{
		batchFn:  batchFn,
		cache:    make(map[K]V),
		pending:  make(map[K]chan result[V]),
		maxBatch: 100,
		wait:     10 * time.Millisecond,
	}
	for _, opt := range opts {
		opt(l)
	}
	return l
}

type Option[K comparable, V any] func(*Loader[K, V])

func WithMaxBatch[K comparable, V any](n int) Option[K, V] {
	return func(l *Loader[K, V]) { l.maxBatch = n }
}

func WithWait[K comparable, V any](d time.Duration) Option[K, V] {
	return func(l *Loader[K, V]) { l.wait = d }
}

func (l *Loader[K, V]) Load(ctx context.Context, key K) (V, error) {
	l.mu.Lock()

	if v, ok := l.cache[key]; ok {
		l.mu.Unlock()
		return v, nil
	}

	if ch, ok := l.pending[key]; ok {
		l.mu.Unlock()
		res := <-ch
		return res.value, res.err
	}

	ch := make(chan result[V], 1)
	l.pending[key] = ch

	if len(l.pending) >= l.maxBatch {
		l.mu.Unlock()
		l.dispatch(ctx)
	} else {
		l.mu.Unlock()
		time.AfterFunc(l.wait, func() { l.dispatch(ctx) })
	}

	res := <-ch
	return res.value, res.err
}

func (l *Loader[K, V]) dispatch(ctx context.Context) {
	l.mu.Lock()
	if len(l.pending) == 0 {
		l.mu.Unlock()
		return
	}

	keys := make([]K, 0, len(l.pending))
	chs := make(map[K][]chan result[V], len(l.pending))
	for k, ch := range l.pending {
		keys = append(keys, k)
		chs[k] = append(chs[k], ch)
		delete(l.pending, k)
	}
	l.mu.Unlock()

	results, err := l.batchFn(ctx, keys)

	for _, key := range keys {
		var res result[V]
		if err != nil {
			res = result[V]{err: err}
		} else if v, ok := results[key]; ok {
			res = result[V]{value: v}
			l.mu.Lock()
			l.cache[key] = v
			l.mu.Unlock()
		} else {
			res = result[V]{err: fmt.Errorf("key not found: %v", key)}
		}
		for _, ch := range chs[key] {
			ch <- res
		}
	}
}

func (l *Loader[K, V]) LoadMany(ctx context.Context, keys []K) ([]V, error) {
	values := make([]V, len(keys))
	var firstErr error
	for i, key := range keys {
		v, err := l.Load(ctx, key)
		if err != nil && firstErr == nil {
			firstErr = err
		}
		values[i] = v
	}
	return values, firstErr
}

リゾルバでのDataLoader使用

package graph

import (
	"context"
	"fmt"

	"myapp/dataloader"
)

type Loaders struct {
	UserByID    *dataloader.Loader[string, *User]
	ProductByID *dataloader.Loader[string, *Product]
	OrderByID   *dataloader.Loader[string, *Order]
}

func NewLoaders(userRepo UserRepository, productRepo ProductRepository, orderRepo OrderRepository) *Loaders {
	return &Loaders{
		UserByID: dataloader.NewLoader(func(ctx context.Context, ids []string) (map[string]*User, error) {
			users, err := userRepo.FindByIDs(ctx, ids)
			if err != nil {
				return nil, fmt.Errorf("batch user load failed: %w", err)
			}
			result := make(map[string]*User, len(users))
			for _, u := range users {
				result[u.ID] = u
			}
			return result, nil
		}, dataloader.WithMaxBatch[string, *User](200), dataloader.WithWait[string, *User](5*time.Millisecond)),

		ProductByID: dataloader.NewLoader(func(ctx context.Context, ids []string) (map[string]*Product, error) {
			products, err := productRepo.FindByIDs(ctx, ids)
			if err != nil {
				return nil, fmt.Errorf("batch product load failed: %w", err)
			}
			result := make(map[string]*Product, len(products))
			for _, p := range products {
				result[p.ID] = p
			}
			return result, nil
		}, dataloader.WithMaxBatch[string, *Product](200), dataloader.WithWait[string, *Product](5*time.Millisecond)),

		OrderByID: dataloader.NewLoader(func(ctx context.Context, ids []string) (map[string]*Order, error) {
			orders, err := orderRepo.FindByIDs(ctx, ids)
			if err != nil {
				return nil, fmt.Errorf("batch order load failed: %w", err)
			}
			result := make(map[string]*Order, len(orders))
			for _, o := range orders {
				result[o.ID] = o
			}
			return result, nil
		}, dataloader.WithMaxBatch[string, *Order](200), dataloader.WithWait[string, *Order](5*time.Millisecond)),
	}
}

func (r *orderResolver) User(ctx context.Context, obj *Order) (*User, error) {
	loader := ctx.Value(loaderKey).(*Loaders)
	return loader.UserByID.Load(ctx, obj.UserID)
}

func (r *orderItemResolver) Product(ctx context.Context, obj *OrderItem) (*Product, error) {
	loader := ctx.Value(loaderKey).(*Loaders)
	return loader.ProductByID.Load(ctx, obj.ProductID)
}

バッチクエリリポジトリ

package repository

import (
	"context"
	"database/sql"
	"fmt"
	"strings"

	_ "github.com/lib/pq"
)

type PostgresUserRepository struct {
	db *sql.DB
}

func NewPostgresUserRepository(dbURL string) (*PostgresUserRepository, error) {
	db, err := sql.Open("postgres", dbURL)
	if err != nil {
		return nil, fmt.Errorf("failed to connect to database: %w", err)
	}
	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(10)
	return &PostgresUserRepository{db: db}, nil
}

func (r *PostgresUserRepository) FindByIDs(ctx context.Context, ids []string) ([]*User, error) {
	if len(ids) == 0 {
		return nil, nil
	}

	placeholders := make([]string, len(ids))
	args := make([]interface{}, len(ids))
	for i, id := range ids {
		placeholders[i] = fmt.Sprintf("$%d", i+1)
		args[i] = id
	}

	query := fmt.Sprintf(
		"SELECT id, email, name, avatar, created_at FROM users WHERE id IN (%s)",
		strings.Join(placeholders, ","),
	)

	rows, err := r.db.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("batch query users failed: %w", err)
	}
	defer rows.Close()

	users := make([]*User, 0, len(ids))
	for rows.Next() {
		var u User
		var avatar sql.NullString
		if err := rows.Scan(&u.ID, &u.Email, &u.Name, &avatar, &u.CreatedAt); err != nil {
			return nil, fmt.Errorf("scan user row failed: %w", err)
		}
		if avatar.Valid {
			u.Avatar = avatar.String
		}
		users = append(users, &u)
	}
	return users, rows.Err()
}

パターン6:プロダクション級フェデレーションアーキテクチャ——認証、モニタリング、高可用性

プロダクション環境のフェデレーションアーキテクチャは、認証パススルー、分散トレーシング、レート制限、サーキットブレーカー、グレースフルデグラデーションを処理する必要がある。

認証ミドルウェア

package middleware

import (
	"context"
	"net/http"
	"strings"

	"github.com/golang-jwt/jwt/v5"
)

type contextKey string

const (
	userIDKey    contextKey = "userID"
	userRoleKey  contextKey = "userRole"
	authHeaderKey           = "Authorization"
)

type Claims struct {
	UserID string `json:"sub"`
	Role   string `json:"role"`
	jwt.RegisteredClaims
}

func AuthMiddleware(jwtSecret string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			authHeader := r.Header.Get(authHeaderKey)
			if authHeader == "" {
				next.ServeHTTP(w, r)
				return
			}

			tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
			if tokenStr == authHeader {
				next.ServeHTTP(w, r)
				return
			}

			token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
				if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
					return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
				}
				return []byte(jwtSecret), nil
			})

			if err != nil || !token.Valid {
				http.Error(w, "invalid token", http.StatusUnauthorized)
				return
			}

			claims, ok := token.Claims.(*Claims)
			if !ok {
				http.Error(w, "invalid claims", http.StatusUnauthorized)
				return
			}

			ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
			ctx = context.WithValue(ctx, userRoleKey, claims.Role)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func GetUserID(ctx context.Context) string {
	if v, ok := ctx.Value(userIDKey).(string); ok {
		return v
	}
	return ""
}

func GetUserRole(ctx context.Context) string {
	if v, ok := ctx.Value(userRoleKey).(string); ok {
		return v
	}
	return ""
}

OpenTelemetryトレーシング統合

package telemetry

import (
	"context"
	"fmt"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
	"go.opentelemetry.io/otel/propagation"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	"go.opentelemetry.io/otel/trace"
)

func InitTracer(ctx context.Context, endpoint string) (func(context.Context) error, error) {
	exporter, err := otlptracegrpc.New(ctx,
		otlptracegrpc.WithEndpoint(endpoint),
		otlptracegrpc.WithInsecure(),
	)
	if err != nil {
		return nil, fmt.Errorf("failed to create OTLP exporter: %w", err)
	}

	provider := sdktrace.NewTracerProvider(
		sdktrace.WithBatcher(exporter),
		sdktrace.WithResource(newResource()),
	)

	otel.SetTracerProvider(provider)
	otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
		propagation.TraceContext{},
		propagation.Baggage{},
	))

	return provider.Shutdown, nil
}

func StartSpan(ctx context.Context, name string) (context.Context, trace.Span) {
	tracer := otel.Tracer("graphql-federation")
	return tracer.Start(ctx, name)
}

レート制限とサーキットブレーカー

package middleware

import (
	"context"
	"fmt"
	"net/http"
	"sync"
	"time"
)

type RateLimiter struct {
	mu       sync.Mutex
	clients  map[string]*clientBucket
	rate     int
	interval time.Duration
}

type clientBucket struct {
	tokens   int
	lastSeen time.Time
}

func NewRateLimiter(rate int, interval time.Duration) *RateLimiter {
	rl := &RateLimiter{
		clients:  make(map[string]*clientBucket),
		rate:     rate,
		interval: interval,
	}
	go rl.cleanup()
	return rl
}

func (rl *RateLimiter) Allow(key string) bool {
	rl.mu.Lock()
	defer rl.mu.Unlock()

	now := time.Now()
	bucket, ok := rl.clients[key]
	if !ok {
		rl.clients[key] = &clientBucket{tokens: rl.rate - 1, lastSeen: now}
		return true
	}

	elapsed := now.Sub(bucket.lastSeen)
	if elapsed >= rl.interval {
		bucket.tokens = rl.rate - 1
		bucket.lastSeen = now
		return true
	}

	if bucket.tokens <= 0 {
		return false
	}

	bucket.tokens--
	return true
}

func (rl *RateLimiter) cleanup() {
	ticker := time.NewTicker(time.Minute)
	for range ticker.C {
		rl.mu.Lock()
		for key, bucket := range rl.clients {
			if time.Since(bucket.lastSeen) > 3*rl.interval {
				delete(rl.clients, key)
			}
		}
		rl.mu.Unlock()
	}
}

func RateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			clientID := r.Header.Get("X-Client-ID")
			if clientID == "" {
				clientID = r.RemoteAddr
			}

			if !limiter.Allow(clientID) {
				http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
				return
			}

			next.ServeHTTP(w, r)
		})
	}
}

type CircuitBreaker struct {
	mu           sync.Mutex
	failureCount int
	threshold    int
	timeout      time.Duration
	state        string
	lastFailure  time.Time
}

func NewCircuitBreaker(threshold int, timeout time.Duration) *CircuitBreaker {
	return &CircuitBreaker{
		threshold: threshold,
		timeout:   timeout,
		state:     "closed",
	}
}

func (cb *CircuitBreaker) Execute(fn func() error) error {
	cb.mu.Lock()
	if cb.state == "open" {
		if time.Since(cb.lastFailure) > cb.timeout {
			cb.state = "half-open"
			cb.mu.Unlock()
		} else {
			cb.mu.Unlock()
			return fmt.Errorf("circuit breaker is open")
		}
	} else {
		cb.mu.Unlock()
	}

	err := fn()
	cb.mu.Lock()
	defer cb.mu.Unlock()

	if err != nil {
		cb.failureCount++
		cb.lastFailure = time.Now()
		if cb.failureCount >= cb.threshold {
			cb.state = "open"
		}
		return err
	}

	cb.failureCount = 0
	cb.state = "closed"
	return nil
}

完全サービス起動

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/playground"
	"github.com/go-chi/chi/v5"
	chimw "github.com/go-chi/chi/v5/middleware"

	"myapp/graph"
	"myapp/middleware"
	"myapp/telemetry"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	shutdown, err := telemetry.InitTracer(ctx, os.Getenv("OTEL_ENDPOINT"))
	if err != nil {
		log.Printf("⚠️ Tracer init failed: %v", err)
	} else {
		defer shutdown(ctx)
	}

	userRepo, err := NewPostgresUserRepository(os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatalf("Failed to connect to user database: %v", err)
	}
	orderRepo, err := NewPostgresOrderRepository(os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatalf("Failed to connect to order database: %v", err)
	}

	resolver := graph.NewResolver(userRepo, orderRepo)
	loaders := graph.NewLoaders(userRepo, nil, orderRepo)

	srv := handler.NewDefaultServer(graph.NewExecutableSchema(
		graph.Config{Resolvers: resolver},
	))

	limiter := middleware.NewRateLimiter(100, time.Second)
	breaker := middleware.NewCircuitBreaker(5, 30*time.Second)

	router := chi.NewRouter()
	router.Use(chimw.RequestID)
	router.Use(chimw.RealIP)
	router.Use(chimw.Logger)
	router.Use(chimw.Recoverer)
	router.Use(chimw.Timeout(30 * time.Second))
	router.Use(middleware.AuthMiddleware(os.Getenv("JWT_SECRET")))
	router.Use(middleware.RateLimitMiddleware(limiter))

	router.Handle("/", playground.Handler("GraphQL Playground", "/query"))
	router.Handle("/query", srv)
	router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("ok"))
	})

	port := os.Getenv("PORT")
	if port == "" {
		port = "4001"
	}

	server := &http.Server{
		Addr:         ":" + port,
		Handler:      router,
		ReadTimeout:  15 * time.Second,
		WriteTimeout: 30 * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	go func() {
		log.Printf("🚀 Subgraph running on :%s", port)
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("Server error: %v", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	log.Println("Shutting down gracefully...")
	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer shutdownCancel()

	if err := server.Shutdown(shutdownCtx); err != nil {
		log.Fatalf("Forced shutdown: %v", err)
	}
	log.Println("Server stopped")
	_ = breaker
	_ = loaders
}

5つのよくある落とし穴

落とし穴1:サブグラフで__resolveReferenceの実装忘れ

誤った実装

// @keyを定義しただけでエンティティ解決を実装していない
type Resolver struct{}

// このメソッドが欠落——ゲートウェイがクロスサブグラフエンティティを解決できない
// func (r *entityResolver) FindUserByID(ctx context.Context, id string) (*User, error) { ... }

正しい実装

func (r *entityResolver) FindUserByID(ctx context.Context, id string) (*User, error) {
	return r.userRepo.FindByID(ctx, id)
}

func (r *entityResolver) FindUserByEmail(ctx context.Context, email string) (*User, error) {
	return r.userRepo.FindByEmail(ctx, email)
}

落とし穴2:@shareableの乱用によるデータ不整合

誤った実装

type User @key(fields: "id") @shareable {
  id: ID!
  name: String!
  email: String!
  orderCount: Int!  # 複数サブグラフがこのフィールドを提供するが、計算ロジックが異なる
}

正しい実装

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

type UserOrderStats @key(fields: "userId") {
  userId: ID!
  orderCount: Int!
  totalSpent: Float!
}

落とし穴3:DataLoaderなしのN+1クエリ

誤った実装

func (r *orderResolver) User(ctx context.Context, obj *Order) (*User, error) {
    // 各OrderごとにユーザーサービスへのHTTPリクエストを発行
    resp, err := http.Get(fmt.Sprintf("http://users-service/users/%s", obj.UserID))
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    var user User
    json.NewDecoder(resp.Body).Decode(&user)
    return &user, nil
}

正しい実装

func (r *orderResolver) User(ctx context.Context, obj *Order) (*User, error) {
    loader := ctx.Value(loaderKey).(*Loaders)
    return loader.UserByID.Load(ctx, obj.UserID)
}

落とし穴4:サブグラフスキーマ変更時の互換性チェックなし

誤った実装

# チェックせずに直接公開
rover subgraph publish my-graph@production \
  --name users \
  --schema ./schemas/users.graphqls

正しい実装

# まず互換性をチェック
rover subgraph check my-graph@production \
  --name users \
  --schema ./schemas/users.graphqls

# 破壊的変更がないことを確認してから公開
rover subgraph publish my-graph@production \
  --name users \
  --schema ./schemas/users.graphqls \
  --routing-url http://users-service:4001/graphql

落とし穴5:ゲートウェイレイヤーのタイムアウトとリトライ設定の欠落

誤った実装

# router.yaml - タイムアウトやリトライ設定が一切ない
supergraph:
  listen: 0.0.0.0:4000

正しい実装

supergraph:
  listen: 0.0.0.0:4000

traffic_shaping:
  all:
    timeout: 30s
    rate_limit:
      capacity: 1000
      interval: 1s
  subgraphs:
    users:
      timeout: 5s
    products:
      timeout: 3s
    orders:
      timeout: 10s

エラートラブルシューティングリファレンス

エラーメッセージ 原因 解決策
ENCORE_UNKNOWN_DIRECTIVE サブグラフがインポートされていないフェデレーションディレクティブを使用 @linkに欠落しているディレクティブのインポートを追加
KEY_FIELDS_MISSING_ON_BASE @keyが参照するフィールドが型に存在しない @keyで指定されたフィールドが型定義で宣言されていることを確認
EXTERNAL_TYPE_MISMATCH @externalで宣言された型が所有サブグラフと一致しない @externalフィールドの型が元の定義と一致するか確認
SHAREABLE_MISMATCH 同じ型が異なるサブグラフ間で@shareable宣言が一貫していない 型を共有する全サブグラフで@shareableをマークする必要がある
RESOLVE_REFERENCE_FAILED __resolveReferenceの実装がエラーを返す エンティティリゾルバのデータベースクエリとエラー処理を確認
QUERY_PLAN_TIMEOUT クエリプランニングがタイムアウト——サブグラフが多すぎるかクエリが深すぎる クエリ深度を制限、スキーマ構造を最適化
SUBGRAPH_UNREACHABLE サブグラフサービスに到達できない サブグラフのヘルスステータスとネットワーク接続性を確認
COMPOSITION_ERROR 型の競合によりスキーマ構成が失敗 rover subgraph checkで互換性を確認
N+1_DETECTED ゲートウェイがN+1クエリパターンを検出 エンティティ解決にDataLoaderバッチロードを追加
CIRCULAR_DEPENDENCY サブグラフ間に循環依存が存在 エンティティ境界をリファクタリング、直接参照の代わりに@requiresを使用

高度な最適化

クエリ複雑度分析と制限

GraphQLクエリの複雑度は悪意ある利用の可能性がある——深くネストされたクエリは指数関数的なデータ量を生成できる。複雑度分析でクエリコストを制限する。

package middleware

import (
	"context"
	"fmt"

	"github.com/99designs/gqlgen/graphql"
)

type ComplexityLimit struct {
	maxComplexity int
}

func NewComplexityLimit(max int) *ComplexityLimit {
	return &ComplexityLimit{maxComplexity: max}
}

func (cl *ComplexityLimit) Extension() graphql.HandlerExtension {
	return graphql.FixedComplexityLimit(cl.maxComplexity)
}

type fieldComplexity struct {
	complexity int
	details    map[string]int
}

func CalculateQueryComplexity(ctx context.Context, req *graphql.Request) (*fieldComplexity, error) {
	complexity := 0
	details := make(map[string]int)

	operation := req.Doc().Operations
	for _, op := range operation {
		for _, sel := range op.SelectionSet {
			calcSelectionComplexity(sel, &complexity, details, 1)
		}
	}

	if complexity > 500 {
		return nil, fmt.Errorf("query complexity %d exceeds limit 500", complexity)
	}

	return &fieldComplexity{complexity: complexity, details: details}, nil
}

func calcSelectionComplexity(sel ast.Selection, total *int, details map[string]int, depth int) {
	switch s := sel.(type) {
	case *ast.Field:
		fieldCost := 1
		if s.SelectionSet != nil {
			fieldCost *= depth
		}
		*total += fieldCost
		details[s.Name.Value] += fieldCost
		if s.SelectionSet != nil {
			for _, child := range s.SelectionSet {
				calcSelectionComplexity(child, total, details, depth+1)
			}
		}
	case *ast.InlineFragment:
		for _, child := range s.SelectionSet {
			calcSelectionComplexity(child, total, details, depth)
		}
	case *ast.FragmentSpread:
		for _, child := range s.Definition.SelectionSet {
			calcSelectionComplexity(child, total, details, depth)
		}
	}
}

永続化クエリとクエリ登録

プロダクション環境では永続化クエリ(Persisted Queries)を使用すべき——クライアントはクエリハッシュのみを送信し、完全なクエリテキストの送信を避け、未知のクエリの実行を防止する。

package persistedquery

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"sync"

	"github.com/99designs/gqlgen/graphql"
)

type PersistedQueryManager struct {
	mu      sync.RWMutex
	queries map[string]string
	strict  bool
}

func NewPersistedQueryManager(strict bool) *PersistedQueryManager {
	return &PersistedQueryManager{
		queries: make(map[string]string),
		strict:  strict,
	}
}

func (pqm *PersistedQueryManager) Register(hash, query string) {
	pqm.mu.Lock()
	defer pqm.mu.Unlock()
	pqm.queries[hash] = query
}

func (pqm *PersistedQueryManager) Middleware() graphql.RequestMiddleware {
	return func(ctx context.Context, next graphql.ResponseHandler) *graphql.Response {
		reqCtx := graphql.GetRequestContext(ctx)
		hash := reqCtx.RawQuery

		if len(hash) == 64 {
			pqm.mu.RLock()
			query, ok := pqm.queries[hash]
			pqm.mu.RUnlock()

			if ok {
				reqCtx.RawQuery = query
			} else if pqm.strict {
				panic(fmt.Sprintf("unknown persisted query: %s", hash))
			}
		}

		return next(ctx)
	}
}

func HashQuery(query string) string {
	h := sha256.Sum256([]byte(query))
	return hex.EncodeToString(h[:])
}

サブグラフキャッシュ戦略

サブグラフレベルのキャッシュは、特にホットエンティティの繰り返しクエリを大幅に削減できる。

package cache

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

type EntityCache struct {
	rdb    *redis.Client
	prefix string
	ttl    time.Duration
}

func NewEntityCache(redisURL, prefix string, ttl time.Duration) (*EntityCache, error) {
	opts, err := redis.ParseURL(redisURL)
	if err != nil {
		return nil, fmt.Errorf("invalid redis URL: %w", err)
	}

	return &EntityCache{
		rdb:    redis.NewClient(opts),
		prefix: prefix,
		ttl:    ttl,
	}, nil
}

func (c *EntityCache) Get(ctx context.Context, entityType, id string, dest interface{}) error {
	key := fmt.Sprintf("%s:%s:%s", c.prefix, entityType, id)
	val, err := c.rdb.Get(ctx, key).Result()
	if err == redis.Nil {
		return fmt.Errorf("cache miss for %s:%s", entityType, id)
	}
	if err != nil {
		return fmt.Errorf("cache read error: %w", err)
	}
	return json.Unmarshal([]byte(val), dest)
}

func (c *EntityCache) Set(ctx context.Context, entityType, id string, val interface{}) error {
	key := fmt.Sprintf("%s:%s:%s", c.prefix, entityType, id)
	data, err := json.Marshal(val)
	if err != nil {
		return fmt.Errorf("cache marshal error: %w", err)
	}
	return c.rdb.Set(ctx, key, data, c.ttl).Err()
}

func (c *EntityCache) Invalidate(ctx context.Context, entityType, id string) error {
	key := fmt.Sprintf("%s:%s:%s", c.prefix, entityType, id)
	return c.rdb.Del(ctx, key).Err()
}

func (c *EntityCache) InvalidatePattern(ctx context.Context, pattern string) error {
	iter := c.rdb.Scan(ctx, 0, fmt.Sprintf("%s:%s:*", c.prefix, pattern), 100).Iterator()
	var keys []string
	for iter.Next(ctx) {
		keys = append(keys, iter.Val())
	}
	if err := iter.Err(); err != nil {
		return fmt.Errorf("cache scan error: %w", err)
	}
	if len(keys) > 0 {
		return c.rdb.Del(ctx, keys...).Err()
	}
	return nil
}

技術比較

次元 Apollo Federation Schema Stitching REST API gRPC tRPC
学習曲線 中程度、フェデレーション概念の理解が必要 高い、手動での競合解決が必要 低い 中程度、Protoの学習が必要 低い(TypeScriptのみ)
スキーマ管理 自動構成、rover CLI 手動スティッチング、カスタムリゾルバ 統一スキーマなし Proto定義、自動生成 TypeScript型推論
チーム間コラボレーション 優秀、サブグラフが独立して進化 普通、競合の手動解決が必要 低い、APIドキュメントが陳腐化しやすい 良好、Protoが契約 TSフルスタックのみ
パフォーマンス 良好、クエリプランニング+バッチ解決 普通、N+1の手動処理が必要 低い、複数リクエスト 優秀、バイナリ+HTTP/2 良好、エンドツーエンドの型安全性
N+1防止 組み込みDataLoaderサポート 手動実装が必要 なし なし なし
エコシステム成熟度 高い、Apolloフルスタック 中程度、コミュニティソリューション 高い 高い 中程度
言語サポート 全言語、Go/Java/TS等 全言語 全言語 全言語 TypeScriptのみ
リアルタイムサブスクリプション サポート サポート WebSocketが必要 双方向ストリームが必要 サポート
オブザーバビリティ Apollo Studio統合 自前構築が必要 自前構築が必要 OpenTelemetry 自前構築が必要
ユースケース 大規模マイクロサービスAPI カスタム構成ロジック シンプルなCRUD 内部高性能通信 TSフルスタックプロジェクト

まとめ:GraphQL Federationは銀の弾丸ではないが、マイクロサービスGraphQLアーキテクチャに対する現在の最も成熟したソリューションである。核心原則:サブグラフはドメイン境界で分割、エンティティは@keyで宣言、N+1はDataLoaderで防止、ゲートウェイでクエリプランニングとレート制限、プロダクションでは認証・トレーシング・キャッシュが必須。2つのサブグラフから始めて段階的に分割し、最初から全部をやらないこと。スキーマチェックはCIで強制実行しなければ、破壊的変更がいずれスーパーグラフ全体を崩壊させる。


推奨ツール

ブラウザローカルツールを無料で試す →

#GraphQL#Federation#Go#微服务#Apollo#2026#API网关