GraphQL Schema Stitching ile Birden Fazla API Birleştirme

Mikroservis mimarisine geçiş yapan bir ekipte çalışıyorsanız, muhtemelen şu soruyla karşılaşmışsınızdır: “Elimde birbirinden bağımsız 5 farklı API var, bunları frontend’den nasıl tek bir yerden sorgulayacağım?” İşte GraphQL Schema Stitching tam bu noktada devreye giriyor. Birden fazla GraphQL şemasını birleştirerek tek bir unified API sunan bu yaklaşım, özellikle REST’ten GraphQL’e geçiş döneminde hayat kurtarıcı oluyor.

Schema Stitching Nedir ve Neden Kullanırız?

Schema Stitching, birden fazla GraphQL şemasını tek bir üst şema altında birleştirme işlemidir. Bunu bir çeşit API gateway olarak düşünebilirsiniz, ancak GraphQL’e özgü süper güçlerle donatılmış bir gateway.

Gerçek dünya senaryosuna bakalım: Diyelim ki bir e-ticaret platformu yönetiyorsunuz. Kullanıcı yönetimi için ayrı bir servis, ürün kataloğu için ayrı bir servis, sipariş takibi için ayrı bir servis ve ödeme işlemleri için ayrı bir servisiniz var. Bu servislerin bir kısmı hâlâ REST, bir kısmı yeni GraphQL’e geçirilmiş durumda. Frontend ekibi ise her şeyi tek endpoint’ten sorgulamak istiyor.

Schema Stitching’in öne çıkan avantajları:

  • Tek endpoint: Frontend sadece /graphql adresine istek atar
  • Kademeli geçiş: REST servislerini yavaş yavaş GraphQL’e dönüştürebilirsiniz
  • Bağımsız deployment: Her servis kendi yaşam döngüsünde gelişmeye devam eder
  • Type birleştirme: Farklı şemalardan gelen tipler birbiriyle ilişkilendirilebilir
  • Merkezi authorization: Auth mantığını tek yerden yönetebilirsiniz

Temel Kurulum ve Bağımlılıklar

Önce projemizi hazırlayalım. Node.js tabanlı bir gateway servisi kuracağız:

mkdir graphql-gateway && cd graphql-gateway
npm init -y
npm install @graphql-tools/schema @graphql-tools/stitch @graphql-tools/wrap 
    @graphql-tools/remote-schemas graphql apollo-server express 
    node-fetch graphql-tag
npm install --save-dev typescript @types/node ts-node nodemon

TypeScript konfigürasyonu için:

cat > tsconfig.json << 'EOF'
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
EOF

mkdir src

Alt Servislerin Hazırlanması

Önce iki bağımsız GraphQL servisi oluşturalım. Gerçek senaryoda bunlar ayrı sunucularda çalışıyor olurdu.

Kullanıcı Servisi (port 4001):

cat > src/user-service.ts << 'EOF'
import { ApolloServer, gql } from 'apollo-server';

const typeDefs = gql`
  type User {
    id: ID!
    username: String!
    email: String!
    createdAt: String!
    role: String!
  }

  type Query {
    user(id: ID!): User
    users: [User!]!
    me: User
  }

  type Mutation {
    updateUserRole(userId: ID!, role: String!): User
  }
`;

const users = [
  { id: '1', username: 'ahmet_sys', email: '[email protected]', createdAt: '2024-01-15', role: 'admin' },
  { id: '2', username: 'zeynep_dev', email: '[email protected]', createdAt: '2024-02-20', role: 'developer' },
  { id: '3', username: 'mehmet_ops', email: '[email protected]', createdAt: '2024-03-10', role: 'operator' },
];

const resolvers = {
  Query: {
    user: (_: any, { id }: { id: string }) => users.find(u => u.id === id),
    users: () => users,
    me: () => users[0],
  },
  Mutation: {
    updateUserRole: (_: any, { userId, role }: { userId: string; role: string }) => {
      const user = users.find(u => u.id === userId);
      if (user) user.role = role;
      return user;
    },
  },
};

const server = new ApolloServer({ typeDefs, resolvers });
server.listen(4001).then(() => {
  console.log('User Service: http://localhost:4001');
});
EOF

Ürün Servisi (port 4002):

cat > src/product-service.ts << 'EOF'
import { ApolloServer, gql } from 'apollo-server';

