Serverless Mimari ile GraphQL API Oluşturma

Bulut dünyasında sunucu yönetiminden kurtulmak ve sadece iş mantığına odaklanmak artık bir lüks değil, rekabetçi kalabilmek için neredeyse zorunluluk haline geldi. Serverless mimarisi bu ihtiyacı karşılarken, GraphQL de REST’in getirdiği over-fetching ve under-fetching problemlerine modern bir çözüm sunuyor. Bu ikisini birleştirdiğinde hem altyapı yükünden kurtulan hem de esnek, performanslı bir API’ye sahip olan bir sistem elde ediyorsun. Bu yazıda AWS Lambda üzerinde GraphQL API nasıl kurulur, production ortamına nasıl taşınır ve karşılaşacağın sorunları nasıl çözersin, hepsini gerçek dünya senaryolarıyla ele alacağız.

Neden Serverless + GraphQL?

REST API’lerin klasik sorunlarından birini düşün: frontend ekibi bir sayfa için 5 farklı endpoint’e istek atmak zorunda kalıyor, ya da tek endpoint’ten ihtiyacı olmayan 50 alan geliyor. Bu hem bant genişliği israfı hem de frontend geliştirme sürecini yavaşlatan bir durum.

GraphQL bu sorunu şu şekilde çözüyor: client tam olarak neye ihtiyaç duyduğunu belirtiyor. Fazla veri yok, eksik veri yok. Serverless mimarisi de burada devreye giriyor çünkü GraphQL’in tek endpoint yapısı Lambda gibi fonksiyonlarla son derece uyumlu. Tek bir /graphql endpoint’ini bir Lambda fonksiyonuna yönlendiriyorsun, gerisi otomatik scale oluyor.

Gerçek dünya senaryosu olarak şunu düşün: E-ticaret platformun var, mobil uygulaman ürün listesi için kategori bilgisi, fiyat ve stok durumu istiyor. Admin panelin ise aynı ürün için tedarikçi bilgisi, maliyet ve kâr marjını istiyor. REST ile bu iki farklı endpoint ya da aşırı şişirilmiş bir response demek. GraphQL ile her client kendi ihtiyacına göre sorgu yazıyor.

Proje Yapısını Kurmak

Önce geliştirme ortamını hazırlayalım. Node.js tabanlı ilerleyeceğiz, AWS SAM kullanarak local geliştirme ve deployment sürecini yöneteceğiz.

# Gerekli araçları kur
npm install -g @aws/aws-sam-cli
npm install -g serverless

# Proje dizinini oluştur
mkdir graphql-serverless-api
cd graphql-serverless-api
npm init -y

# Temel bağımlılıkları yükle
npm install apollo-server-lambda graphql @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install --save-dev @types/aws-lambda esbuild serverless-esbuild

Proje dizin yapısı şu şekilde olmalı:

  • src/: Lambda fonksiyon kodları
  • src/schema/: GraphQL şema tanımlamaları
  • src/resolvers/: Resolver fonksiyonları
  • src/models/: DynamoDB veri modelleri
  • src/utils/: Yardımcı fonksiyonlar
  • template.yaml: SAM template dosyası

GraphQL Şemasını Tanımlamak

Şema tanımı GraphQL’in kalbidir. Burada yapacağın tasarım kararları ileride seni ya kurtarır ya da başının belasına dönüşür. Şema tasarımında domain-driven düşünmek önemli.

