İçeriğe atla

DynamoDB Toolbox Rehberi: TypeScript Single-Table Tasarımı

Raw AWS SDK karmaşıklığından üretime hazır single-table tasarımına. Pratik DynamoDB Toolbox desenleri, yaygın tuzaklar ve ölçeklenen mimari kararları.

Ayhan Sipahi Ayhan Sipahi

Raw DynamoDB SDK call’ları ile serverless API’lerin inşası önemli bakım yükü yaratır. Binlerce satır AttributeValue mapping’i, onlarca dağınık UpdateExpression string’i ve sıfır type safety kırılgan sistemlere yol açar. Schema değişiklikleri yanlışlıkla kullanıcı kayıtlarını bozduğunda, daha iyi bir yaklaşımın gerekli olduğu açık hale gelir.

Burada varsayılan olarak DynamoDB Toolbox’a uzanmak mantıklı. Kütüphane table’ı tipli entity tanımlarıyla sarar; key üretimi, default’lar ve marshalling artık her çağrı noktasında elle yazılmaz. İşin çoğu üç yerde toplanır: entity tasarımı, iş kurallarını üstlenen bir servis katmanı ve raw SDK’dan kademeli geçiş.

Baştan bir uyarı: entity tanımlama API’si iki kez biçim değiştirdi ve arama sonuçları üç kuşağı birbirine karıştırıyor. Aşağıdaki bütün kod, npm’in şu anda latest olarak sunduğu v2 hattını hedefliyor.

Araç İhtiyacını Doğuran Sorunlar#

AttributeValue Karmaşıklığı#

Raw DynamoDB SDK ile çalışmak her gün böyle kod yazmak demekti:

// Raw SDK: her key, isim ve değer elle yazılıyor
const params = {
  TableName: 'Users',
  Key: {
    'PK': { S: `USER#${userId}` },
    'SK': { S: `PROFILE#${userId}` }
  },
  UpdateExpression: 'SET #email = :email, #updatedAt = :updatedAt, #version = #version + :inc',
  ExpressionAttributeNames: {
    '#email': 'email',
    '#updatedAt': 'updatedAt',
    '#version': 'version'
  },
  ExpressionAttributeValues: {
    ':email': { S: newEmail },
    ':updatedAt': { S: new Date().toISOString() },
    ':inc': { N: '1' }
  },
  ConditionExpression: 'attribute_exists(PK) AND #version = :currentVersion',
  ReturnValues: 'ALL_NEW'
};

const result = await dynamodb.updateItem(params).promise();

Şimdi bunu bir servisteki her operasyonla çarpın. Ortada ne type safety var, ne validation, ne de bir user kaydının nasıl görünmesi gerektiğine dair ortak bir tanım.

Schema Validasyon Problemi#

Yaygın bir senaryo: user record’larına preferences field’ı eklemek. Uygun validasyon olmadan, field eklemek yerine tüm record yapısını overwrite etmek kolay. Şunlar yanlış gidebilir:

// Amaçlanan
const updateParams = {
  UpdateExpression: 'SET preferences = :prefs',
  ExpressionAttributeValues: {
    ':prefs': { M: { theme: { S: 'dark' } } }
  }
};

// Gerçekte ne oldu (copy-paste hatası)
const updateParams = {
  UpdateExpression: 'SET preferences = :prefs',
  ExpressionAttributeValues: {
    ':prefs': { S: JSON.stringify({ theme: 'dark' }) } // Yanlış tür!
  }
};

Sonuç: bozulmuş kullanıcı kayıtları ve emergency data recovery. Bu, type safety ve validasyonun production sistemler için neden kritik olduğunu gösterir.

UpdateExpression Tutarlılık Zorluğu#

Büyük kod tabanları zamanla servislere dağılmış onlarca farklı UpdateExpression string’i biriktirir. Her varyasyon yeni bir hata ihtimali getirir:

// user-service.ts'de
'SET #email = :email, #updatedAt = :updatedAt'

// profile-service.ts'de
'SET email = :email, updatedAt = :updatedAt' // # eksik

// preferences-service.ts'de
'SET #email = :e, #updated = :u' // Farklı attribute isimleri

// admin-service.ts'de
'SET email = :email, #updatedAt = :updatedAt' // Karışık stil

Buradaki hiçbir şey tutarlı ya da yeniden kullanılabilir değil; bu yüzden her değişiklik sessizce yanlış attribute’a yazma riski taşır.

DynamoDB Toolbox’ı Keşfetmek#

DynamoDB karmaşıklığı için çözümler değerlendirilirken, DynamoDB Toolbox birkaç anahtar yetenek nedeniyle öne çıkıyor:

  • Type safety - AttributeValue cehenneminin sonu
  • Schema validation - Hataları production’a ulaşmadan yakala
  • Single-table design desteği - Birden fazla entity ek tesisat olmadan tek table’ı paylaşır
  • TypeScript-first - Tipler entity tanımından türetilir, ayrıca elde tutulmaz

Bu özellikler, raw DynamoDB operasyonlarını sürdürülmesi zor yapan temel zorlukları ele alıyor.

Entity ve Servis Mimarisi#

Entity tanımlarını, iş mantığını ve handler’ları ayrı dosyalarda tutan bir yerleşim:

Temel: Type-Safe Entity Tanımları#

npm registry’si dynamodb-toolbox için 221 yayımlanmış sürüm listeliyor; ilki 2019-12-06 tarihli 0.1.0. Entity API’si bu sürümlerin hepsinde aynı değil. Kopyalanan snippet’lerin neden sık sık derlenmediğini registry’deki takvim açıklıyor: 0.9.5, 0.x hattını 2024-05-05’te kapattı; 1.0.0 2024-07-19’da geldi; 2.0.0 ise 1.16.3’ün 1.x hattını kapatmasından bir gün sonra, 2025-03-06’da yayımlandı. Geriye 1.x için yaklaşık yedi buçuk aylık bir pencere kalıyor, ama o pencerede yazılan içerik hâlâ arama sonuçlarına çıkıyor. v2.0.0 sürüm notu kırılmayı açıkça yazıyor: schema söz diziminde büyük değişiklikler var ve dahili entity attribute’undan vazgeçme seçeneği geliyor. npm’deki güncel latest ise 2026-08-07’de yayımlanan 2.10.4; aşağıdaki kod da bunu hedefliyor.

Yaygın olarak dolaşan iki attribute key’i hiç var olmadı. 0.9.5’in yayımlanmış PureAttributeDefinition tipi tam 18 key’den oluşan kapalı bir liste: partitionKey, sortKey, type, default, dependsOn, transform, format, coerce, save, onUpdate, hidden, required, alias, map, setType, delimiter, prefix ve suffix. Ne validate ne de properties bu listede var. Yani attribute düzeyinde bir validate: callback’i ve map altında iç içe bir properties: bloğu, örneklerde ne kadar sık görünürse görünsün, hiçbir zaman geçerli 0.x olmadı.

v2 ikisine de bir yer açıyor. Schema builder’ı 13 tip sunuyor (any, null, boolean, number, string, binary, set, list, tuple, item, map, record, anyOf), her entity’yi item({...}) kökünde topluyor ve iç içe yapıyı map(attributes, props?) ile kuruyor; buradaki attribute sözlüğü bir key değil, ilk konumsal argüman. Validasyon ise zincirlenebilir bir metoda dönüştü: dokümantasyon bir validator’ı, schema’nın doğruladığı bir girdiyi alıp boolean döndüren fonksiyon olarak tanımlıyor ve .validate(), .putValidate(), .updateValidate(), .keyValidate() metotlarını veriyor. .validate(), key schema’larında keyValidate gibi, diğer durumlarda putValidate gibi davranıyor. Bir validator true ya da bir hata metni döndürür; değeri değiştiremez, bu yüzden e-postayı küçük harfe çevirmek transformer’ın ya da servis katmanının işi.

// lib/database/entities.ts - table ve entity tanımları, dynamodb-toolbox 2.10.4
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
import { Table } from 'dynamodb-toolbox/table';
import { Entity } from 'dynamodb-toolbox/entity';
import type { FormattedItem } from 'dynamodb-toolbox/entity';
import { item } from 'dynamodb-toolbox/schema/item';
import { string } from 'dynamodb-toolbox/schema/string';
import { number } from 'dynamodb-toolbox/schema/number';
import { boolean } from 'dynamodb-toolbox/schema/boolean';
import { map } from 'dynamodb-toolbox/schema/map';
import { set } from 'dynamodb-toolbox/schema/set';

// DYNAMODB_ENDPOINT AWS'te boş kalır, testlerde DynamoDB Local'i gösterir.
const dynamoClient = new DynamoDBClient({
  region: process.env.AWS_REGION,
  endpoint: process.env.DYNAMODB_ENDPOINT,
});

