İçeriğe atla

AWS AppSync & GraphQL: Production-Ready Real-time API'ler Geliştirmek

AWS AppSync ile ölçeklenebilir real-time API'ler: JavaScript resolver'lar, subscription filtering, caching stratejileri ve infrastructure as code pattern'leri.

Ayhan Sipahi Ayhan Sipahi

AWS AppSync; WebSocket subscription’ları, conflict resolution ve AWS data source’larına doğrudan bağlantı sunan yönetilen bir GraphQL endpoint veriyor. Çoğu ekip için geçerli kalan varsayılan dar olanı: resolver’ları JavaScript (APPSYNC_JS) runtime’ıyla doğrudan DynamoDB’ye bağla, Lambda’ya ancak bir field async I/O ya da runtime’ın ifade edemediği bir mantık istediğinde uzan.

Bu tek karar, latency ve maliyeti herhangi bir şema kararından daha fazla oynatıyor. Subscription filtering, caching katmanları, single-table ile multi-table modelleme ve bunların etrafındaki CDK bağlantıları hep bu kararın üzerine yapılan ince ayar.

Real-time API’lerin Zor Tarafları#

Real-time özellikler içeren modern uygulamalar geliştirmek, basit REST API development’ın ötesinde birkaç teknik zorluk sunuyor:

Infrastructure karmaşıklığı: WebSocket server’larını yönetmek, connection state’ini handle etmek, bidirectional communication’ı scale etmek ve high availability sağlamak gerekiyor. Geleneksel yaklaşımlar socket.io server’ları deploy etmeyi veya Redis pub/sub altyapısını maintain etmeyi içeriyor.

Data senkronizasyonu: Kullanıcılar offline olup pending değişikliklerle geri döndüğünde, birden çok client arasında veri tutarlılığını korumak katlanarak karmaşıklaşıyor. N-client problemi, her yeni kullanıcıyla potential conflict’lerin katlanarak artması demek.

Fine-grained authorization: REST API’ler genellikle endpoint seviyesinde authorize ederken, GraphQL field-level access control gerektiriyor. Tek bir query, nested field’lar boyunca farklı permission gereksinimleri olan veri isteyebiliyor.

Performance vs maliyet trade-off’ları: Real-time özellikler, long-lived WebSocket connection’lar, high-frequency subscription update’ler ve inefficient resolver implementation’lar yoluyla beklenmedik maliyetlere yol açabiliyor.

AppSync’de tipik bir request flow’u şöyle görünüyor:

DynamoDBDataSourceResolverAppSyncClientDynamoDBDataSourceResolverAppSyncClientGraphQL QueryResolver'ı çalıştırRequest'i dönüştürDynamoDB QueryVeriyi döndürRaw ResultResponse'u dönüştürGraphQL Response

Teknik Gereksinimler#

Production-ready bir real-time GraphQL API, şu teknik gereksinimleri karşılamalı:

Resolver performansı: JavaScript resolver’lar, VTL (Velocity Template Language), pipeline resolver’lar ve direct Lambda integration arasında seçim yapılmalı. Her yaklaşımın farklı latency karakteristikleri ve geliştirme karmaşıklığı var.

Subscription mimarisi: Client bandwidth ve processing overhead’i azaltmak için server-side filtering implement edilmeli. Traditional mutation-based subscription’larla yeni AppSync Events channel-based yaklaşım arasındaki farklar anlaşılmalı.

Caching layer’ları: AppSync’in built-in ElastiCache integration’ı, DynamoDB’nin long-term cache olarak kullanımı ve farklı access pattern’ler ve TTL gereksinimleri için DAX (DynamoDB Accelerator) değerlendirilmeli.

Data modeling stratejisi: Access pattern’lere göre single-table ve multi-table DynamoDB tasarımları arasında karar verilmeli. GraphQL schema yapısının database yapısını yansıtması gerekmiyor; bu esneklik hem güçlü hem de potansiyel olarak sorunlu olabiliyor.