// src/schema/typeDefs.js
const { gql } = require('apollo-server-lambda');

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

  type Category {
    id: ID!
    name: String!
    slug: String!
    products: [Product!]!
  }

  type Order {
    id: ID!
    userId: ID!
    items: [OrderItem!]!
    totalAmount: Float!
    status: OrderStatus!
    createdAt: String!
  }

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

  enum OrderStatus {
    PENDING
    PROCESSING
    SHIPPED
    DELIVERED
    CANCELLED
  }

  type Query {
    product(id: ID!): Product
    products(
      categoryId: ID
      minPrice: Float
      maxPrice: Float
      limit: Int
      nextToken: String
    ): ProductConnection!
    category(id: ID!): Category
    categories: [Category!]!
    order(id: ID!): Order
    myOrders(userId: ID!, limit: Int): [Order!]!
  }

  type ProductConnection {
    items: [Product!]!
    nextToken: String
  }

  type Mutation {
    createProduct(input: CreateProductInput!): Product!
    updateProduct(id: ID!, input: UpdateProductInput!): Product!
    deleteProduct(id: ID!): Boolean!
    createOrder(input: CreateOrderInput!): Order!
    updateOrderStatus(id: ID!, status: OrderStatus!): Order!
  }

  input CreateProductInput {
    name: String!
    description: String
    price: Float!
    stock: Int!
    categoryId: ID!
  }

  input UpdateProductInput {
    name: String
    description: String
    price: Float
    stock: Int
  }

  input CreateOrderInput {
    userId: ID!
    items: [OrderItemInput!]!
  }

  input OrderItemInput {
    productId: ID!
    quantity: Int!
  }
`;

module.exports = typeDefs;

Lambda Handler ve Apollo Server Entegrasyonu

Ana handler dosyası, Lambda ile Apollo Server’ı birbirine bağlayan noktadır. Production ortamında handler’ın soğuk başlatma süresini minimize etmek kritik önem taşıyor.

// src/handler.js
const { ApolloServer } = require('apollo-server-lambda');
const typeDefs = require('./schema/typeDefs');
const resolvers = require('./resolvers');
const { createDynamoDBClient } = require('./utils/dynamodb');
const { verifyToken } = require('./utils/auth');

// DynamoDB client'ı handler dışında oluşturuyoruz
// Bu şekilde warm start'larda yeniden oluşturulmaz
const dynamoClient = createDynamoDBClient();

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: async ({ event, context }) => {
    // Her request için context oluştur
    let user = null;

    const authHeader = event.headers?.Authorization ||
                       event.headers?.authorization;

    if (authHeader && authHeader.startsWith('Bearer ')) {
      const token = authHeader.substring(7);
      try {
        user = await verifyToken(token);
      } catch (err) {
        console.warn('Token doğrulama hatası:', err.message);
      }
    }

    return {
      user,
      dynamoClient,
      requestId: context.awsRequestId,
    };
  },
  formatError: (error) => {
    // Hassas bilgileri client'a sızdırma
    console.error('GraphQL Hatası:', {
      message: error.message,
      path: error.path,
      extensions: error.extensions,
    });

    if (error.message.startsWith('Internal server error')) {
      return new Error('Bir hata oluştu, lütfen tekrar deneyin');
    }

    return error;
  },
  introspection: process.env.STAGE !== 'production',
  playground: process.env.STAGE !== 'production',
});

exports.graphqlHandler = server.createHandler({
  cors: {
    origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
    credentials: true,
  },
});

Resolver Yapısını Organize Etmek

Resolver’lar büyüdükçe tek dosyada tutmak kabus haline geliyor. Domain bazlı ayırım burada seni kurtarır.

// src/resolvers/productResolvers.js
const { UserInputError, ForbiddenError } = require('apollo-server-lambda');

const productResolvers = {
  Query: {
    product: async (_, { id }, { dynamoClient }) => {
      const result = await dynamoClient.get({
        TableName: process.env.PRODUCTS_TABLE,
        Key: { id },
      });

      if (!result.Item) {
        throw new UserInputError(`Ürün bulunamadı: ${id}`);
      }

      return result.Item;
    },

    products: async (_, { categoryId, minPrice, maxPrice, limit = 20, nextToken }, { dynamoClient }) => {
      let filterExpression = [];
      let expressionValues = {};

      if (categoryId) {
        filterExpression.push('categoryId = :categoryId');
        expressionValues[':categoryId'] = categoryId;
      }

      if (minPrice !== undefined) {
        filterExpression.push('price >= :minPrice');
        expressionValues[':minPrice'] = minPrice;
      }

      if (maxPrice !== undefined) {
        filterExpression.push('price <= :maxPrice');
        expressionValues[':maxPrice'] = maxPrice;
      }

      const params = {
        TableName: process.env.PRODUCTS_TABLE,
        Limit: limit,
        ExclusiveStartKey: nextToken
          ? JSON.parse(Buffer.from(nextToken, 'base64').toString())
          : undefined,
      };

      if (filterExpression.length > 0) {
        params.FilterExpression = filterExpression.join(' AND ');
        params.ExpressionAttributeValues = expressionValues;
      }

      const result = await dynamoClient.scan(params);

      return {
        items: result.Items || [],
        nextToken: result.LastEvaluatedKey
          ? Buffer.from(JSON.stringify(result.LastEvaluatedKey)).toString('base64')
          : null,
      };
    },
  },

  Mutation: {
    createProduct: async (_, { input }, { user, dynamoClient }) => {
      if (!user || user.role !== 'ADMIN') {
        throw new ForbiddenError('Bu işlem için yetkiniz yok');
      }

      const product = {
        id: require('crypto').randomUUID(),
        ...input,
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      };

      await dynamoClient.put({
        TableName: process.env.PRODUCTS_TABLE,
        Item: product,
        ConditionExpression: 'attribute_not_exists(id)',
      });

      return product;
    },

    updateProduct: async (_, { id, input }, { user, dynamoClient }) => {
      if (!user || user.role !== 'ADMIN') {
        throw new ForbiddenError('Bu işlem için yetkiniz yok');
      }

      const updateFields = Object.entries(input)
        .filter(([_, v]) => v !== undefined);

      if (updateFields.length === 0) {
        throw new UserInputError('Güncelleme için en az bir alan gerekli');
      }

      const updateExpression = 'SET ' + updateFields
        .map(([key]) => `#${key} = :${key}`)
        .join(', ') + ', updatedAt = :updatedAt';

      const expressionNames = Object.fromEntries(
        updateFields.map(([key]) => [`#${key}`, key])
      );

      const expressionValues = {
        ...Object.fromEntries(updateFields.map(([key, val]) => [`:${key}`, val])),
        ':updatedAt': new Date().toISOString(),
      };

      const result = await dynamoClient.update({
        TableName: process.env.PRODUCTS_TABLE,
        Key: { id },
        UpdateExpression: updateExpression,
        ExpressionAttributeNames: expressionNames,
        ExpressionAttributeValues: expressionValues,
        ConditionExpression: 'attribute_exists(id)',
        ReturnValues: 'ALL_NEW',
      });

      return result.Attributes;
    },
  },

  Product: {
    category: async ({ categoryId }, _, { dynamoClient }) => {
      const result = await dynamoClient.get({
        TableName: process.env.CATEGORIES_TABLE,
        Key: { id: categoryId },
      });
      return result.Item;
    },
  },
};

