DEV Community

myougaTheAxo
myougaTheAxo

Posted on • Originally published at zenn.dev

Claude CodeでDynamoDB Single-Table設計を実装する:GSI・条件付き書き込み・Streams

DynamoDBはスケーラブルなNoSQLデータベースですが、リレーショナル思考で設計すると「テーブルを増やしすぎる」「JOINを求めて複雑なアプリコードを書く」という罠にはまります。Single-Table Design(STD)を採用することで、1テーブルで複数エンティティを効率的に管理し、高速クエリを維持できます。今回はClaude Codeを活用し、本番運用に耐えるDynamoDBパターンを実装します。

Single-Table Designの基本思想

STDのキーアイデアは「アクセスパターン先行設計」です。

  • PK(Partition Key): エンティティの種類と識別子を結合(例: USER#123
  • SK(Sort Key): サブエンティティや属性を表す(例: PROFILEORDER#2026-03-11
  • 複数エンティティを同一テーブルに共存させ、取得を1回のQueryで済ませる

テーブル定義とTypeScript型

// types/dynamodb.ts
export type PK = `USER#${string}` | `ORDER#${string}` | `PRODUCT#${string}`;
export type SK =
  | 'PROFILE'
  | `ORDER#${string}`
  | `STATUS#${string}#${string}`; // STATUS#<status>#<createdAt>

export interface DynamoItem {
  PK: PK;
  SK: SK;
  GSI1PK?: string; // 日付/ステータスのGSI用
  GSI1SK?: string;
  version?: number; // 楽観的ロック用
  [key: string]: unknown;
}

export interface UserProfile extends DynamoItem {
  PK: `USER#${string}`;
  SK: 'PROFILE';
  name: string;
  email: string;
  createdAt: string;
  version: number;
}

export interface Order extends DynamoItem {
  PK: `USER#${string}`;
  SK: `ORDER#${string}`;
  GSI1PK: string; // 'ORDER'
  GSI1SK: string; // '<createdAt>#<orderId>'
  status: 'pending' | 'shipped' | 'delivered' | 'cancelled';
  total: number;
  createdAt: string;
}
Enter fullscreen mode Exit fullscreen mode

CDKでのテーブル定義(GSI込み)

import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { Stack } from 'aws-cdk-lib';

export function createMainTable(stack: Stack): dynamodb.Table {
  const table = new dynamodb.Table(stack, 'MainTable', {
    tableName: 'myouga-main',
    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, // Streams有効化
    pointInTimeRecovery: true,
  });

  // GSI1: 日付・ステータス系クエリ
  table.addGlobalSecondaryIndex({
    indexName: 'GSI1',
    partitionKey: { name: 'GSI1PK', type: dynamodb.AttributeType.STRING },
    sortKey: { name: 'GSI1SK', type: dynamodb.AttributeType.STRING },
    projectionType: dynamodb.ProjectionType.ALL,
  });

  return table;
}
Enter fullscreen mode Exit fullscreen mode

DynamoDBClientラッパー

// lib/dynamo-client.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

const client = new DynamoDBClient({
  region: process.env.AWS_REGION ?? 'ap-northeast-1',
});

export const docClient = DynamoDBDocumentClient.from(client, {
  marshallOptions: { removeUndefinedValues: true },
});

export const TABLE = process.env.DYNAMO_TABLE ?? 'myouga-main';
Enter fullscreen mode Exit fullscreen mode

UserProfileのCRUD(楽観的ロック付き)

// repositories/user.repository.ts
import { docClient, TABLE } from '../lib/dynamo-client';
import { UserProfile } from '../types/dynamodb';
import { PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';

export async function createUserProfile(
  userId: string,
  data: { name: string; email: string }
): Promise<UserProfile> {
  const now = new Date().toISOString();
  const item: UserProfile = {
    PK: `USER#${userId}`,
    SK: 'PROFILE',
    name: data.name,
    email: data.email,
    createdAt: now,
    version: 1,
  };

  await docClient.send(new PutCommand({
    TableName: TABLE,
    Item: item,
    ConditionExpression: 'attribute_not_exists(PK)', // 重複作成防止
  }));

  return item;
}

export async function updateUserProfile(
  userId: string,
  updates: Partial<{ name: string; email: string }>,
  expectedVersion: number
): Promise<UserProfile> {
  const key = { PK: `USER#${userId}` as const, SK: 'PROFILE' as const };

  try {
    const result = await docClient.send(new UpdateCommand({
      TableName: TABLE,
      Key: key,
      UpdateExpression: 'SET #name = :name, #email = :email, #version = :newVersion',
      // 楽観的ロック: versionが期待値と一致する場合のみ更新
      ConditionExpression: '#version = :expectedVersion',
      ExpressionAttributeNames: {
        '#name': 'name',
        '#email': 'email',
        '#version': 'version',
      },
      ExpressionAttributeValues: {
        ':name': updates.name,
        ':email': updates.email,
        ':newVersion': expectedVersion + 1,
        ':expectedVersion': expectedVersion,
      },
      ReturnValues: 'ALL_NEW',
    }));

    return result.Attributes as UserProfile;
  } catch (err) {
    if (err instanceof ConditionalCheckFailedException) {
      throw new Error(
        `Optimistic lock failed: version mismatch (expected ${expectedVersion})`
      );
    }
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

GSIを使った日付・ステータスクエリ

// repositories/order.repository.ts
import { docClient, TABLE } from '../lib/dynamo-client';
import { Order } from '../types/dynamodb';
import { QueryCommand } from '@aws-sdk/lib-dynamodb';

// 全Orderを作成日降順で取得(GSI1使用)
export async function listOrdersByDate(
  limit = 20,
  lastKey?: Record<string, unknown>
): Promise<{ items: Order[]; lastKey?: Record<string, unknown> }> {
  const result = await docClient.send(new QueryCommand({
    TableName: TABLE,
    IndexName: 'GSI1',
    KeyConditionExpression: 'GSI1PK = :pk',
    ExpressionAttributeValues: { ':pk': 'ORDER' },
    ScanIndexForward: false, // 降順
    Limit: limit,
    ExclusiveStartKey: lastKey,
  }));

  return {
    items: (result.Items ?? []) as Order[],
    lastKey: result.LastEvaluatedKey,
  };
}

// ステータス絞り込み + 日付範囲(GSI1SK: '<createdAt>#<orderId>')
export async function listOrdersByStatus(
  status: Order['status'],
  from: string,
  to: string
): Promise<Order[]> {
  const result = await docClient.send(new QueryCommand({
    TableName: TABLE,
    IndexName: 'GSI1',
    KeyConditionExpression:
      'GSI1PK = :pk AND GSI1SK BETWEEN :from AND :to',
    FilterExpression: '#status = :status',
    ExpressionAttributeNames: { '#status': 'status' },
    ExpressionAttributeValues: {
      ':pk': 'ORDER',
      ':from': `${from}#`,
      ':to': `${to}#~`, // ISO日付文字列の範囲
      ':status': status,
    },
  }));

  return (result.Items ?? []) as Order[];
}
Enter fullscreen mode Exit fullscreen mode

DynamoDB Streamsハンドラー(検索インデックス連携)

// handlers/dynamo-stream.handler.ts
import { DynamoDBStreamEvent, DynamoDBRecord } from 'aws-lambda';
import { unmarshall } from '@aws-sdk/util-dynamodb';
import { AttributeValue } from '@aws-sdk/client-dynamodb';

interface SearchIndexService {
  upsert(id: string, doc: Record<string, unknown>): Promise<void>;
  delete(id: string): Promise<void>;
}

declare const searchIndex: SearchIndexService;

export async function handleDynamoStream(event: DynamoDBStreamEvent): Promise<void> {
  const promises = event.Records.map(async (record: DynamoDBRecord) => {
    const newImage = record.dynamodb?.NewImage
      ? unmarshall(record.dynamodb.NewImage as Record<string, AttributeValue>)
      : null;
    const oldImage = record.dynamodb?.OldImage
      ? unmarshall(record.dynamodb.OldImage as Record<string, AttributeValue>)
      : null;

    if (!newImage && !oldImage) return;

    const pk: string = (newImage?.PK ?? oldImage?.PK) as string;

    // USERのPROFILEのみインデックス対象
    if (!pk?.startsWith('USER#') || (newImage?.SK ?? oldImage?.SK) !== 'PROFILE') {
      return;
    }

    const userId = pk.replace('USER#', '');

    switch (record.eventName) {
      case 'INSERT':
      case 'MODIFY':
        await searchIndex.upsert(userId, {
          name: newImage!.name,
          email: newImage!.email,
          updatedAt: new Date().toISOString(),
        });
        break;

      case 'REMOVE':
        await searchIndex.delete(userId);
        break;
    }
  });

  await Promise.allSettled(promises);
}
Enter fullscreen mode Exit fullscreen mode

Claude Codeとのワークフロー

Claude Codeは「アクセスパターンの洗い出し」フェーズでも威力を発揮します。

「ユーザーとその注文を扱うSingle-Table設計を作って。
 アクセスパターン:
 1. ユーザープロフィール取得(userId指定)
 2. 特定ユーザーの注文一覧(最新順)
 3. 全注文の日付範囲検索(ページネーション付き)
 4. ステータス別注文一覧」
Enter fullscreen mode Exit fullscreen mode

この一文でPK/SK設計・GSI定義・クエリコードまで一括生成できます。

まとめ

  • Single-Table Designはアクセスパターンを先に定義し、PK=USER#{id}/SK=PROFILEのような複合キーで複数エンティティを1テーブルに共存させる
  • GSI(グローバルセカンダリインデックス)で日付・ステータス系クエリを効率化し、全テーブルスキャンを回避
  • ConditionExpression(楽観的ロック)でversionフィールドを使い、競合書き込みを安全に検出・拒否
  • DynamoDB Streamsでデータ変更をリアルタイムに検知し、Elasticsearch等の検索インデックスを非同期に更新する

Claude Codeを使えば、これらのパターンを安全・高速に実装できます。


Code Review Pack ¥980
Claude Codeを使ったコードレビュー・リファクタリング用プロンプト集です。DynamoDB設計のような複雑なデータ設計レビューにも対応。
👉 noteで購入する

Top comments (0)