export const documentClient = DynamoDBDocumentClient.from(dynamoClient, {
  marshallOptions: { removeUndefinedValues: true, convertEmptyValues: false },
  unmarshallOptions: { wrapNumbers: false },
});

export const MainTable = new Table({
  name: process.env.MAIN_TABLE_NAME!,
  partitionKey: { name: 'PK', type: 'string' },
  sortKey: { name: 'SK', type: 'string' },
  indexes: {
    GSI1: {
      type: 'global',
      partitionKey: { name: 'GSI1PK', type: 'string' },
      sortKey: { name: 'GSI1SK', type: 'string' },
    },
    GSI2: {
      type: 'global',
      partitionKey: { name: 'GSI2PK', type: 'string' },
      sortKey: { name: 'GSI2SK', type: 'string' },
    },
  },
  documentClient,
});

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const USERNAME = /^[a-zA-Z0-9_-]+$/;

// Sahiplenilen her değer için bir kural; aşağıya gömmek yerine dışa aktarılıyor.
// Schema bunları put validator'ı olarak kullanıyor, servis katmanı aynı kuralı
// put validator'ının söz sahibi olmadığı değişiklik yolunda çağırıyor, handler
// da isteği bunlarla ayrıştırıyor. Bu üç yerden yalnızca birinde duran bir
// kısıt, sonraki bir değişikliğin kayıtta reddedilecek bir değeri saklamasına
// izin verir.
export const claimRules = {
  email: (value: string) => EMAIL.test(value) || 'Provide a valid email address',
  username: (value: string) =>
    (value.length >= 3 && value.length <= 30 && USERNAME.test(value)) ||
    'Username must be 3 to 30 letters, digits, hyphens or underscores',
};

const userSchema = item({
  userId: string().key(),
  // Validator true ya da hata metni döndürür. Değeri asla yeniden yazmaz.
  // Key olmayan bir attribute'ta `.validate()` bir put validator'ı; aşağıdaki
  // iki kural update sırasında çalışmaz.
  email: string().validate(claimRules.email),
  username: string().validate(claimRules.username),
  firstName: string().optional(),
  lastName: string().optional(),
  avatar: string().optional(),
  bio: string().optional(),
  // İç içe yapı konumsal bir attribute sözlüğü, `properties` key'i değil.
  preferences: map({
    theme: string().default('light'),
    notifications: boolean().default(true),
    language: string().default('en'),
  }).default({ theme: 'light', notifications: true, language: 'en' }),
  version: number().default(1),
}).and(prev => ({
  // Index attribute'ları item'dan türetiliyor; çağıran hiçbirini unutamıyor.
  GSI1PK: string().link<typeof prev>(({ email }) => `EMAIL#${email}`),
  GSI1SK: string().link<typeof prev>(({ userId }) => `USER#${userId}`),
  GSI2PK: string().link<typeof prev>(({ username }) => `USERNAME#${username}`),
  GSI2SK: string().link<typeof prev>(({ userId }) => `USER#${userId}`),
}));

export const UserEntity = new Entity({
  name: 'User',
  table: MainTable,
  schema: userSchema,
  // Client request token taşıyan bir transaction'a giren her entity'de kapalı.
  // Otomatik `created` ve `modified` değerleri her build'de yeniden üretiliyor;
  // yeniden kullanılan bir token ise aynı payload'ı iki kez istiyor.
  timestamps: false,
  computeKey: ({ userId }) => ({ PK: `USER#${userId}`, SK: `USER#${userId}` }),
});

// Sahiplenilen her değer için bir nöbetçi item. Servis katmanı bunları
// kullanıcıyla aynı transaction'da yazıyor; mükerrer kayıt veritabanında eleniyor.
export const UniqueValueEntity = new Entity({
  name: 'UniqueValue',
  table: MainTable,
  schema: item({
    scope: string().key().enum('EMAIL', 'USERNAME'),
    claim: string().key(),
    userId: string(),
  }),
  timestamps: false,
  computeKey: ({ scope, claim }) => ({
    PK: `${scope}#${claim}`,
    SK: `${scope}#${claim}`,
  }),
});

export const OrganizationEntity = new Entity({
  name: 'Organization',
  table: MainTable,
  schema: item({
    orgId: string().key(),
    name: string(),
    domain: string().optional(),
    plan: string().default('free'),
    settings: map({
      maxUsers: number().default(10),
      features: set(string()).default(new Set(['basic'])),
      billing: map({
        customerId: string().optional(),
        subscriptionId: string().optional(),
      }).optional(),
    }).default({ maxUsers: 10, features: new Set(['basic']) }),
  }).and(prev => ({
    GSI1PK: string()
      .optional()
      .link<typeof prev>(({ domain }) => (domain ? `DOMAIN#${domain}` : undefined)),
    GSI1SK: string()
      .optional()
      .link<typeof prev>(({ orgId }) => `ORG#${orgId}`),
  })),
  timestamps: false,
  computeKey: ({ orgId }) => ({ PK: `ORG#${orgId}`, SK: `ORG#${orgId}` }),
});

export const MembershipEntity = new Entity({
  name: 'Membership',
  table: MainTable,
  schema: item({
    orgId: string().key(),
    userId: string().key(),
    role: string().enum('owner', 'admin', 'member').default('member'),
    permissions: set(string()).default(new Set<string>()),
    invitedBy: string().optional(),
    status: string().enum('active', 'invited', 'removed').default('active'),
  }).and(prev => ({
    GSI1PK: string().link<typeof prev>(({ userId }) => `USER#${userId}`),
    GSI1SK: string().link<typeof prev>(({ orgId }) => `ORG#${orgId}`),
  })),
  timestamps: false,
  computeKey: ({ orgId, userId }) => ({
    PK: `ORG#${orgId}`,
    SK: `USER#${userId}`,
  }),
});

// Tipler entity'den geliyor; schema'dan kopmaları mümkün değil.
export type User = FormattedItem<typeof UserEntity>;
export type Organization = FormattedItem<typeof OrganizationEntity>;
export type Membership = FormattedItem<typeof MembershipEntity>;

// Düz bir update'in dokunabileceği attribute'lar, tek tek yazılmış hali. Key'ler,
// version sayacı, sahiplenilen değerler ve onlara bağlı index attribute'ları
// listede yok; bu yüzden bu yoldan bir kullanıcı ne index partition'ı
// değiştirebiliyor ne de arkasında bir teklik nöbetçisi bırakabiliyor.
export type UserProfileUpdate = Partial<
  Pick<User, 'firstName' | 'lastName' | 'avatar' | 'bio' | 'preferences'>
>;

Burada üç şey tanımın dışına, tip sisteminin içine taşındı. Key üretimi computeKey içinde yaşıyor; PK ve SK metinleri tam olarak tek bir yerde kuruluyor. Index attribute’ları tanımladıkları item’a bağlı olduğu için hiçbir çağıran GSI1PK yazmadan bir kullanıcı yazamıyor. FormattedItem<typeof UserEntity> de TypeScript tipini schema’dan türetiyor, altına yeniden yazmıyor; elle yazılan bir tip ilk orada schema’dan kopar. Yanındaki update tipi de bir dışlama listesi değil, izin listesi: kısmi bir update’in yazabileceği beş attribute’u adıyla sayıyor, böylece schema’ya sonradan eklenen bir alan tipi kazara genişletemiyor.

Servis Katmanı#

Bu katmanda üç tuzak var ve üçü de doğru göründüğü için code review’dan geçiyor.

Birincisi query imzası. 0.x’te yayımlanan Entity tipi query(pk: any, options?: EntityQueryOptions, params?: Partial<QueryInput>) biçiminde; yani ilk konumsal argüman partition key’in değeri. Karşılaştırma key’leri options nesnesine ait ve $QueryOptions tam on tanesini sayıyor (reverse, select, eq, lt, lte, gt, gte, between, beginsWith, startKey); bunlar sort key’e uygulanıyor. query('GSI1PK', { eq: 'EMAIL#...' }, { index: 'GSI1' }) biçiminde yazılan bir çağrı bu yüzden key’i düz metin "GSI1PK" olan partition’ı istiyor, sonra GSI1SK’yı filtreliyor. eq kelimesi iki kuşakta da geçiyor; ikisinin bu kadar kolay karışmasının sebebi tam olarak bu. v2 her şeyi tek nesneye alarak belirsizliği kaldırıyor: .query({ partition, index, range }).

İkincisi, bir yazma işleminin yazdığı şeyi geri vermesini beklemek. AWS’nin PutItem referansı net: PutItem, ReturnValues için NONE ve ALL_OLD dışında bir değer tanımıyor ve Attributes alanı işlemden önceki değerleri taşıyor. 0.9.5’in tipleri de aynı şeyi söylüyor: put’un dönüş değerleri 'NONE' | 'ALL_OLD' olarak tiplenmiş ve varsayılanda Attributes yanıttan tamamen düşüyor. Yani put sonrası result.Item iki ayrı sebeple yanlıştı: key adı yanlış ve o veri zaten telde yok. v2 bunun yerine put yanıtına ToolboxItem koyuyor; hesaplanmış key’ler ve default’lar dahil, kütüphanenin gönderdiği item’ın tamamı. Update ayrı bir operasyon: UpdateItem, ALL_NEW kabul ediyor, dolayısıyla güncellenen kayıt gerçekten Attributes içinde geliyor.

