DynamoDBはスケーラブルなNoSQLデータベースですが、リレーショナル思考で設計すると「テーブルを増やしすぎる」「JOINを求めて複雑なアプリコードを書く」という罠にはまります。Single-Table Design(STD)を採用することで、1テーブルで複数エンティティを効率的に管理し、高速クエリを維持できます。今回はClaude Codeを活用し、本番運用に耐えるDynamoDBパターンを実装します。
Single-Table Designの基本思想
STDのキーアイデアは「アクセスパターン先行設計」です。
-
PK(Partition Key): エンティティの種類と識別子を結合(例:
USER#123) -
SK(Sort Key): サブエンティティや属性を表す(例:
PROFILE、ORDER#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;
}
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;
}
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';
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;
}
}
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[];
}
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);
}
Claude Codeとのワークフロー
Claude Codeは「アクセスパターンの洗い出し」フェーズでも威力を発揮します。
「ユーザーとその注文を扱うSingle-Table設計を作って。
アクセスパターン:
1. ユーザープロフィール取得(userId指定)
2. 特定ユーザーの注文一覧(最新順)
3. 全注文の日付範囲検索(ページネーション付き)
4. ステータス別注文一覧」
この一文で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)