const typeDefs = gql`
  type Product {
    id: ID!
    name: String!
    price: Float!
    stock: Int!
    category: String!
    ownerId: ID!
  }

  type Query {
    product(id: ID!): Product
    products(category: String): [Product!]!
    productsByOwner(ownerId: ID!): [Product!]!
  }

  type Mutation {
    createProduct(name: String!, price: Float!, stock: Int!, category: String!, ownerId: ID!): Product
    updateStock(productId: ID!, quantity: Int!): Product
  }
`;

const products = [
  { id: 'p1', name: 'Sunucu Rack', price: 15000, stock: 10, category: 'hardware', ownerId: '1' },
  { id: 'p2', name: 'SSD 2TB', price: 3500, stock: 50, category: 'storage', ownerId: '2' },
  { id: 'p3', name: 'UPS 3000VA', price: 8000, stock: 20, category: 'power', ownerId: '1' },
];

let idCounter = 4;

const resolvers = {
  Query: {
    product: (_: any, { id }: { id: string }) => products.find(p => p.id === id),
    products: (_: any, { category }: { category?: string }) =>
      category ? products.filter(p => p.category === category) : products,
    productsByOwner: (_: any, { ownerId }: { ownerId: string }) =>
      products.filter(p => p.ownerId === ownerId),
  },
  Mutation: {
    createProduct: (_: any, args: any) => {
      const product = { id: `p${idCounter++}`, ...args };
      products.push(product);
      return product;
    },
    updateStock: (_: any, { productId, quantity }: { productId: string; quantity: number }) => {
      const product = products.find(p => p.id === productId);
      if (product) product.stock = quantity;
      return product;
    },
  },
};

const server = new ApolloServer({ typeDefs, resolvers });
server.listen(4002).then(() => {
  console.log('Product Service: http://localhost:4002');
});
EOF

Gateway Servisi: Schema Stitching’in Kalbi

Şimdi asıl gateway’i oluşturalım. Bu servis her iki şemayı birleştirecek ve aralarındaki ilişkileri kuracak:

cat > src/gateway.ts << 'EOF'
import { ApolloServer } from 'apollo-server';
import { stitchSchemas } from '@graphql-tools/stitch';
import { schemaFromExecutor } from '@graphql-tools/wrap';
import { buildHTTPExecutor } from '@graphql-tools/executor-http';

async function buildGatewaySchema() {
  // Remote şemaları executor üzerinden al
  const userExecutor = buildHTTPExecutor({
    endpoint: 'http://localhost:4001/graphql',
  });

  const productExecutor = buildHTTPExecutor({
    endpoint: 'http://localhost:4002/graphql',
  });

  const userSchema = await schemaFromExecutor(userExecutor);
  const productSchema = await schemaFromExecutor(productExecutor);

  // Şemaları birleştir ve type merging tanımla
  return stitchSchemas({
    subschemas: [
      {
        schema: userSchema,
        executor: userExecutor,
        merge: {
          User: {
            fieldName: 'user',
            selectionSet: '{ id }',
            args: ({ id }: { id: string }) => ({ id }),
          },
        },
      },
      {
        schema: productSchema,
        executor: productExecutor,
        merge: {
          Product: {
            fieldName: 'product',
            selectionSet: '{ id }',
            args: ({ id }: { id: string }) => ({ id }),
          },
        },
      },
    ],
    // İki şema arasında köprü kuran ek tip tanımları
    typeDefs: `
      extend type Product {
        owner: User
      }
      extend type User {
        products: [Product!]!
      }
    `,
    resolvers: {
      Product: {
        owner: {
          selectionSet: '{ ownerId }',
          resolve(product, _args, context, info) {
            return info.mergeInfo.delegateToSchema({
              schema: userSchema,
              operation: 'query',
              fieldName: 'user',
              args: { id: product.ownerId },
              context,
              info,
            });
          },
        },
      },
      User: {
        products: {
          selectionSet: '{ id }',
          resolve(user, _args, context, info) {
            return info.mergeInfo.delegateToSchema({
              schema: productSchema,
              operation: 'query',
              fieldName: 'productsByOwner',
              args: { ownerId: user.id },
              context,
              info,
            });
          },
        },
      },
    },
  });
}

async function startGateway() {
  const schema = await buildGatewaySchema();

  const server = new ApolloServer({
    schema,
    context: ({ req }) => ({
      headers: req.headers,
      authToken: req.headers.authorization,
    }),
  });

  const { url } = await server.listen(4000);
  console.log(`Gateway hazir: ${url}`);
}