Üçüncüsü bir tasarım tuzağı. Üç kez okuyup sonra yazmak teklik kontrolü değildir; okuma ile yazma arasında başka bir invocation aynı e-postayı sahiplenebilir. AWS tek item durumunda okumayı değil koşulu öneriyor: yeni bir item’ın mevcut olanın yerine geçmesini engellemek için, tablonun partition key’i olarak kullanılan attribute adıyla attribute_not_exists fonksiyonunu içeren bir conditional expression kullanın. Birden fazla item’a yayılan bir sahiplenme içinse TransactWriteItems en fazla 100 aksiyon isteğini gruplar, toplamı 4 MB ile sınırlar, iki aksiyonun aynı item’ı hedeflemesini yasaklar ve ya hepsi başarılı olacak ya da hiçbiri olmayacak biçimde atomik tamamlanır. Reddedilme, konumsal bir CancellationReasons dizisi taşıyan TransactionCanceledException olarak geliyor; böylece handler mükerrer e-postayı (ConditionalCheckFailed) throttle’dan (ProvisionedThroughputExceeded) ayırabiliyor. Tekrar deneyecekseniz ClientRequestToken’ı yeniden kullanın: token, kendisini kullanan ilk istek tamamlandıktan sonra 10 dakika geçerli kalıyor ve en fazla 36 karakter tutuyor; bir UUID tam sığıyor. Token isteğin kendisiyle gelmeli. Handler içinde üretilen bir token her denemede yenilenir; yanıtı kaybolan bir istek tekrar denendiğinde ikinci bir transaction açılır ve ilkinin yazdığı teklik kayıtlarına çarpar. Token’ı yeniden kullanmak da tek başına yetmiyor. Kullanılmış bir token’ı değişmiş bir payload ile taşıyan istek, ilkine çözülmek yerine IdempotentParameterMismatch ile reddediliyor. Timestamps açık olan bir entity ise created ve modified değerlerini her build’de yeniden üretiyor; yani iki deneme aynı payload’ı hiç göndermiyor. Yukarıdaki entity’ler timestamps’i tam bu yüzden kapalı tutuyor. Bu iki alana ihtiyaç duyan bir kayıt, değerleri isteğin kendisiyle almalı; token için geçerli olan gerekçenin aynısı. Commit’ten sonra bir ayrıntı kalıyor. Ardından yapılan okumanın güçlü tutarlı olması gerekiyor; varsayılan nihai tutarlı okuma, transaction’ın az önce yazdığı bir item için boş dönebiliyor. Sahiplenilen bir değerin sonradan değişmesi de aynı biçimi kullanıyor. Bir teklik kaydını taşımak tek transaction içinde üç aksiyon demek: eski nöbetçiyi silmek, yenisini attribute_not_exists koşuluyla yazmak ve kullanıcıyı, değeri yansıtan index attribute’uyla birlikte güncellemek. E-posta ile username yalnızca nöbetçinin scope’unda ve o index attribute’unda ayrışıyor; bu yüzden tek bir metot ikisini de karşılıyor ve genel bir update hiçbirine dokunmuyor. Schema’nın put validator’ı bu yolda söz sahibi değil ve yeni değer tek commit’te hem kayda hem nöbetçiye hem de index attribute’una gidiyor; bu yüzden metot, transaction’ı kurmadan önce aynı kuralı kendisi uyguluyor. Aksi halde kayıt ile değişiklik farklı değerleri reddeder ve kazanan ikincisi olur. Üç aksiyonun ikisi claim item’ı yazıyor; bu yüzden kayıtlı değerin aynısına çözülen bir değişiklik bu üç aksiyondan geçemez. Yoksa delete ile put tek bir item’a düşer ve aynı-item kuralı çağrıyı reddeder. O yol yine de kayıtlı değeri doğrulamalı; value ile eşitlenen bayat bir previous, aksi halde kaydın hiç yapmadığı bir değişikliği başarılı gösterir. Token’ı harcaması da gerekiyor. Yalnızca okuyup dönen bir dal token’ı kaydettirmez; yanıtı kaybolan bir istek tekrar denendiğinde kaydı yeniden okur ve araya giren ne varsa ona takılır. Aynı token’la gönderilen tek bir condition check ikisini birden yapıyor: hiçbir şey yazmıyor ve birebir aynı tekrar, çoktan başarılı olmuş çağrıya çözülüyor. Değiştirilen değer de isteğin kendisiyle geliyor. Onu her denemede yeniden okuyan bir metot, araya giren değişikliğin etrafında transaction’ı baştan kurar; yeniden kullanılan token da değişmiş bir payload taşır.

// services/user-service.ts - entity'lerin üzerindeki iş kuralları
import { TransactionCanceledException } from '@aws-sdk/client-dynamodb';
import { GetItemCommand } from 'dynamodb-toolbox/entity/actions/get';
import { UpdateItemCommand, $add } from 'dynamodb-toolbox/entity/actions/update';
import { PutTransaction } from 'dynamodb-toolbox/entity/actions/transactPut';
import { UpdateTransaction } from 'dynamodb-toolbox/entity/actions/transactUpdate';
import { DeleteTransaction } from 'dynamodb-toolbox/entity/actions/transactDelete';
import { ConditionCheck } from 'dynamodb-toolbox/entity/actions/transactCheck';
import { execute } from 'dynamodb-toolbox/entity/actions/transactWrite';
import { QueryCommand } from 'dynamodb-toolbox/table/actions/query';
import {
  MainTable,
  UserEntity,
  UniqueValueEntity,
  claimRules,
  type User,
  type UserProfileUpdate,
} from '../database/entities';

export class UserService {
  // Üç item, tek commit. Mükerrer kayıt, birkaç milisaniye önce yapılmış bir
  // okumada değil, doğrudan veritabanında eleniyor.
  async createUser(input: {
    userId: string;
    email: string;
    username: string;
    firstName?: string;
    lastName?: string;
    idempotencyKey: string;
  }): Promise<User> {
    const { idempotencyKey, ...attributes } = input;
    const email = attributes.email.toLowerCase();

    try {
      await execute(
        // Anahtar çağırandan geliyor, burada üretilmiyor. Yanıtı kaybolan bir
        // istek tekrar denendiğinde kendi kayıtlarına çarpmak yerine ilk
        // transaction'a düşüyor. Bu üç item'da denemeden denemeye değişen alan
        // yok; her tekrar, token'ın verildiği payload'ı gönderiyor.
        { clientRequestToken: idempotencyKey },
        UserEntity.build(PutTransaction)
          .item({ ...attributes, email })
          .options({ condition: { attr: 'userId', exists: false } }),
        UniqueValueEntity.build(PutTransaction)
          .item({ scope: 'EMAIL', claim: email, userId: input.userId })
          .options({ condition: { attr: 'claim', exists: false } }),
        UniqueValueEntity.build(PutTransaction)
          .item({ scope: 'USERNAME', claim: input.username, userId: input.userId })
          .options({ condition: { attr: 'claim', exists: false } }),
      );
    } catch (error) {
      throw explainCancellation(error, ['user id', 'email', 'username']);
    }

    // TransactWriteItems item attribute'u döndürmüyor; kaydedilen satır bir
    // okumadan geliyor ve bu okuma güçlü tutarlı. Varsayılan okumada az önce
    // commit edilen kullanıcı yokmuş gibi dönebilir; metot da başarıyla biten
    // bir yazma için hata bildirir. Tek item yolunda PutItemCommand yazdığı
    // item'ı `ToolboxItem` içinde verdiği için bu çağrı gereksiz.
    const { Item } = await UserEntity.build(GetItemCommand)
      .key({ userId: input.userId })
      .options({ consistent: true })
      .send();

    if (!Item) {
      throw new Error('User was committed but could not be read back');
    }

    return Item;
  }

  // Partition DEĞERİ `partition` içine gider. Sort key koşulları `range` içine.
  // Index okuması her zaman nihai tutarlıdır, güçlü tutarlı istenemez; yazmanın
  // hemen ardından yapılan bir arama boş dönebilir.
  async getUserByEmail(email: string): Promise<User | null> {
    const { Items = [] } = await MainTable.build(QueryCommand)
      .query({ index: 'GSI1', partition: `EMAIL#${email.toLowerCase()}` })
      .entities(UserEntity)
      .options({ limit: 1 })
      .send();

    return Items[0] ?? null;
  }