Authorization konfigürasyonu: Granular access control için field-level directive’lerle multi-auth mode’ları (API Key, Cognito User Pools, IAM, OIDC, Lambda authorizer’lar) kurulmalı.

Uygulama#

AppSync Mimarisi#

AppSync, client’lar ile data source’lar arasında durarak, subscription’lar için entegre WebSocket desteği olan yönetilen bir GraphQL endpoint sağlıyor. Araya Lambda girmeden doğrudan AWS data source’larına bağlanabiliyor; latency ve maliyet tartışmasının çoğu bu tek özelliğe dayanıyor:

import * as appsync from 'aws-cdk-lib/aws-appsync';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { Construct } from 'constructs';

export class AppSyncApiStack extends Construct {
  public readonly api: appsync.GraphqlApi;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    // Multi-auth konfigürasyonuyla GraphQL API oluştur
    this.api = new appsync.GraphqlApi(this, 'Api', {
      name: 'production-api',
      definition: appsync.Definition.fromFile('schema.graphql'),
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: appsync.AuthorizationType.USER_POOL,
          userPoolConfig: {
            userPool: userPool,
          },
        },
        additionalAuthorizationModes: [
          { authorizationType: appsync.AuthorizationType.IAM },
          { authorizationType: appsync.AuthorizationType.API_KEY },
        ],
      },
      xrayEnabled: true,
      logConfig: {
        fieldLogLevel: appsync.FieldLogLevel.ALL,
        excludeVerboseContent: false,
      },
    });

    // Real-time update'ler için stream'li DynamoDB table
    const table = new dynamodb.Table(this, 'DataTable', {
      partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
      pointInTimeRecovery: true,
    });

    // Direct DynamoDB data source (Lambda yok)
    const dataSource = this.api.addDynamoDbDataSource('MainDataSource', table);
  }
}

Direct data source connection, Lambda invocation maliyetlerini ve cold start latency’sini ortadan kaldırıyor. Basit CRUD operasyonlarında istek AppSync runtime’ından hiç çıkmıyor; ödenecek bir init fazı ve ayarlanacak ikinci bir faturalandırma yüzeyi kalmıyor.

Modern JavaScript Resolver’lar#

AppSync artık VTL yerine JavaScript resolver’ları önerilen yaklaşım olarak destekliyor. İşte yaygın bir DynamoDB query operasyonunu kullanan pratik bir karşılaştırma:

Legacy VTL yaklaşımı (maintain etmesi daha zor):

{
  "version": "2018-05-29",
  "operation": "Query",
  "query": {
    "expression": "PK = :pk AND begins_with(SK, :sk)",
    "expressionValues": {
      ":pk": $util.dynamodb.toDynamoDBJson($ctx.args.userId),
      ":sk": $util.dynamodb.toDynamoDBJson("ORDER#")
    }
  },
  "index": "GSI1",
  "limit": $util.defaultIfNull($ctx.args.limit, 20),
  "nextToken": $util.toJson($ctx.args.nextToken)
}

Modern JavaScript yaklaşımı (daha iyi developer experience):

// resolvers/getUserOrders.js
import * as ddb from '@aws-appsync/utils/dynamodb';

export function request(ctx) {
  const { userId, limit = 20, nextToken } = ctx.args;

  return ddb.query({
    query: {
      PK: { eq: userId },
      SK: { beginsWith: 'ORDER#' },
    },
    index: 'GSI1',
    limit,
    nextToken,
  });
}

export function response(ctx) {
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type);
  }

  return {
    items: ctx.result.items,
    nextToken: ctx.result.nextToken,
  };
}

JavaScript resolver’ların önemli kısıtlamaları:

  • Async/await desteği yok (APPSYNC_JS runtime kısıtlaması)
  • Geleneksel for loop’lar yok (for-in, for-of veya array method’ları kullan)
  • try/catch block’ları yok (early return’ler ve explicit error handling kullan)
  • Sadece ECMAScript 6 subset’i

Kompleks async operasyonlar için Lambda function step’li pipeline resolver’lar veya direct Lambda resolver’lar kullan.