module.exports = productResolvers;

N+1 Problemini DataLoader ile Çözmek

Serverless GraphQL’in en sinir bozucu taraflarından biri N+1 problemi. Her ürün için ayrı bir kategori sorgusu atıyorsan, 100 ürünlük bir listede 100 DynamoDB çağrısı yapıyorsun. DataLoader bu sorunu batch’leyerek çözüyor.

// src/utils/dataLoaders.js
const DataLoader = require('dataloader');

const createCategoryLoader = (dynamoClient) => {
  return new DataLoader(
    async (categoryIds) => {
      // Tekrar eden id'leri temizle
      const uniqueIds = [...new Set(categoryIds)];

      // Batch get ile tek seferde çek
      const result = await dynamoClient.batchGet({
        RequestItems: {
          [process.env.CATEGORIES_TABLE]: {
            Keys: uniqueIds.map((id) => ({ id })),
          },
        },
      });

      const categories = result.Responses[process.env.CATEGORIES_TABLE];
      const categoryMap = Object.fromEntries(
        categories.map((cat) => [cat.id, cat])
      );

      // DataLoader sıralamayı korumamızı bekler
      return categoryIds.map((id) => categoryMap[id] || null);
    },
    {
      // Aynı request içinde cache'le
      cache: true,
      maxBatchSize: 100,
    }
  );
};