  async getUserByUsername(username: string): Promise<User | null> {
    const { Items = [] } = await MainTable.build(QueryCommand)
      .query({ index: 'GSI2', partition: `USERNAME#${username}` })
      .entities(UserEntity)
      .options({ limit: 1 })
      .send();

    return Items[0] ?? null;
  }

  // UpdateItem, ALL_NEW kabul ediyor; yeni kayıt gerçekten Attributes'ta gelir.
  // UserProfileUpdate bir izin listesi; bu imza ne sahiplenilen değerleri ne de
  // onlara bağlı GSI attribute'larını kabul ediyor. Validator'lar yalnızca put
  // sırasında çalışıyor, link update sırasında yeniden hesaplanmıyor ve buradan
  // birini değiştirmek teklik nöbetçisini eski değerde bırakır. İkisi de
  // aşağıdaki `changeClaim`'den geçiyor; o da değeri denetliyor ve nöbetçiyi
  // değerle birlikte taşıyor.
  async updateUser(
    userId: string,
    updates: UserProfileUpdate,
    expectedVersion?: number,
  ): Promise<User> {
    const condition =
      expectedVersion === undefined
        ? { attr: 'userId', exists: true }
        : {
            and: [
              { attr: 'userId', exists: true },
              { attr: 'version', eq: expectedVersion },
            ],
          };

    const { Attributes } = await UserEntity.build(UpdateItemCommand)
      .item({ userId, ...updates, version: $add(1) })
      .options({ condition, returnValues: 'ALL_NEW' })
      .send();

    if (!Attributes) {
      throw new Error('Update returned no attributes');
    }

    return Attributes;
  }

  // Bir teklik kaydını taşımak tek transaction: eski nöbetçiyi sil, yenisini
  // koşulla al, kullanıcıyı ona bağla. E-posta ile username yalnızca nöbetçinin
  // scope'unda ve değeri yansıtan index attribute'unda ayrışıyor; bu yüzden tek
  // metot ikisini de karşılıyor. Put validator'ı update sırasında çalışmadığı
  // için schema'nın tanımladığı kural burada uygulanıyor. Her dal kendi
  // scope'unu, attribute'unu ve index'ini yazıyor; tipli aksiyon literal istiyor.
  async changeClaim(
    userId: string,
    change: { field: 'email' | 'username'; previous: string; value: string },
    idempotencyKey: string,
  ): Promise<void> {
    // İki değer de istekten geliyor; bu yüzden her deneme aynı üç aksiyonu
    // kuruyor. Kayıtlı değeri burada okumak, araya giren bir değişikliğin
    // etrafında transaction'ı baştan kurar; yeniden kullanılan token da
    // değişmiş bir payload taşır.
    const normalize = (input: string) =>
      change.field === 'email' ? input.toLowerCase() : input;
    const previous = normalize(change.previous);
    const value = normalize(change.value);

    // Kayda, nöbetçiye ve index attribute'una düşen şey normalize edilmiş
    // değer; bu yüzden ona güvenilmiyor, burada kontrol ediliyor. Put
    // validator'ı bunu kayıtta yakalardı, bu yazmayı ise hiç görmüyor.
    const verdict = claimRules[change.field](value);
    if (verdict !== true) {
      throw new Error(`Invalid ${change.field}: ${verdict}`);
    }

    // Değişmeyen bir değer, delete ile put'u aynı claim item'ına gönderir; bir
    // transaction aynı item'a iki kez dokunamaz. Bu yol yine de bir
    // transaction'dan geçiyor: yalnızca okuyup dönen bir dal token'ı harcamaz ve
    // yanıtı kaybolan bir istek tekrar denendiğinde kaydı yeniden okuyup araya
    // gireni görür. Tek bir condition check hiçbir şey yazmadan kayıtlı değeri
    // doğruluyor, token'ı kaydediyor ve her denemede aynı payload'ı gönderiyor.
    if (previous === value) {
      const check =
        change.field === 'email'
          ? UserEntity.build(ConditionCheck)
              .key({ userId })
              .condition({ attr: 'email', eq: value })
          : UserEntity.build(ConditionCheck)
              .key({ userId })
              .condition({ attr: 'username', eq: value });

      try {
        await execute({ clientRequestToken: idempotencyKey }, check);
      } catch (error) {
        if (!(error instanceof TransactionCanceledException)) {
          throw error;
        }

        // Tek aksiyon var; ilk sebep bu kontrole ait. Throttle da buraya
        // düşüyor ve kendi hatasını koruyor.
        const [stored] = error.CancellationReasons ?? [];
        throw stored?.Code === 'ConditionalCheckFailed'
          ? new Error(`Stale ${change.field}: the stored value is different`)
          : error;
      }

      return;
    }

    try {
      if (change.field === 'email') {
        await execute(
          { clientRequestToken: idempotencyKey },
          UniqueValueEntity.build(DeleteTransaction)
            .key({ scope: 'EMAIL', claim: previous })
            .options({ condition: { attr: 'userId', eq: userId } }),
          UniqueValueEntity.build(PutTransaction)
            .item({ scope: 'EMAIL', claim: value, userId })
            .options({ condition: { attr: 'claim', exists: false } }),
          UserEntity.build(UpdateTransaction)
            .item({
              userId,
              email: value,
              // Link put sırasında hesaplanıyor, update sırasında değil; bu
              // yüzden index attribute'u yansıttığı değerin yanında yazılıyor.
              GSI1PK: `EMAIL#${value}`,
              version: $add(1),
            })
            .options({ condition: { attr: 'email', eq: previous } }),
        );
      } else {
        await execute(
          { clientRequestToken: idempotencyKey },
          UniqueValueEntity.build(DeleteTransaction)
            .key({ scope: 'USERNAME', claim: previous })
            .options({ condition: { attr: 'userId', eq: userId } }),
          UniqueValueEntity.build(PutTransaction)
            .item({ scope: 'USERNAME', claim: value, userId })
            .options({ condition: { attr: 'claim', exists: false } }),
          UserEntity.build(UpdateTransaction)
            .item({
              userId,
              username: value,
              GSI2PK: `USERNAME#${value}`,
              version: $add(1),
            })
            .options({ condition: { attr: 'username', eq: previous } }),
        );
      }
    } catch (error) {
      if (!(error instanceof TransactionCanceledException)) {
        throw error;
      }

      // Yine konumsal: ikinci aksiyon yeni kayıt. Diğer ikisi, kayıtlı değer
      // çağıranın gönderdiği `previous` değilse başarısız oluyor.
      const [, claimed] = error.CancellationReasons ?? [];
      throw claimed?.Code === 'ConditionalCheckFailed'
        ? new Error(`Already taken: ${change.field}`)
        : error;
    }
  }
}

// CancellationReasons konumsaldır: i'nci kayıt i'nci transaction item'ını anlatır.
function explainCancellation(error: unknown, labels: string[]): Error {
  if (!(error instanceof TransactionCanceledException)) {
    return error instanceof Error ? error : new Error(String(error));
  }

  const taken = (error.CancellationReasons ?? [])
    .map((reason, index) =>
      reason.Code === 'ConditionalCheckFailed' ? labels[index] : null,
    )
    .filter((label): label is string => label !== null);

  return taken.length > 0
    ? new Error(`Already taken: ${taken.join(', ')}`)
    : error;
}

export const userService = new UserService();

Kopyalamadan önce iki sınırı adıyla koymak gerekiyor. Update tipi derleme zamanı koruması ve TypeScript çalışma anında ortada yok: bir update endpoint’i, aşağıdaki handler’ın create gövdesini Zod ile ayrıştırdığı gibi kendi gövdesini de bu tipe ayrıştırmalı; yoksa JSON payload GSI1PK’yı yine spread’in içine sokabiliyor. İkincisi, kullanıcı silmek teklik hikâyesinin diğer yarısı ve uzunluk nedeniyle burada yok. Biçimi değişiklikle aynı üç aksiyon: iki nöbetçiyi bırakmak ve kullanıcı kaydını tek transaction’da kaldırmak; aksi halde bir teklik kaydı gösterdiği satırdan uzun yaşıyor.

Lambda Handler: Production-Ready API Endpoint’leri#

// handlers/users/create.ts - Kullanıcı oluşturma endpoint'i
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { userService } from '../../services/user-service';
import { claimRules } from '../../database/entities';
import { z } from 'zod';

// Input validation schema. Sahiplenilen değerler kuralı burada yeniden yazmak
// yerine dışa aktarılan kurallara devrediyor; böylece isteği ayrıştıran katman,
// put validator'ı ve `changeClaim` bir değerin ne içerebileceği konusunda
// ayrışamıyor.
const CreateUserSchema = z.object({
  userId: z.string().min(1).max(50),
  email: z.string().refine(value => claimRules.email(value) === true, {
    message: 'Provide a valid email address',
  }),
  username: z.string().refine(value => claimRules.username(value) === true, {
    message: 'Username must be 3 to 30 letters, digits, hyphens or underscores',
  }),
  firstName: z.string().optional(),
  lastName: z.string().optional(),
});