Multi-Step Operasyonlar için Pipeline Resolver’lar#

Pipeline resolver’lar, ek Lambda invocation’ları olmadan birden çok operasyonu compose etmeye izin veriyor. Bu pattern, authorization check’leri, quota enforcement ve data transformation’lar için iyi çalışıyor:

// Function 1: User quota kontrolü
export function request(ctx) {
  return {
    operation: 'GetItem',
    key: util.dynamodb.toMapValues({ userId: ctx.identity.sub }),
  };
}

export function response(ctx) {
  const quota = ctx.result?.quota ?? 0;

  if (quota <= 0) {
    util.error('API kotası aşıldı', 'QuotaExceeded');
  }

  // Quota bilgisini stash ile sonraki function'a aktar
  ctx.stash.currentQuota = quota;
  return ctx.result;
}
// Function 2: İstenen veriyi getir
export function request(ctx) {
  return {
    operation: 'Query',
    query: {
      expression: 'PK = :pk',
      expressionValues: {
        ':pk': util.dynamodb.toDynamoDB(ctx.args.id),
      },
    },
  };
}

export function response(ctx) {
  // Veriyi sonraki function'a aktar
  ctx.stash.data = ctx.result.items;
  return ctx.result;
}
// Function 3: Quota counter'ı güncelle
export function request(ctx) {
  return {
    operation: 'UpdateItem',
    key: util.dynamodb.toMapValues({ userId: ctx.identity.sub }),
    update: {
      expression: 'SET quota = quota - :decrement',
      expressionValues: {
        ':decrement': { N: 1 },
      },
    },
  };
}

export function response(ctx) {
  // Function 2'den gelen veriyi döndür
  return ctx.stash.data;
}

ctx.stash objesi, final function’a kadar gerçek response’u değiştirmeden pipeline function’lar arasında veri geçişine izin veriyor.

Enhanced Filtering ile Real-time Subscription’lar#

Traditional GraphQL subscription’lar mutation’larda trigger olur ama client’lar genellikle hangi update’leri alacaklarını filtrelemek ister. AppSync’in enhanced filtering’i bunu server-side yapıyor:

GraphQL schema:

type Subscription {
  onMessagePosted(roomId: ID!): Message
    @aws_subscribe(mutations: ["postMessage"])
}

type Mutation {
  postMessage(roomId: ID!, content: String!, userId: ID!): Message
}

type Message {
  id: ID!
  roomId: ID!
  userId: ID!
  content: String!
  timestamp: AWSDateTime!
}

Enhanced filtering ile subscription resolver:

// resolvers/onMessagePosted.js
export function request(ctx) {
  return { payload: null };
}

export function response(ctx) {
  // Server-side subscription filter ayarla
  const filter = {
    filterGroup: [
      {
        filters: [
          // Sadece bu room'un mesajları
          {
            fieldName: 'roomId',
            operator: 'eq',
            value: ctx.args.roomId,
          },
          // Mesaj yazana gönderme
          {
            fieldName: 'userId',
            operator: 'ne',
            value: ctx.identity.sub,
          },
        ],
      },
    ],
  };

  extensions.setSubscriptionFilter(util.transform.toSubscriptionFilter(filter));
  return null;
}

Mevcut filter operator’ler: eq, ne, in, notIn, gt, ge, lt, le, between, contains, notContains, beginsWith, containsAny. Bir grup içindeki filter’lar AND mantığı, birden çok grup OR mantığı kullanıyor.

Server-side filtreleme en çok, tek bir client’ın birden fazla room’a subscribe olduğu multi-tenant uygulamalarda fark yaratıyor. Filtreleme olmadan her client, mutation’ın ürettiği her mesajı alıyor ve payload ağı geçtikten sonra çoğunu atıyor.

AppSync Events: Channel-Based Real-time#

AppSync Events, GraphQL mutation’lardan decoupled, daha esnek bir real-time update yaklaşımı sağlıyor:

Publish

Filter

Authorize

Authorize

Wildcard

Publisher Lambda/HTTP

AppSync Channel

OnPublish Handler

Client 1