startGateway().catch(console.error);
EOF

REST API’leri Schema Stitching’e Dahil Etmek

Gerçek geçiş senaryolarında elinizde hâlâ REST API’ler bulunuyor. @graphql-tools/wrap ile REST’i de şemaya dahil edebilirsiniz:

cat > src/rest-wrapper.ts << 'EOF'
import { makeExecutableSchema } from '@graphql-tools/schema';
import { wrapSchema, TransformObjectFields } from '@graphql-tools/wrap';
import fetch from 'node-fetch';
import { print, GraphQLSchema } from 'graphql';

// REST API'yi simüle eden bir order servisi
// Gerçekte http://siparis-api.firma.internal gibi bir adres olurdu
const ORDER_API_BASE = 'http://localhost:5000/api';

// REST endpoint'leri için custom executor
const restExecutor = async ({ document, variables }: any) => {
  const query = print(document);

  // GraphQL sorgusunu parse edip REST çağrısına dönüştürme mantığı
  if (query.includes('order(')) {
    const id = variables?.id;
    const response = await fetch(`${ORDER_API_BASE}/orders/${id}`);
    const order = await response.json();
    return { data: { order } };
  }

  if (query.includes('ordersByUser')) {
    const userId = variables?.userId;
    const response = await fetch(`${ORDER_API_BASE}/orders?userId=${userId}`);
    const orders = await response.json();
    return { data: { ordersByUser: orders } };
  }

  return { data: {} };
};

// REST API'nin GraphQL şemasını tanımla
export const orderTypeDefs = `
  type Order {
    id: ID!
    userId: ID!
    productId: ID!
    quantity: Int!
    status: String!
    totalPrice: Float!
    createdAt: String!
  }

  type Query {
    order(id: ID!): Order
    ordersByUser(userId: ID!): [Order!]!
  }
`;

export function createOrderSubschema() {
  const schema = makeExecutableSchema({
    typeDefs: orderTypeDefs,
    resolvers: {
      Query: {
        // Resolver'lar executor tarafından yönetilecek
      },
    },
  });

  return {
    schema,
    executor: restExecutor,
    merge: {
      Order: {
        fieldName: 'order',
        selectionSet: '{ id }',
        args: ({ id }: { id: string }) => ({ id }),
      },
    },
  };
}
EOF

Monitoring ve Hata Yönetimi

Production ortamında schema stitching kullanıyorsanız, servislerden biri çökerse ne olur? Bunu yönetmek kritik:

cat > src/resilient-gateway.ts << 'EOF'
import { stitchSchemas } from '@graphql-tools/stitch';
import { schemaFromExecutor, wrapSchema } from '@graphql-tools/wrap';
import { buildHTTPExecutor } from '@graphql-tools/executor-http';
import { makeExecutableSchema } from '@graphql-tools/schema';

interface ServiceConfig {
  name: string;
  endpoint: string;
  timeout: number;
  retries: number;
}

const services: ServiceConfig[] = [
  { name: 'user-service', endpoint: 'http://localhost:4001/graphql', timeout: 5000, retries: 3 },
  { name: 'product-service', endpoint: 'http://localhost:4002/graphql', timeout: 5000, retries: 3 },
];

// Timeout ve retry mekanizması olan executor
function createResilientExecutor(config: ServiceConfig) {
  const baseExecutor = buildHTTPExecutor({
    endpoint: config.endpoint,
    timeout: config.timeout,
    headers: {
      'Content-Type': 'application/json',
      'X-Service-Name': 'graphql-gateway',
    },
  });

  return async (args: any) => {
    let lastError: Error | null = null;

    for (let attempt = 1; attempt <= config.retries; attempt++) {
      try {
        const result = await baseExecutor(args);
        return result;
      } catch (error: any) {
        lastError = error;
        console.error(`[${config.name}] Deneme ${attempt}/${config.retries} basarisiz:`, error.message);

        if (attempt < config.retries) {
          // Exponential backoff: 1s, 2s, 4s
          await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt - 1) * 1000));
        }
      }
    }

    // Tum denemeler basarisiz, fallback null donut
    console.error(`[${config.name}] Servis erisimi tamamen basarisiz`);
    return { data: null, errors: [{ message: `${config.name} servisi suan erisilebilir degil` }] };
  };
}

// Servis saglik kontrolu
async function checkServiceHealth(endpoint: string): Promise<boolean> {
  try {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query: '{ __typename }' }),
      signal: AbortSignal.timeout(2000),
    });
    return response.ok;
  } catch {
    return false;
  }
}