export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  console.log('Create user request:', {
    requestId: event.requestContext.requestId,
    sourceIp: event.requestContext.identity.sourceIp,
  });

  try {
    // Input'u parse et ve validate et
    if (!event.body) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: 'Request body is required',
          code: 'MISSING_BODY',
        }),
      };
    }

    let requestData;
    try {
      requestData = JSON.parse(event.body);
    } catch (error) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: 'Invalid JSON in request body',
          code: 'INVALID_JSON',
        }),
      };
    }

    // Zod ile validate et
    const validationResult = CreateUserSchema.safeParse(requestData);
    if (!validationResult.success) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: 'Validation failed',
          code: 'VALIDATION_ERROR',
          details: validationResult.error.errors,
        }),
      };
    }

    // API Gateway her denemede yeni bir requestId üretir; bu yüzden idempotency
    // anahtarı istemciden gelir. Gelmezse tekrar deneme ikinci bir transaction olur.
    const idempotencyKey = Object.entries(event.headers ?? {}).find(
      ([name]) => name.toLowerCase() === 'idempotency-key',
    )?.[1];

    if (!idempotencyKey || idempotencyKey.length > 36) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: 'Idempotency-Key header is required, 36 characters at most',
          code: 'MISSING_IDEMPOTENCY_KEY',
        }),
      };
    }

    // User oluştur
    const user = await userService.createUser({
      ...validationResult.data,
      idempotencyKey,
    });

    // Response'u sade tut: preferences kendi endpoint'inden alınıyor
    const { preferences, ...safeUser } = user;

    return {
      statusCode: 201,
      headers: {
        'Content-Type': 'application/json',
        'X-Request-ID': event.requestContext.requestId,
      },
      body: JSON.stringify({
        message: 'User created successfully',
        user: safeUser,
      }),
    };

  } catch (error) {
    console.error('Error creating user:', {
      error: error.message,
      stack: error.stack,
      requestId: event.requestContext.requestId,
    });

    // Servis, iptal edilen transaction'ı bu mesaja çeviriyor
    if (error.message.startsWith('Already taken:')) {
      return {
        statusCode: 409,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: error.message,
          code: 'CONFLICT',
        }),
      };
    }

    // Generic error response
    return {
      statusCode: 500,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        error: 'Internal server error',
        code: 'INTERNAL_ERROR',
        requestId: event.requestContext.requestId,
      }),
    };
  }
};

Gelişmiş Pattern’ler#

Optimistic Locking Pattern#

// patterns/optimistic-locking.ts - version çakışmasında yeniden oku ve dene
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
import { GetItemCommand } from 'dynamodb-toolbox/entity/actions/get';
import { UpdateItemCommand, $add } from 'dynamodb-toolbox/entity/actions/update';
import {
  UserEntity,
  type User,
  type UserProfileUpdate,
} from '../database/entities';

// Servis katmanındaki izin listesinin aynısı: sahiplenilen bir değer ve ona
// bağlı index attribute'u, genel bir kısmi update'e binmeden
// UserService.changeClaim üzerinden birlikte taşınır.
export async function updateUserWithRetry(
  userId: string,
  updates: UserProfileUpdate,
  maxAttempts = 3,
): Promise<User> {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    // Bilerek güçlü tutarlı. Varsayılan okuma az önce kaybeden version'ı geri
    // verebilir; o zaman her deneme aynı koşula takılır ve döngü boşa biter.
    const { Item } = await UserEntity.build(GetItemCommand)
      .key({ userId })
      .options({ consistent: true })
      .send();

    if (!Item) {
      throw new Error('User not found');
    }

    try {
      const { Attributes } = await UserEntity.build(UpdateItemCommand)
        .item({ userId, ...updates, version: $add(1) })
        .options({
          condition: { attr: 'version', eq: Item.version },
          returnValues: 'ALL_NEW',
        })
        .send();

      if (!Attributes) {
        throw new Error('Update returned no attributes');
      }

      return Attributes;
    } catch (error) {
      if (!(error instanceof ConditionalCheckFailedException)) {
        throw error;
      }

      // Bu turu başkası kazandı. Geri çekil, yeniden oku, tekrar dene.
      await new Promise(resolve =>
        setTimeout(resolve, 2 ** attempt * 50 + Math.floor(Math.random() * 50)),
      );
    }
  }

  throw new Error(`Version conflict persisted after ${maxAttempts} attempts`);
}

Batch Operations Pattern#

Bir batch yardımcısındaki sabitler AWS’ye ait ve v2’de hâlâ çağıranın sorumluluğunda. BatchWriteItem referansına göre tek bir BatchWriteItem çağrısı ağ üzerinden en fazla 16MB veri taşıyabiliyor ve bu veri en fazla 25 put ya da delete operasyonundan oluşuyor. Çağrı; 25’ten fazla istek varsa, iki istek aynı partition ve sort key’i taşıyorsa, herhangi bir item 400 KB’ı aşıyorsa, toplam 16 MB’ı geçiyorsa ya da partition key 2048 bayttan veya sort key 1024 bayttan uzunsa tamamen reddediliyor. Aynı sayfa BatchWriteItem’ın item güncelleyemediğini de söylüyor; bir batch yardımcısının yalnızca put ve delete alması bundan. Aynı referans batch’i teklik hikâyesinin de dışında tutuyor: tek tek put ve delete isteklerine koşul yazılamıyor ve mevcut bir item’a yapılan put onun üzerine yazıyor. Bu son çift, kullanıcı kaydını batch write yolundan tamamen çıkarıyor. Put bütün item’ı geri göndermek zorunda; çağıranın hesaplayamadığı alanlar da az önce yapılmış bir okumadan geliyor: e-posta, username, onlara bağlı index attribute’ları ve version sayacı. Arada commit eden ne varsa bu bayat kopyanın altında kalıyor ve version sayacı geri gidiyor. Bunu yakalayan bir şey de yok, çünkü batch put’un takılacağı bir koşul yok. Bir uyarı notu da bunu onarmıyor; kaydın ihtiyaç duyduğu koruma, API’nin kabul etmediği tek şey. Bu yüzden kullanıcı kaydı koşullu yollarda kalıyor: kayıt oluşturma createUser, e-posta ya da username değişikliği changeClaim, geri kalan her düzenleme updateUserWithRetry. Batch write ise ne teklik kaydı ne de version taşıyan, çağıranın tamamını zaten elinde tuttuğu satırlara uyuyor. Bu tabloda uyan batch aksiyonu okuma. BatchGetItem’ın kendi çifti var: tek bir operasyon en fazla 16 MB veri ve en fazla 100 item döndürüyor, daha fazlası istendiğinde “Too many items requested for the BatchGetItem call” mesajlı bir ValidationException dönüyor. Tekrarlanan key okuma tarafında da ölümcül: aynı key birden fazla kez belirtilirse BatchGetItem yine ValidationException ile sonuçlanıyor. Parçalara bölmek tekrarı ortadan kaldırmadığı için, girdisi düz bir id listesi olan bir yardımcı bölmeden önce tekrarları ayıklar.

Bu sınırlara bölmek gerekli ama yeterli değil ve AWS’nin kendi örneği nedenini gösteriyor: 100 item istenirse ve her item 300 KB ise sistem 16 MB sınırını aşmamak için 52 item döndürüyor. Kalanı UnprocessedKeys içinde geliyor; yazma tarafı da UnprocessedItems üzerinden aynı şekilde davranıyor. Bu iki alanı yok sayan bir yardımcı veriyi sessizce kaybeder.

v2 sınırları olduğu yerde bırakıyor. BatchWriteCommand, BatchPutRequest ve BatchDeleteRequest nesnelerini topluyor ve bağımsız bir execute() ile çalışıyor; dokümantasyon table başına yalnızca bir BatchWriteCommand desteklendiğini belirtiyor ve iki execute yardımcısı da hiçbir şeyi parçalara bölmüyor. maxAttempts seçeneği, başarısız istekleri tek bir promise içinde yeniden denemek için sunulan bir meta seçenek olarak tanımlanmış ve varsayılanı 1; yani varsayılan davranışta işlenmemiş item’lar yeniden denenmez, geri döndürülür. Değeri yükseltmek döngüyü hiç beklemeden yeniden denetiyor ki AWS sayfasının uyardığı tek şey de bu: üstel geri çekilme kullanmanız şiddetle öneriliyor, çünkü batch işlemini hemen tekrarlarsanız alttaki okuma ve yazma istekleri tablo düzeyindeki throttling yüzünden yine başarısız olabilir. Gecikmeyi siz ekleyeceksiniz.