// Handler'da context'e ekle
// Her request için yeni loader oluşturmak önemli
const createLoaders = (dynamoClient) => ({
  category: createCategoryLoader(dynamoClient),
});

module.exports = { createLoaders };
// handler.js context kısmını güncelle
const { createLoaders } = require('./utils/dataLoaders');

context: async ({ event, context }) => {
  // ...mevcut kod...
  return {
    user,
    dynamoClient,
    loaders: createLoaders(dynamoClient), // Her request için taze loader
    requestId: context.awsRequestId,
  };
},

// Resolver'da kullan
Product: {
  category: async ({ categoryId }, _, { loaders }) => {
    return loaders.category.load(categoryId);
  },
},

SAM Template ile Deployment

# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: GraphQL Serverless API

Globals:
  Function:
    Timeout: 30
    MemorySize: 512
    Runtime: nodejs18.x
    Environment:
      Variables:
        PRODUCTS_TABLE: !Ref ProductsTable
        CATEGORIES_TABLE: !Ref CategoriesTable
        ORDERS_TABLE: !Ref OrdersTable
        STAGE: !Ref Stage

Parameters:
  Stage:
    Type: String
    Default: dev
    AllowedValues: [dev, staging, production]

Resources:
  GraphQLFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/handler.graphqlHandler
      Description: Ana GraphQL Lambda Fonksiyonu
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ProductsTable
        - DynamoDBCrudPolicy:
            TableName: !Ref CategoriesTable
        - DynamoDBCrudPolicy:
            TableName: !Ref OrdersTable
      Events:
        GraphQL:
          Type: Api
          Properties:
            Path: /graphql
            Method: ANY
            RestApiId: !Ref ApiGateway

  ApiGateway:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref Stage
      Cors:
        AllowMethods: "'GET,POST,OPTIONS'"
        AllowHeaders: "'Content-Type,Authorization'"
        AllowOrigin: "'*'"
      Auth:
        DefaultAuthorizer: NONE
      GatewayResponses:
        DEFAULT_4XX:
          ResponseParameters:
            Headers:
              Access-Control-Allow-Origin: "'*'"

  ProductsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
        - AttributeName: categoryId
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH
      GlobalSecondaryIndexes:
        - IndexName: CategoryIndex
          KeySchema:
            - AttributeName: categoryId
              KeyType: HASH
          Projection:
            ProjectionType: ALL
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true

Outputs:
  GraphQLEndpoint:
    Description: GraphQL API Endpoint
    Value: !Sub 'https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${Stage}/graphql'
# Build ve deploy
sam build --use-container
sam deploy --guided --stack-name graphql-api-production 
  --parameter-overrides Stage=production 
  --capabilities CAPABILITY_IAM

# Local test
sam local start-api --env-vars env.json

# Log takibi
sam logs -n GraphQLFunction --stack-name graphql-api-production --tail

Production’da Dikkat Edilmesi Gerekenler

Cold Start Optimizasyonu

Lambda’nın en büyük sorunu soğuk başlatma süreleri. GraphQL şeması büyüdükçe bu süre uzuyor. Birkaç pratik önlem:

  • Provisioned Concurrency kullan: Kritik Lambda’lar için belirli sayıda instance’ı sıcak tut. Maliyeti var ama latency garantisi sağlıyor.
  • Bundle size’ı küçük tut: esbuild ile tree-shaking yaparak gereksiz modülleri dışarıda bırak. apollo-server-lambda yerine @as-integrations/aws-lambda daha hafif bir alternatif.
  • Lazy loading: Kullanılmayan modülleri fonksiyonun başında değil, gerçekten ihtiyaç duyulduğunda yükle.

Hata Yönetimi ve Observability

// src/utils/logger.js
const logger = {
  info: (message, meta = {}) => {
    console.log(JSON.stringify({
      level: 'INFO',
      message,
      timestamp: new Date().toISOString(),
      ...meta,
    }));
  },
  error: (message, error, meta = {}) => {
    console.error(JSON.stringify({
      level: 'ERROR',
      message,
      error: {
        name: error?.name,
        message: error?.message,
        stack: process.env.STAGE !== 'production' ? error?.stack : undefined,
      },
      timestamp: new Date().toISOString(),
      ...meta,
    }));
  },
};