Client 2

Admin Client

Traditional subscription’lardan temel farklar:

ÖzellikTraditional SubscriptionsAppSync Events
TriggerGraphQL mutation’larHTTP/WebSocket publish
Schema couplingTight (mutation-based)Loose (channel-based)
FilteringField-based filter’larCustom handler’lar
Wildcard’larDesteklenmiyornamespace/channel/*
AuthorizationGraphQL directive’lerOnPublish/OnSubscribe handler’lar

Use case örneği: Cihazların HTTP ile publish ettiği ama client’ların WebSocket ile subscribe olduğu IoT sensor verisi:

// Lambda function HTTP ile AppSync Events channel'a publish ediyor
import { SignatureV4 } from '@aws-sdk/signature-v4';
import { Sha256 } from '@aws-crypto/sha256-js';

export async function handler(event) {
  // IoT sensor veri gönderiyor
  const sensorData = JSON.parse(event.body);

  const endpoint = `https://${process.env.APPSYNC_API_ID}.appsync-api.${process.env.AWS_REGION}.amazonaws.com`;
  const payload = JSON.stringify({
    channel: `device/${sensorData.deviceId}`,
    events: [JSON.stringify(sensorData)],
  });

  // SigV4 ile request'i imzala
  const signer = new SignatureV4({
    credentials: await import('@aws-sdk/credential-provider-node').then(m => m.defaultProvider()()),
    region: process.env.AWS_REGION,
    service: 'appsync',
    sha256: Sha256,
  });

  const signedRequest = await signer.sign({
    method: 'POST',
    hostname: `${process.env.APPSYNC_API_ID}.appsync-api.${process.env.AWS_REGION}.amazonaws.com`,
    path: `/event`,
    protocol: 'https:',
    headers: {
      'Content-Type': 'application/json',
      host: `${process.env.APPSYNC_API_ID}.appsync-api.${process.env.AWS_REGION}.amazonaws.com`,
    },
    body: payload,
  });

  const response = await fetch(`${endpoint}/event`, {
    method: 'POST',
    headers: signedRequest.headers,
    body: payload,
  });

  return { statusCode: response.status };
}

Client belirli device’a veya tüm device’lara subscribe oluyor:

subscription OnSensorData {
  subscribe(namespace: "sensors", channel: "device/sensor-123") {
    id
    data
  }
}

subscription OnAllSensors {
  subscribe(namespace: "sensors", channel: "device/*") {
    id
    data
  }
}

Caching Stratejileri#

AppSync, ElastiCache üzerinden built-in caching sağlıyor ama doğru caching stratejisini seçmek data freshness gereksinimleri ve maliyet kısıtlamalarına bağlı.

Yüksek

Düşük

Saniyeler

Dakika-Saat

Mikrosaniye

Cache Stratejisi Kararı

Access Frequency

Freshness Gereksinimi

Cache Gerekmiyor

AppSync Cache TTL 1-3600s

DynamoDB Cache Table

DynamoDB DAX

$$$ ElastiCache Saatlik Maliyet

$ Pay-per-request

$$ DAX Saatlik Maliyet

AppSync built-in cache konfigürasyonu:

// CDK konfigürasyonu
const resolver = dataSource.createResolver('GetProduct', {
  typeName: 'Query',
  fieldName: 'getProduct',
  code: appsync.Code.fromAsset('resolvers/getProduct.js'),
  runtime: appsync.FunctionRuntime.JS_1_0_0,
  cachingConfig: {
    ttl: Duration.minutes(5),
    cachingKeys: ['$context.identity.sub', '$context.arguments.id'],
  },
});

cachingKeys listesi cache’in nasıl bölüneceğini belirliyor. $context.identity.sub eklemek her kullanıcıya ayrı bir entry veriyor; kişiselleştirilmiş veri için doğru ama entry sayısını çoğaltıp hit rate’i aşağı çekiyor. Product catalog gibi paylaşılan reference data’da bu anahtarı listeye koyma.

Long-term cache olarak DynamoDB (pipeline resolver pattern):

// Function 1: Cache table'ı kontrol et
export function request(ctx) {
  return {
    operation: 'GetItem',
    key: util.dynamodb.toMapValues({ cacheKey: ctx.args.id }),
  };
}

export function response(ctx) {
  const cached = ctx.result;
  const now = util.time.nowEpochSeconds();

  // Cache'in valid olup olmadığını kontrol et
  if (cached && cached.ttl > now) {
    // Cache'lenmiş veriyi döndür, kalan function'ları skip et
    return JSON.parse(cached.data);
  }

  // Cache miss, sonraki function'a devam et
  return null;
}
// Function 2: Pahalı source'dan getir (external API, kompleks query)
// Function 3: Sonucu TTL attribute ile cache table'a kaydet
export function request(ctx) {
  const ttl = util.time.nowEpochSeconds() + 3600; // 1 saat

  return {
    operation: 'PutItem',
    key: util.dynamodb.toMapValues({ cacheKey: ctx.args.id }),
    attributeValues: util.dynamodb.toMapValues({
      data: JSON.stringify(ctx.prev.result),
      ttl: ttl,
    }),
  };
}

export function response(ctx) {
  return ctx.prev.result; // Function 2'den gelen veriyi döndür
}

Expired cache entry’lerini otomatik silmek için ttl attribute’unda DynamoDB TTL’i aktifleştir.

Şema Tasarımı: Single-table vs Multi-table#

Single-table ve multi-table DynamoDB tasarımı arasındaki seçim, resolver karmaşıklığını ve query performansını önemli ölçüde etkiliyor.

Multi-table design (daha basit resolver’lar, daha fazla esneklik):

UsersTable: PK=userId
ProductsTable: PK=productId
OrdersTable: PK=orderId, GSI: userId-timestamp

Order’larıyla birlikte user için GraphQL resolver iki query gerektiriyor:

// getUser resolver
export function request(ctx) {
  return { operation: 'GetItem', key: { id: ctx.args.userId } };
}

// user.orders resolver (ayrı resolver)
export function request(ctx) {
  return {
    operation: 'Query',
    index: 'userIdIndex',
    query: {
      userId: { eq: ctx.source.id },
    },
  };
}

Single-table design (kompleks resolver’lar, optimize edilmiş query’ler):

MainTable:
PK=USER#123, SK=PROFILE
PK=USER#123, SK=ORDER#2024-12-01#001
PK=USER#123, SK=ORDER#2024-11-30#002
PK=PRODUCT#789, SK=METADATA

Tek query user ve order’ları getiriyor:

export function request(ctx) {
  return {
    operation: 'Query',
    query: {
      PK: { eq: `USER#${ctx.args.userId}` },
    },
  };
}

export function response(ctx) {
  const items = ctx.result.items;

  // Profile'ı order'lardan ayır
  const profile = items.find(item => item.SK === 'PROFILE');
  const orders = items.filter(item => item.SK.startsWith('ORDER#'));

  return {
    ...profile,
    orders: orders,
  };
}

Her yaklaşımı ne zaman kullanmalı:

  • Multi-table: Prototyping, evolving schema’lar, bilinmeyen access pattern’ler, küçük-orta ölçek
  • Single-table: Bilinen access pattern’ler, high scale gereksinimleri, latency-critical uygulamalar, maliyet optimizasyonu

Authorization Mode’ları#

AppSync, tek bir API’de kombine edilebilen beş authorization mode destekliyor:

type Query {
  # API key ile erişilebilir public veri
  publicPosts: [Post] @aws_api_key

  # Sadece authenticated user'lar
  myPosts: [Post] @aws_cognito_user_pools

  # Sadece admin user'lar
  allUsers: [User] @aws_cognito_user_pools(cognito_groups: ["Admin"])

  # IAM ile service-to-service
  internalData: [Data] @aws_iam

  # Custom authorization logic
  partnerData: [Data] @aws_lambda
}

Custom logic için Lambda authorizer (örn: DynamoDB’de saklanan API key’leri validate etmek):

export async function handler(event: AppSyncAuthorizerEvent) {
  const apiKey = event.authorizationToken;

  // DynamoDB'de API key'i ara
  const result = await dynamodb.get({
    TableName: 'ApiKeys',
    Key: { apiKey },
  });

  if (!result.Item || result.Item.expiresAt < Date.now()) {
    return {
      isAuthorized: false,
      deniedFields: ['Query.*'],
    };
  }

  return {
    isAuthorized: true,
    resolverContext: {
      customerId: result.Item.customerId,
      tier: result.Item.tier,
    },
    ttlOverride: 300, // Authorization sonucunu 5 dakika cache'le
  };
}

resolverContext, resolver’larda ctx.identity.resolverContext ile erişilebilir ve custom authorization verisinin request boyunca akmasını sağlıyor.

Offline Support için Conflict Resolution#

Offline-first uygulamalar geliştirirken, concurrent update’leri handle etmek bir conflict resolution stratejisi gerektiriyor. AppSync üç yaklaşımı destekliyor:

1. Optimistic Concurrency (version kontrolü):

// Version check ile mutation resolver
export function request(ctx) {
  return {
    operation: 'UpdateItem',
    key: util.dynamodb.toMapValues({ id: ctx.args.id }),
    update: {
      expression: 'SET #content = :content, #version = :newVersion',
      expressionNames: {
        '#content': 'content',
        '#version': 'version',
      },
      expressionValues: {
        ':content': util.dynamodb.toDynamoDB(ctx.args.content),
        ':newVersion': util.dynamodb.toDynamoDB(ctx.args.version + 1),
        ':expectedVersion': util.dynamodb.toDynamoDB(ctx.args.version),
      },
    },
    condition: {
      expression: '#version = :expectedVersion',
      expressionNames: { '#version': 'version' },
    },
  };
}

export function response(ctx) {
  if (ctx.error) {
    // Version mismatch - conflict tespit edildi
    if (ctx.error.type === 'DynamoDB:ConditionalCheckFailedException') {
      util.error('Conflict: Item başka bir kullanıcı tarafından değiştirildi', 'ConflictError', ctx.result);
    }
    util.error(ctx.error.message, ctx.error.type);
  }
  return ctx.result;
}

2. Automerge (Amplify DataStore için default):

  • Conflicting olmayan field değişikliklerini otomatik merge eder
  • Collection’lar için set union kullanır
  • Scalar’lar için last-writer-wins kullanır

3. Custom Lambda resolver:

export async function handler(event: ConflictEvent) {
  const { base, local, remote } = event;

  // Custom merge logic
  const resolved = {
    ...base,
    // Content için local edit'leri tercih et
    content: local.content,
    // Numeric değerleri topla
    viewCount: (local.viewCount || 0) + (remote.viewCount || 0) - (base.viewCount || 0),
    // Array'leri merge et
    tags: [...new Set([...local.tags, ...remote.tags])],
  };

  return resolved;
}

Efficient synchronization için Delta Sync:

AppSync, değişiklikleri ayrı bir Delta Sync table’da track edebiliyor ve client’ların sadece son sync’lerinden itibaren değişen item’ları request etmelerini sağlıyor:

query SyncPosts($lastSync: AWSTimestamp!) {
  syncPosts(lastSync: $lastSync, limit: 100) {
    items {
      id
      content
      updatedAt
      _deleted
    }
    nextToken
  }
}

Uçtan Uca CDK Altyapı Örneği#

TypeScript resolver bundling ile production-ready bir AppSync API:

import * as cdk from 'aws-cdk-lib';
import * as appsync from 'aws-cdk-lib/aws-appsync';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as logs from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
import { execSync } from 'child_process';

export class ProductionAppSyncStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // TypeScript resolver'ları JavaScript'e build et
    execSync('npm run build:resolvers', {
      cwd: './resolvers',
      stdio: 'inherit',
    });

    // Authentication için Cognito User Pool
    const userPool = new cognito.UserPool(this, 'UserPool', {
      selfSignUpEnabled: true,
      userVerification: {
        emailSubject: 'E-posta adresini doğrula',
        emailBody: 'Doğrulama kodu: {####}',
      },
      signInAliases: { email: true },
      passwordPolicy: {
        minLength: 8,
        requireLowercase: true,
        requireUppercase: true,
        requireDigits: true,
      },
    });

    // Single-table design ile DynamoDB table
    const table = new dynamodb.Table(this, 'MainTable', {
      partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
      pointInTimeRecovery: true,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
      // Cache entry'ler için TTL aktifleştir
      timeToLiveAttribute: 'ttl',
    });

    // User-specific query'ler için GSI
    table.addGlobalSecondaryIndex({
      indexName: 'GSI1',
      partitionKey: { name: 'GSI1PK', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'GSI1SK', type: dynamodb.AttributeType.STRING },
      projectionType: dynamodb.ProjectionType.ALL,
    });

    // API log'ları için CloudWatch log group
    const logGroup = new logs.LogGroup(this, 'ApiLogs', {
      retention: logs.RetentionDays.ONE_WEEK,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // AppSync GraphQL API
    const api = new appsync.GraphqlApi(this, 'Api', {
      name: `${id}-api`,
      definition: appsync.Definition.fromFile('schema.graphql'),
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: appsync.AuthorizationType.USER_POOL,
          userPoolConfig: { userPool },
        },
        additionalAuthorizationModes: [
          { authorizationType: appsync.AuthorizationType.IAM },
          {
            authorizationType: appsync.AuthorizationType.API_KEY,
            apiKeyConfig: {
              expires: cdk.Expiration.after(cdk.Duration.days(365)),
            },
          },
        ],
      },
      xrayEnabled: true,
      logConfig: {
        fieldLogLevel: appsync.FieldLogLevel.ALL,
        excludeVerboseContent: false,
        cloudWatchLogsLogGroup: logGroup,
      },
    });

    // DynamoDB data source
    const dataSource = api.addDynamoDbDataSource('MainDataSource', table);

    // Bundled JavaScript file'lardan resolver'lar oluştur
    const resolvers = [
      { typeName: 'Query', fieldName: 'getUser', file: 'getUser.js' },
      { typeName: 'Query', fieldName: 'listPosts', file: 'listPosts.js' },
      { typeName: 'Mutation', fieldName: 'createPost', file: 'createPost.js' },
      { typeName: 'Mutation', fieldName: 'updatePost', file: 'updatePost.js' },
    ];

    resolvers.forEach(({ typeName, fieldName, file }) => {
      dataSource.createResolver(`${typeName}${fieldName}Resolver`, {
        typeName,
        fieldName,
        code: appsync.Code.fromAsset(`resolvers/dist/${file}`),
        runtime: appsync.FunctionRuntime.JS_1_0_0,
      });
    });

    // Output'lar
    new cdk.CfnOutput(this, 'GraphQLApiUrl', {
      value: api.graphqlUrl,
    });
    new cdk.CfnOutput(this, 'ApiKey', {
      value: api.apiKey || 'N/A',
    });
    new cdk.CfnOutput(this, 'UserPoolId', {
      value: userPool.userPoolId,
    });
  }
}

Resolver build script (resolvers/package.json):

{
  "scripts": {
    "build:resolvers": "esbuild src/*.ts --bundle --platform=node --target=es2020 --outdir=dist --format=esm"
  },
  "devDependencies": {
    "esbuild": "^0.19.0",
    "@aws-appsync/utils": "^1.3.0"
  }
}

Monitoring ve Observability#

Production AppSync API’leri, birden çok boyutta kapsamlı monitoring gerektiriyor:

CloudWatch Metrics (otomatik):

  • 4XXError ve 5XXError: Client ve server error rate’leri
  • Latency: Request processing süresi (P50, P95, P99)
  • ConnectedSubscriptions: Aktif WebSocket connection’lar
  • SubscriptionPublishErrors: Başarısız subscription delivery’ler

X-Ray tracing detaylı request flow görselleştirmesi sağlıyor:

// X-Ray gösteriyor:
// 1. AppSync API entry
// 2. Resolver execution süresi
// 3. DynamoDB query latency
// 4. Total request duration

Specific resolver sorunlarını debug etmek için field-level logging aktifleştir:

logConfig: {
  fieldLogLevel: appsync.FieldLogLevel.ALL, // Her resolver execution'ı logla
  excludeVerboseContent: false, // Request/response body'lerini dahil et
}

Custom CloudWatch dashboard:

const dashboard = new cloudwatch.Dashboard(this, 'ApiDashboard', {
  dashboardName: 'AppSync-Production',
});

dashboard.addWidgets(
  new cloudwatch.GraphWidget({
    title: 'Request Latency',
    left: [
      api.metricLatency({ statistic: 'p50' }),
      api.metricLatency({ statistic: 'p95' }),
      api.metricLatency({ statistic: 'p99' }),
    ],
  }),
  new cloudwatch.GraphWidget({
    title: 'Error Rate',
    left: [
      api.metric4XXError(),
      api.metric5XXError(),
    ],
  }),
);

Pattern’ler Arasında Seçim#

Yukarıdaki parçalar, şema birkaç düzine field’ı geçmeden önce netleştirilmesi gereken birkaç karara iniyor.

İlk karar resolver tipi. JavaScript resolver’lar basit CRUD’u, pipeline resolver’lar authorization check’in ardından gelen fetch gibi çok adımlı işleri, Lambda da APPSYNC_JS runtime’ının ifade edemediklerini karşılıyor: async I/O, üçüncü parti SDK’lar, ağır business logic. Direct DynamoDB resolver’ın yeteceği bir yola Lambda koymak, faturası ödenecek fazladan bir invocation ve açıklanacak bir cold start getiriyor.

Geri dönmesi en zor karar table layout’u. Access pattern’ler zaten belliyse ve tek query bir ekranı doldurabiliyorsa single-table kazandırıyor. Şema hâlâ oynuyorsa her resolver bağımsız kaldığı için multi-table kazandırıyor. Proje ortasında multi-table’dan single-table’a geçmek her resolver’ı yeniden yazmak ve her item’ı backfill etmek demek; bu yüzden kararı iyi vermekten çok erken vermek gerekiyor.

Subscription filtering’in kuralı daha basit: birden fazla consumer’ı olan her subscription server-side filtrelenmeli. Client-side filtreleme aynı payload’ı her subscriber’a gönderip cihazın atmasını bekliyor; bunun bedeli önce mobil şebekede ve batarya tüketiminde görülüyor.

Caching kararı verinin şekline göre ayrışıyor. Okuma frekansı yüksek, güncellemesi seyrek olan reference data; isabet alsın almasın saatlik faturalanan built-in AppSync cache’ine uyuyor. Saatlerce geçerli kalan user-specific data, TTL attribute’lu ve request başına faturalanan bir DynamoDB cache table’ına uyuyor. DAX ise erişimin zaten bir DynamoDB okuması olduğu ve hedefin mikrosaniye olduğu durumu karşılıyor.

İki maliyet kalemi genelde sürpriz oluyor. Subscription’lar mesaj başına olduğu kadar connection-minute başına da faturalanıyor; arka planda WebSocket’ini açık tutan mobil client, kimse bakmazken ücret biriktiriyor. Uygulama arka plana düştüğünde bağlantıyı kapat, öne döndüğünde yeniden subscribe ol. İkincisi, optimistic concurrency çakışan yazmayı kaybetmiyor, reddediyor; collaborative editing yayına çıkmadan önce client’ın ConflictError için bir retry yolu olmalı.

Çoğu API için geçerli kalan varsayılan dar olanı: direct data source’lar, JavaScript resolver’lar ve runtime gerçekten önünü kestiğinde Lambda. Bir field async çağrı, üçüncü parti SDK ya da early return’lerle ifade edilemeyecek bir hata yönetimi istiyorsa bu varsayılandan sap. Fazladan invocation maliyetini orada hak ediyor.

Kaynaklar#

İlgili yazılar