// patterns/batch-operations.ts - v2 batch okuması etrafında bölme ve backoff
import {
  BatchGetCommand,
  execute as executeGet,
} from 'dynamodb-toolbox/table/actions/batchGet';
import { BatchGetRequest } from 'dynamodb-toolbox/entity/actions/batchGet';
import { MainTable, UserEntity, type User } from '../database/entities';

// Bu bir AWS sınırı, kütüphane sınırı değil. execute() hiçbir şey bölmüyor.
const READ_CHUNK = 100; // BatchGetItem: en fazla 100 key
const MAX_ROUNDS = 6;

function chunk<T>(values: T[], size: number): T[][] {
  const out: T[][] = [];
  for (let index = 0; index < values.length; index += size) {
    out.push(values.slice(index, index + size));
  }
  return out;
}

const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

export async function getUsers(userIds: string[]): Promise<User[]> {
  const found: User[] = [];

  // Aynı key iki kez gelirse istek ValidationException ile düşer; birleştirilen
  // kayıtlardan toplanan id'ler sık tekrar eder, o yüzden girdi ayıklanıyor.
  const uniqueIds = [...new Set(userIds)];

  for (const batch of chunk(uniqueIds, READ_CHUNK)) {
    let outstanding = batch;
    let settled = false;

    for (let round = 0; round < MAX_ROUNDS && !settled; round++) {
      const command = MainTable.build(BatchGetCommand).requests(
        ...outstanding.map(userId =>
          UserEntity.build(BatchGetRequest).key({ userId }),
        ),
      );

      const { Responses = [], UnprocessedKeys } = await executeGet(command);
      const page = (Responses[0] ?? []) as User[];
      found.push(...page);

      // 300 KB'lık 100 key, 52 item döndürür. Kalanı hata olarak değil,
      // işlenmemiş key olarak burada görünür. Tek komut tek table'ı kapsar;
      // yeniden deneme listesi de bu map'in tek girdisidir.
      const pendingKeys = Object.values(UnprocessedKeys ?? {})[0]?.Keys ?? [];
      settled = pendingKeys.length === 0;
      if (settled) {
        break;
      }

      // Sonraki tur, dönen id'lerden değil bu key'lerden kuruluyor. Karşılığı
      // olmayan bir id, key'i okunmuş olsa bile Responses'ta yok; dönenleri
      // çıkarmak onu her turda yeniden sorar.
      const retryable = new Set(pendingKeys.map(key => String(key.PK)));
      outstanding = outstanding.filter(userId =>
        retryable.has(`USER#${userId}`),
      );
      await wait(2 ** round * 50 + Math.floor(Math.random() * 50));
    }

    if (!settled) {
      throw new Error('Batch get gave up with unprocessed keys');
    }
  }

  return found;
}

ACID İşlemler için Transaction Pattern#

100 aksiyon ve 4 MB sınırları burada da geçerli; iki aksiyonun aynı item’ı hedefleyememesi kuralı da öyle. Aşağıdaki iki operasyon ilk iki sınırın epeyce içinde kalıyor; devir işlemi ise üçüncüsünü varsaymak yerine kontrol ediyor: eşit gelen iki kullanıcı id’si tek bir membership item’ını transaction’a iki kez sokar. Devri alan tarafta yalnızca role koşulu yetmiyor. role ile status ayrı attribute’lar; davet edilmiş ya da çıkarılmış bir üyelik de member rolünü taşıyabiliyor. Yalnızca role’e bakan bir koşul sahipliği o üyeliğe verir ve organizasyonu aktif bir sahip olmadan bırakır. Burada bir kullanıcı oluşturmak, servis katmanının yazdığı teklik kayıtlarının aynısını yazıyor. Bunları atlayan ikinci bir oluşturma yolu, ilkinin engellediği mükerrer kaydı geri açar. İkisi de çağıranın idempotency anahtarını alıyor; yeni bir token üreten bir tekrar deneme yeni bir transaction demek ve ilkinin yazdıklarına çarpar. İkisi de denemeden denemeye değişen bir attribute üretmiyor; yeniden kullanılan bir token’ı göndermeye değer kılan da bu.

// patterns/transactions.ts - entity düzeyinde write transaction'ları
import { execute } from 'dynamodb-toolbox/entity/actions/transactWrite';
import { PutTransaction } from 'dynamodb-toolbox/entity/actions/transactPut';
import { UpdateTransaction } from 'dynamodb-toolbox/entity/actions/transactUpdate';
import {
  UserEntity,
  UniqueValueEntity,
  OrganizationEntity,
  MembershipEntity,
} from '../database/entities';

export class TransactionService {
  // Ya organization sahibiyle birlikte var olur, ya da hiçbir şey yazılmaz.
  // Teklik kayıtları da aynı transaction'da; bu yol, servis katmanının
  // reddedeceği bir e-postayı ya da username'i dağıtamıyor.
  async createUserWithOrganization(
    user: { userId: string; email: string; username: string },
    org: { orgId: string; name: string },
    idempotencyKey: string,
  ): Promise<void> {
    const email = user.email.toLowerCase();

    await execute(
      { clientRequestToken: idempotencyKey },
      UserEntity.build(PutTransaction)
        .item({ ...user, email })
        .options({ condition: { attr: 'userId', exists: false } }),
      UniqueValueEntity.build(PutTransaction)
        .item({ scope: 'EMAIL', claim: email, userId: user.userId })
        .options({ condition: { attr: 'claim', exists: false } }),
      UniqueValueEntity.build(PutTransaction)
        .item({ scope: 'USERNAME', claim: user.username, userId: user.userId })
        .options({ condition: { attr: 'claim', exists: false } }),
      OrganizationEntity.build(PutTransaction)
        .item(org)
        .options({ condition: { attr: 'orgId', exists: false } }),
      MembershipEntity.build(PutTransaction).item({
        orgId: org.orgId,
        userId: user.userId,
        role: 'owner',
      }),
    );
  }

  // İki update de ORG#orgId üzerine düşüyor; biri USER#fromUserId, diğeri
  // USER#toUserId. Eşit id'ler bunu tek item'a çevirir ve transaction buna
  // izin vermez; bu yüzden çift kurulmadan önce kontrol ediliyor. Her iki
  // taraf da ne bulmayı beklediğini yazıyor; bayat bir çağrı iki sahip
  // yaratamıyor. Devri alan taraf status'ü de soruyor: `role` ile `status`
  // bağımsız olduğu için yalnızca role'e bakan bir koşul, davet edilmiş ya da
  // çıkarılmış bir üyeliği sahip yapar ve organizasyonu aktif sahipsiz bırakır.
  async transferOwnership(
    orgId: string,
    fromUserId: string,
    toUserId: string,
    idempotencyKey: string,
  ): Promise<void> {
    if (fromUserId === toUserId) {
      throw new Error('Ownership transfer needs two different users');
    }

    await execute(
      { clientRequestToken: idempotencyKey },
      MembershipEntity.build(UpdateTransaction)
        .item({ orgId, userId: fromUserId, role: 'member' })
        .options({ condition: { attr: 'role', eq: 'owner' } }),
      MembershipEntity.build(UpdateTransaction)
        .item({ orgId, userId: toUserId, role: 'owner' })
        .options({
          condition: {
            and: [
              { attr: 'role', eq: 'member' },
              { attr: 'status', eq: 'active' },
            ],
          },
        }),
    );
  }
}

Performans Optimizasyon Pattern’leri#

Connection Reuse ve Warm Start’lar#

Yukarıdaki entity dosyası, örnek kısa kalsın diye client’ını satır içinde kuruyor. Gerçek bir serviste client bir kez burada kurulur ve entities.ts tarafından import edilir; böylece sıcak bir Lambda her invocation’da yeni bağlantı açmak yerine aynı soketleri kullanır.

// config/dynamodb-config.ts - Paylaşılan client konfigürasyonu
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Connection reuse için singleton pattern
class DynamoDBManager {
  private static instance: DynamoDBManager;
  private client: DynamoDBClient;
  private docClient: DynamoDBDocumentClient;

  private constructor() {
    this.client = new DynamoDBClient({
      region: process.env.AWS_REGION,
      // Lambda için connection ayarları. requestHandler tek bir nesne olmalı;
      // ikinci kez yazarsanız ilk ayarlar sessizce kaybolur.
      maxAttempts: 3,
      requestHandler: {
        connectionTimeout: 1000,
        socketTimeout: 1000,
        keepAlive: true,
        keepAliveMsecs: 1000,
        maxSockets: 50,
      },
    });

    this.docClient = DynamoDBDocumentClient.from(this.client, {
      marshallOptions: {
        removeUndefinedValues: true,
        convertEmptyValues: false,
        convertClassInstanceToMap: true,
      },
      unmarshallOptions: {
        wrapNumbers: false,
      },
    });
  }

