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
}
}
}
}
ゲートウェイのクエリプランナーは次の実行計画を生成する:
- usersサブグラフからUserのname、emailを取得
- ordersサブグラフからUser(id="user-123")のordersを取得
- productsサブグラフからOrderItem内のProductエンティティを一括解決
- 結果をマージしてクライアントに返却
パターン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で強制実行しなければ、破壊的変更がいずれスーパーグラフ全体を崩壊させる。
推奨ツール
- JSONフォーマッター — GraphQLクエリレスポンスのフォーマット、スーパーグラフ構成結果のデバッグ
- Base64エンコード — JWTトークンと認証ヘッダーのエンコード
- ハッシュ計算 — 永続化クエリのSHA256ハッシュ値の計算
ブラウザローカルツールを無料で試す →