module.exports = logger;

CloudWatch Logs Insights ile query atmak için structured logging şart. JSON formatında log basmak bu nedenle standart haline gelmeli.

Rate Limiting ve DDoS Koruması

GraphQL’in tek endpoint yapısı API Gateway’in standart rate limiting’ini devre dışı bırakmaz ama complexity limiting eklemelisin. Kötü niyetli bir kullanıcı iç içe geçmiş devasa sorgularla sistemini çökertebilir.

npm install graphql-query-complexity

Apollo Server plugin olarak ekleyerek her sorgunun karmaşıklığını hesaplayabilir ve belirli bir eşiği aşan sorguları reddedebilirsin. Sınır değeri olarak genellikle 1000 iyi bir başlangıç noktası.

Gerçek Dünya Senaryosu: E-ticaret API Testi

Sistemi ayağa kaldırdıktan sonra şu sorgu ile test edebilirsin:

# API endpoint'ini test et
curl -X POST https://your-api-id.execute-api.eu-west-1.amazonaws.com/production/graphql 
  -H "Content-Type: application/json" 
  -H "Authorization: Bearer your-jwt-token" 
  -d '{
    "query": "query GetProducts($categoryId: ID, $limit: Int) { products(categoryId: $categoryId, limit: $limit) { items { id name price stock category { name slug } } nextToken } }",
    "variables": {
      "categoryId": "elektronik",
      "limit": 10
    }
  }'

# Mutation testi
curl -X POST https://your-api-id.execute-api.eu-west-1.amazonaws.com/production/graphql 
  -H "Content-Type: application/json" 
  -H "Authorization: Bearer admin-jwt-token" 
  -d '{
    "query": "mutation CreateProduct($input: CreateProductInput!) { createProduct(input: $input) { id name price createdAt } }",
    "variables": {
      "input": {
        "name": "Test Ürün",
        "price": 299.99,
        "stock": 50,
        "categoryId": "elektronik"
      }
    }
  }'

Maliyet Optimizasyonu

Serverless’ın güzelliği maliyet modelinde yatıyor ama yanlış kullanırsan sürprizlerle karşılaşabilirsin. DynamoDB için On-Demand yerine iş yükünü tahmin edebildiğinde Provisioned kapasiteye geç. Lambda için memory ayarını doğru yapmak önemli: 512MB yeterli olduğunda 1024MB tahsis etmek hem yavaşlatmaz hem de maliyeti ikiye katlar değil ama optimize de etmez. Lambda Power Tuning aracını kullanarak optimum memory değerini bul.

Sonuç

Serverless + GraphQL kombinasyonu doğru kurulduğunda gerçekten güçlü bir altyapı sunuyor. Altyapı yönetimiyle vakit kaybetmek yerine iş mantığına odaklanabiliyorsun, scale otomatik geliyor ve maliyet kullanım bazlı oluyor.

Ancak bu yapının da kendine özgü zorlukları var. Soğuk başlatmalar, N+1 problemleri, şema tasarımındaki kararların ileride nasıl etki edeceği ve debugging zorluğu bunların başında geliyor. Bu yazıda ele aldığımız DataLoader, structured logging, complexity limiting ve bundle optimizasyonu gibi pratikler bu sorunları büyük ölçüde gideriyor.

Başlangıçta küçük tut: tek bir domain’den başla, şemayı iyi tasarla, observability’yi baştan kur. Sonra her şeyi yerleştirilmiş ama ölçeklenemeyen bir monolith’ten dönüştürmeye çalışmak yerine temelden sağlam bir sistem inşa etmiş olursun. Serverless ile GraphQL öğrenme eğrisi var ama yerleştiğinde altyapıyı düşünmeden API geliştirmenin keyfini çıkarıyorsun.

Bir yanıt yazın

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