  static getInstance(): DynamoDBManager {
    if (!DynamoDBManager.instance) {
      DynamoDBManager.instance = new DynamoDBManager();
    }
    return DynamoDBManager.instance;
  }

  getClient(): DynamoDBClient {
    return this.client;
  }

  getDocClient(): DynamoDBDocumentClient {
    return this.docClient;
  }
}

export const dynamoManager = DynamoDBManager.getInstance();
export const docClient = dynamoManager.getDocClient();

Query Optimization Pattern’leri#

Önce pagination. AWS’nin pagination sayfası Query sonuçlarının 1 MB veya daha küçük sayfalara bölündüğünü ve tek bir Query’nin yalnızca 1 MB sınırına sığan bir sonuç kümesi döndürdüğünü söylüyor. Ardından çoğu cursor yardımcısının dışarı verdiği bayrağı doğrudan uyarıyor: LastEvaluatedKey boş değilse bu, sonuç kümesinde daha fazla veri olduğu anlamına gelmez; sonun geldiğini anlamanın tek yolu LastEvaluatedKey’in boş gelmesidir. Bir sayfa sıfır eşleşen item döndürüp yine de LastEvaluatedKey taşıyabiliyor, çünkü API referansı Limit’i eşleşen değil değerlendirilen item sayısının üst sınırı olarak tanımlıyor ve FilterExpression sorgu bittikten sonra, sonuçlar döndürülmeden önce uygulanıyor. Yani hasMore = !!LastEvaluatedKey bir hasMore değil; okumanın bir sayfa sınırında durduğunu bildiriyor. Key’i istemciye opak bir cursor olarak verin ve cursor gelmeyi bırakınca istemci dursun.

İkinci düzeltme sayma. Tek bir Select: COUNT sorgusu aynı 1 MB sayfasıyla sınırlı; o sınırın üstünde partition’ı değil bir sayfayı yanıtlıyor. Ucuz bir okuma da değil: Query API referansına göre COUNT, item’ları getirmekle aynı miktarda read capacity unit tüketiyor ve aynı item boyutu hesabına tabi. Kazandırdığı şey telde taşınan bayt. Doğru bir sayım sayfaları dolaşıp toplamı tutar; v2’nin .paginate() metodu tam bunun için var. Aynı doküman sayfası, dolaşmanın tek çağrının içinde kalması gerektiğinde maxPages’i tek promise içinde birden fazla istek göndermeye yarayan bir meta seçenek olarak sunuyor.

Fan-out yardımcıları kendi uyarısını hak ediyor. DynamoDB dokümantasyonunda N paralel sorguyu bir optimizasyon olarak sunan hiçbir şey yok ve her sorgu yine kendi 1 MB sayfasında duruyor; desene iliştirilecek bir sayı da bu yüzden yok. Bir istek gerçekten birden fazla partition’a ihtiyaç duyduğunda Promise.all makul bir biçim. Bu çağrıyı saran ve kendini optimizasyon diye sunan bir yardımcı ise dokümantasyonun hiçbir sayfasının desteklemediği bir hızlanma vaat ediyor.

// patterns/query-optimization.ts - v2 Query aksiyonuyla sayfalama ve sayma
import { QueryCommand } from 'dynamodb-toolbox/table/actions/query';
import {
  MainTable,
  MembershipEntity,
  type Membership,
} from '../database/entities';

const encodeCursor = (key: Record<string, unknown>) =>
  Buffer.from(JSON.stringify(key)).toString('base64url');

const decodeCursor = (cursor: string) =>
  JSON.parse(Buffer.from(cursor, 'base64url').toString());

export async function listMemberships(
  userId: string,
  options: { pageSize?: number; cursor?: string } = {},
): Promise<{ items: Membership[]; nextCursor?: string }> {
  const { Items = [], LastEvaluatedKey } = await MainTable.build(QueryCommand)
    .query({ index: 'GSI1', partition: `USER#${userId}` })
    .entities(MembershipEntity)
    .options({
      limit: options.pageSize ?? 20,
      exclusiveStartKey: options.cursor
        ? decodeCursor(options.cursor)
        : undefined,
    })
    .send();

  // Bilerek `hasMore` değil. Buradaki key okumanın bir sayfa sınırında
  // durduğunu söyler; sonucun bittiğini yalnızca key'in hiç gelmemesi kanıtlar.
  return {
    items: Items,
    nextCursor: LastEvaluatedKey ? encodeCursor(LastEvaluatedKey) : undefined,
  };
}

// COUNT, item'ları getirmekle aynı read capacity'yi harcar ve tek çağrı aynı
// 1 MB sayfasıyla sınırlıdır; bu yüzden toplam dolaşarak çıkarılır.
export async function countMemberships(userId: string): Promise<number> {
  const command = MainTable.build(QueryCommand)
    .query({ index: 'GSI1', partition: `USER#${userId}` })
    .options({ select: 'COUNT' });

  let total = 0;
  for await (const page of command.paginate()) {
    total += page.Count ?? 0;
  }

  return total;
}

Test Stratejileri#

DynamoDB Local ile Local Testing#

// tests/setup/dynamodb-local.ts - Integration testleri için DynamoDB Local ortamı
import { spawn, ChildProcess } from 'child_process';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';

// Entity dosyası import edildiği anda ortamı okuyor; bu yüzden AWS_REGION,
// sahte kimlik bilgileri, MAIN_TABLE_NAME ve DYNAMODB_ENDPOINT, testler
// entity'leri çekmeden önce Jest setup dosyasında ayarlanıyor. Harness de aynı
// iki değişkeni okuyor; entity'lerin kullanmadığı bir table yaratamıyor.
const TABLE_NAME = process.env.MAIN_TABLE_NAME!;
const ENDPOINT = process.env.DYNAMODB_ENDPOINT!;

export class DynamoDBLocalTestEnvironment {
  private dynamoProcess: ChildProcess | null = null;
  private client: DynamoDBClient;

  constructor() {
    this.client = new DynamoDBClient({
      region: process.env.AWS_REGION,
      endpoint: ENDPOINT,
      credentials: {
        accessKeyId: 'fake',
        secretAccessKey: 'fake',
      },
    });
  }

  async start(): Promise<void> {
    return new Promise((resolve, reject) => {
      // DynamoDB Local'i başlat
      this.dynamoProcess = spawn('java', [
        '-Djava.library.path=./DynamoDBLocal_lib',
        '-jar', 'DynamoDBLocal.jar',
        '-sharedDb',
        '-port', new URL(ENDPOINT).port,
      ], {
        cwd: './dynamodb-local',
        stdio: 'pipe',
      });

      this.dynamoProcess.stdout?.on('data', (data) => {
        if (data.toString().includes('Initializing DynamoDB Local')) {
          resolve();
        }
      });

      this.dynamoProcess.on('error', reject);

      // 10 saniye sonra timeout
      setTimeout(() => reject(new Error('DynamoDB Local startup timeout')), 10000);
    });
  }

  // İki index de yaratılıyor; user entity'si GSI2'yi de bağlıyor ve yalnızca
  // GSI1 taşıyan bir table'da username araması çalışmıyor.
  async createTable(): Promise<void> {
    const createTableCommand = new CreateTableCommand({
      TableName: TABLE_NAME,
      KeySchema: [
        { AttributeName: 'PK', KeyType: 'HASH' },
        { AttributeName: 'SK', KeyType: 'RANGE' },
      ],
      AttributeDefinitions: [
        { AttributeName: 'PK', AttributeType: 'S' },
        { AttributeName: 'SK', AttributeType: 'S' },
        { AttributeName: 'GSI1PK', AttributeType: 'S' },
        { AttributeName: 'GSI1SK', AttributeType: 'S' },
        { AttributeName: 'GSI2PK', AttributeType: 'S' },
        { AttributeName: 'GSI2SK', AttributeType: 'S' },
      ],
      GlobalSecondaryIndexes: [
        {
          IndexName: 'GSI1',
          KeySchema: [
            { AttributeName: 'GSI1PK', KeyType: 'HASH' },
            { AttributeName: 'GSI1SK', KeyType: 'RANGE' },
          ],
          Projection: { ProjectionType: 'ALL' },
        },
        {
          IndexName: 'GSI2',
          KeySchema: [
            { AttributeName: 'GSI2PK', KeyType: 'HASH' },
            { AttributeName: 'GSI2SK', KeyType: 'RANGE' },
          ],
          Projection: { ProjectionType: 'ALL' },
        },
      ],
      BillingMode: 'PAY_PER_REQUEST',
    });

    await this.client.send(createTableCommand);
  }

  async cleanup(): Promise<void> {
    try {
      await this.client.send(new DeleteTableCommand({
        TableName: TABLE_NAME,
      }));
    } catch (error) {
      // Table mevcut olmayabilir
    }

    if (this.dynamoProcess) {
      this.dynamoProcess.kill();
      this.dynamoProcess = null;
    }
  }
}

Integration Testleri#

// tests/integration/user-service.test.ts - UserService için integration testleri
import { describe, beforeAll, afterAll, beforeEach, test, expect } from '@jest/globals';
import { randomUUID } from 'node:crypto';
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
import { DynamoDBLocalTestEnvironment } from '../setup/dynamodb-local';
import { UserService } from '../../services/user-service';

describe('UserService Integration Tests', () => {
  let testEnv: DynamoDBLocalTestEnvironment;
  let userService: UserService;

  beforeAll(async () => {
    testEnv = new DynamoDBLocalTestEnvironment();
    await testEnv.start();
    await testEnv.createTable();
    userService = new UserService();
  });

  afterAll(async () => {
    await testEnv.cleanup();
  });

  beforeEach(async () => {
    // Testler arasında temizlik
    // Implementation temizlik stratejinize bağlı
  });

  test('should create user with validation', async () => {
    const userData = {
      userId: 'test-user-1',
      email: 'test@example.com',
      username: 'testuser',
      firstName: 'Test',
      lastName: 'User',
    };

    const user = await userService.createUser({
      ...userData,
      idempotencyKey: randomUUID(),
    });

    expect(user).toBeDefined();
    expect(user.userId).toBe(userData.userId);
    expect(user.email).toBe(userData.email);
    expect(user.version).toBe(1);
    // Bu entity'de timestamps kapalı; kayıt yalnızca schema'nın tanımladığını
    // taşıyor
    expect(user.preferences.theme).toBe('light');
  });

  test('should prevent duplicate email registration', async () => {
    const userData1 = {
      userId: 'user1',
      email: 'duplicate@example.com',
      username: 'user1',
    };

    const userData2 = {
      userId: 'user2',
      email: 'duplicate@example.com', // Aynı email
      username: 'user2',
    };

    await userService.createUser({ ...userData1, idempotencyKey: randomUUID() });

    // Ayrı bir anahtar; yani tekrar deneme değil, ikinci bir kayıt girişimi.
    // Transaction iptal olur ve CancellationReasons suçluyu adlandırır
    await expect(
      userService.createUser({ ...userData2, idempotencyKey: randomUUID() }),
    ).rejects.toThrow('Already taken: email');
  });

  test('should handle concurrent updates with optimistic locking', async () => {
    // User oluştur
    const user = await userService.createUser({
      userId: 'concurrent-test',
      email: 'concurrent@example.com',
      username: 'concurrent',
      idempotencyKey: randomUUID(),
    });

    // Concurrent update'leri simüle et
    const update1Promise = userService.updateUser(user.userId, {
      firstName: 'Update1',
    }, user.version);

    const update2Promise = userService.updateUser(user.userId, {
      firstName: 'Update2',
    }, user.version);

    // Biri başarılı, diğeri başarısız olmalı
    const results = await Promise.allSettled([update1Promise, update2Promise]);

    const successes = results.filter(r => r.status === 'fulfilled');
    const failures = results.filter(r => r.status === 'rejected');

    expect(successes).toHaveLength(1);
    expect(failures).toHaveLength(1);
    expect(failures[0].reason).toBeInstanceOf(ConditionalCheckFailedException);
  });

  // Email araması GSI1'den okuyor ve index okuması nihai tutarlı. Bu yüzden
  // test, commit'in hemen ardından tek denemeyle yetinmiyor; sınırlı bir
  // pencere boyunca yokluyor.
  async function findUserByEmail(email: string, timeoutMs = 5000) {
    const deadline = Date.now() + timeoutMs;

    while (Date.now() < deadline) {
      const user = await userService.getUserByEmail(email);
      if (user) {
        return user;
      }

      await new Promise(resolve => setTimeout(resolve, 100));
    }

    return null;
  }

  test('should query users by email efficiently', async () => {
    const userData = {
      userId: 'query-test',
      email: 'query@example.com',
      username: 'queryuser',
    };

    await userService.createUser({ ...userData, idempotencyKey: randomUUID() });

    const foundUser = await findUserByEmail('query@example.com');

    expect(foundUser).not.toBeNull();
    expect(foundUser!.userId).toBe(userData.userId);
  });
});

Type Safety Ne Kazandırır#

Yükün büyük kısmını tipli entity’ler ve schema validation taşır. İkisi birlikte, bir kayıt bozulmadan çok önce, kod hâlâ yazılırken bütün bir hata ailesini ortadan kaldırır:

  • Hatalı biçimli email’ler ve sınır dışı username’ler yazma isteği gitmeden reddedilir.
  • Entity’ye yeni bir field eklemek, update’in kaydı sessizce değiştirmesine yol açmaz.
  • Eşzamanlı yazıcılar birbirini ezmek yerine version attribute’u üzerinden sıraya girer.
  • Her access pattern tek bir dosyada adlandırıldığı için verimsiz sorgu code review’da görünür.

İkincil kazançlar daha küçük ama gerçek. Paylaşılan client bağlantıları invocation’lar arasında sıcak tutar; entity tanımı da ekibe yeni katılan için table’ın dokümantasyonu yerine geçer.

Pratik Dersler#

1. Table’larla Değil, Entity’lerle Başlayın#

DynamoDB table’ınızı önce tasarlamayın. Entity’lerinizi ve access pattern’lerinizi tasarlayın, sonra table yapınızı bunların etrafında kurun.

2. Validation En İyi Arkadaşınız#

Her entity kendi validator’larını taşımalı. v2’de bunlar schema üzerinde zincirlenebilir metotlar; kural, iki dosya öteki bir servis yerine koruduğu attribute’un hemen yanında duruyor. Key olmayan bir attribute’ta .validate() yalnızca put sırasında çalışıyor; bu yüzden kuralı dışa aktarın ve değeri yazan diğer her yoldan çağırın: hem update yolundan hem de isteği ayrıştıran katmandan. Bu yollardan yalnızca birinin kendi başına yazdığı kısıt, diğerlerinin uygulamadığı kısıttır.

3. Her Zaman Optimistic Locking Kullanın#

Concurrent update’ler olacak. İlk günden version field’ları ve optimistic locking ile bunları planlayın.

4. Gerçek Data Pattern’leriyle Test Edin#

Unit testler harika, ama gerçekçi data hacimlerindeki integration testler gerçek sorunları yakalar.

5. Query Performansını İzleyin#

DynamoDB Toolbox query yapmayı kolaylaştırır - belki çok kolay. Read/write unit’lerinizi izleyin ve pahalı sorguları optimize edin.

Raw SDK’dan Migration Stratejisi#

Şu anda raw DynamoDB SDK kullanıyorsanız, güvenle migrate etmenin yolu:

Faz 1: Paralel Implementation#

// Mevcut olanların yanında yeni operasyonları implement edin
import { GetItemCommand } from 'dynamodb-toolbox/entity/actions/get';
import { UserEntity } from '../database/entities';

class UserRepository {
  // Eski method (şimdilik tut)
  async getUserOld(userId: string) {
    const params = {
      TableName: 'Users',
      Key: { PK: { S: `USER#${userId}` }, SK: { S: `USER#${userId}` } }
    };
    return await this.dynamoClient.getItem(params).promise();
  }

  // DynamoDB Toolbox ile yeni method
  async getUser(userId: string) {
    const { Item } = await UserEntity.build(GetItemCommand)
      .key({ userId })
      .send();

    return Item ?? null;
  }
}

Faz 2: Feature Flag’li Rollout#

// Kademeli olarak switch yapmak için feature flag'ler kullanın
const useNewRepository = process.env.USE_DYNAMODB_TOOLBOX === 'true';

const user = useNewRepository
  ? await userRepo.getUser(userId)
  : await userRepo.getUserOld(userId);

Faz 3: Tam Migration#

Yeni implementation’a güvendikten sonra, eski kodu kaldırın ve temizleyin.

Nerede Uygun#

DynamoDB Toolbox, table sizin sorumluluğunuzdaysa, access pattern’ler makul ölçüde oturmuşsa ve ekip zaten TypeScript yazıyorsa yerini hak eder. Bu koşullarda entity dosyası key üretiminin, default’ların ve marshalling’in tek adresi olur; bir schema değişikliğinin maliyeti tek bir tanımı düzenlemeye iner.

İki durumda raw command’larda kalmak daha mantıklı. Access pattern’ler hâlâ her hafta değişiyorsa entity tanımları değişimin üstüne değişim biriktirir; DocumentClient etrafındaki ince bir yardımcı katmanı sürdürmek daha ucuza gelir. Servis iki sorgusu olan tek bir Lambda ise kurulum maliyeti çözdüğünüz problemden büyüktür.

Kod yazmadan önce sürümü netleştirin. Yukarıdaki her şey v2 için geçerli; 0.x ya da v1’de entity tanımları, snippet’lerin derlenmeyeceği kadar farklı biçimde.

Kaynaklar#

İlgili yazılar