export async function buildResilientSchema() {
  const subschemas = [];

  for (const service of services) {
    const isHealthy = await checkServiceHealth(service.endpoint);

    if (!isHealthy) {
      console.warn(`[UYARI] ${service.name} sagliksiz, bos sema kullaniliyor`);
      // Servis down olsa bile gateway ayakta kalsin
      continue;
    }

    const executor = createResilientExecutor(service);
    const schema = await schemaFromExecutor(executor);
    subschemas.push({ schema, executor });
    console.log(`[OK] ${service.name} basariyla yuklendi`);
  }

  if (subschemas.length === 0) {
    throw new Error('Hicbir servis erisebilir degil!');
  }

  return stitchSchemas({ subschemas });
}
EOF

Performans Optimizasyonu: DataLoader Entegrasyonu

N+1 problemi schema stitching’de gerçek bir tehdit. Bir kullanıcının tüm ürünlerini listelerken her ürün için ayrı ayrı kullanıcı sorgusu atmak istemezsiniz:

cat > src/dataloader-setup.ts << 'EOF'
import DataLoader from 'dataloader';
import fetch from 'node-fetch';

// Batch user fetch - tek seferde birden fazla kullanıcı çek
export function createUserLoader() {
  return new DataLoader(async (userIds: readonly string[]) => {
    console.log(`[DataLoader] ${userIds.length} kullanici batch halinde cekiliyor`);

    const response = await fetch('http://localhost:4001/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        query: `
          query BatchUsers($ids: [ID!]!) {
            usersByIds(ids: $ids) {
              id
              username
              email
              role
            }
          }
        `,
        variables: { ids: userIds },
      }),
    });

    const { data } = await response.json() as any;
    const userMap = new Map(data.usersByIds.map((u: any) => [u.id, u]));

    // DataLoader, ayni sirada sonuc bekler
    return userIds.map(id => userMap.get(id) || null);
  }, {
    // 50ms icerisinde gelen istekleri batch'le
    batchScheduleFn: (callback) => setTimeout(callback, 50),
    maxBatchSize: 100,
  });
}

// Context factory - her request için yeni loader instance'ı
export function createContext() {
  return {
    loaders: {
      user: createUserLoader(),
    },
  };
}
EOF

Şema Dönüşümleri ve Filtreleme

Bazen alt şemalardan gelen tipleri veya alanları gizlemeniz ya da yeniden adlandırmanız gerekir:

cat > src/schema-transforms.ts << 'EOF'
import { 
  FilterObjectFields, 
  RenameTypes, 
  RenameRootFields,
  TransformObjectFields 
} from '@graphql-tools/wrap';

// Kullanici servisinden hassas alanlari gizle
export const userServiceTransforms = [
  // Sadece belirli alanlari public'e ac
  new FilterObjectFields((typeName, fieldName) => {
    if (typeName === 'User') {
      // password, internalNotes gibi hassas alanlari filtrele
      const publicFields = ['id', 'username', 'email', 'role', 'createdAt'];
      return publicFields.includes(fieldName);
    }
    return true;
  }),

  // Tip isimlerini namespace'le (cakisma olmasin diye)
  new RenameTypes((name) => {
    const renames: Record<string, string> = {
      'User': 'UserProfile',
      'UserEdge': 'UserProfileEdge',
    };
    return renames[name] ?? name;
  }),

  // Query isimlerini daha anlasilir hale getir
  new RenameRootFields((_, fieldName) => {
    const renames: Record<string, string> = {
      'me': 'currentUser',
      'users': 'allUsers',
    };
    return renames[fieldName] ?? fieldName;
  }),

  // Alan degerlerini transform et
  new TransformObjectFields((typeName, fieldName, fieldNode) => {
    // email alanini maskele (admin olmayan kullanicilar icin)
    if (typeName === 'User' && fieldName === 'email') {
      return {
        ...fieldNode,
        resolve: (parent: any, args: any, context: any) => {
          if (context.userRole !== 'admin') {
            const [local, domain] = parent.email.split('@');
            return `${local[0]}***@${domain}`;
          }
          return parent.email;
        },
      };
    }
    return fieldNode;
  }),
];
EOF

Production Deployment Senaryosu

Tüm bu servisleri Docker ile ayağa kaldıralım:

cat > docker-compose.yml << 'EOF'
version: '3.8'

services:
  user-service:
    build:
      context: .
      dockerfile: Dockerfile.service
    command: ts-node src/user-service.ts
    ports:
      - "4001:4001"
    environment:
      - NODE_ENV=production
      - DB_URL=postgresql://user:pass@postgres:5432/users
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:4001/graphql?query={__typename}"]
      interval: 30s
      timeout: 10s
      retries: 3
    restart: unless-stopped

  product-service:
    build:
      context: .
      dockerfile: Dockerfile.service
    command: ts-node src/product-service.ts
    ports:
      - "4002:4002"
    environment:
      - NODE_ENV=production
      - DB_URL=postgresql://user:pass@postgres:5432/products
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:4002/graphql?query={__typename}"]
      interval: 30s
      timeout: 10s
      retries: 3
    restart: unless-stopped

  gateway:
    build:
      context: .
      dockerfile: Dockerfile.service
    command: ts-node src/gateway.ts
    ports:
      - "4000:4000"
    environment:
      - NODE_ENV=production
      - USER_SERVICE_URL=http://user-service:4001/graphql
      - PRODUCT_SERVICE_URL=http://product-service:4002/graphql
      - SCHEMA_POLL_INTERVAL=30000
    depends_on:
      user-service:
        condition: service_healthy
      product-service:
        condition: service_healthy
    restart: unless-stopped
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.gateway.rule=Host(`api.firma.com`)"
      - "traefik.http.routers.gateway.tls=true"

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru
    restart: unless-stopped

networks:
  default:
    name: graphql-network
EOF

Schema Stitching vs Federation: Ne Zaman Hangisini Seçmeli?

Bu konuda çok sık soru geliyor. Kısa cevap: Apollo Federation daha opinionated ve büyük ekipler için daha uygun. Schema Stitching ise daha esnek.

Duruma göre rehber:

  • Schema Stitching tercih edin: Mevcut şemalar üzerinde değişiklik yapma imkanınız yoksa, farklı GraphQL kütüphaneleri kullanıyorsanız (Hasura, Prisma vb.), ekibiniz küçükse veya şemalar arasında ince ayarlı transformlar yapmanız gerekiyorsa.
  • Federation tercih edin: Tüm servisler Apollo Server kullanıyorsa, her ekip kendi şemasını bağımsız yönetecekse ve @key, @extends gibi directive’lerle çalışmakta problem görmüyorsanız.
  • İkisini birlikte: Evet, mümkün. Federation gateway’i olarak @apollo/gateway kullanıp remote şemaları stitching ile içeri alabilirsiniz.

Sık karşılaşılan sorunlar ve çözümleri:

  • Circular dependency: İki şema birbirini referans alıyorsa, delegateToSchema yerine lazy resolver kullanın.
  • Type conflict: Farklı şemalarda aynı isimde farklı tipler varsa RenameTypes transform’u uygulayın.
  • Slow schema building: Şema initialization’ını uygulama başlangıcında bir kez yapın, her request’te tekrar yapmayin. Redis’e serialize edip cache’leyebilirsiniz.
  • N+1 problemi: DataLoader olmadan batchleme yapamayan resolver’lar çok sayıda upstream istek açar. Her entity için loader yazın.

Sonuç

Schema Stitching, mikroservis geçiş döneminde gerçek anlamda köprü görevi gören bir yaklaşım. Tüm servislerinizi aynı anda GraphQL’e taşımanız gerekmiyor; REST’ten gelen verileri bile wrapper ile şemaya dahil edebiliyorsunuz.

Özetle pratikte dikkat etmeniz gereken başlıca noktalar şunlar:

  • Gateway başlangıcında şemaları önbelleğe alın, her request’te remote şema çekmeyin
  • Health check mekanizması kurun, bir servis düşünce gateway tamamen çöküyor olmamalı
  • DataLoader olmadan üretime gitmeyin, N+1 problemi yüklü ortamlarda ölümcül olabilir
  • Transform’ları erken tanımlayın, tip isim çakışmaları development’da değil production’da fark edilirse maliyetli olur
  • Schema polling ekleyin, alt servislerdeki şema değişikliklerini gateway otomatik fark etsin

Bu mimariye geçen projelerde genellikle frontend ekibinden ilk geri bildirim şu oluyor: “Artık hangi endpoint’i çağıracağımı düşünmüyorum.” Bu tek cümle bile Schema Stitching’i uygulamaya değer kılıyor.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir