【TypeScript】DynamoDBでもトランザクション用の汎用的なサービスクラスを作りたい
リテールアプリ共創部のるおんです。
先日、TypeScriptを用いたバックエンド開発において、Amazon DynamoDB × レイヤードアーキテクチャでトランザクションをどう実現するかを考える機会がありました。
集約の境界が適切に設計されていれば単一アイテム操作で済むことが多いです。しかし実際の開発では、ビジネス要件として複数のエンティティをアトミックに操作したい場面がどうしても出てきます。1テーブル1ドメインモデルで設計している場合はなおさらです。RDBではリレーションの関係でこういった仕組みを構築することが多いと思いますが、NoSQLである Amazon DynamoDB ではそこまでトランザクションが発生することは多くないですが、同じようなことができないかと考え今回の実装をしてみました。
そこで、ドメイン層に汎用的なTransactionServiceを作り、インフラ層でDynamoDBのTransactWriteCommandを使って実装するアプローチを取ってみました。
ここでいう「汎用的」とは、特定のユースケースに縛られず、どのエンティティの組み合わせでもトランザクション操作を宣言的に記述できるという意味です。今回はその設計と実装を共有したいと思います。
RDB(Prisma)での場合
RDBの場合はPrismaの$transactionやTypeORMのQueryRunnerのようにコールバック型でトランザクションを扱うのが一般的です。しかし、DynamoDBのTransactWriteItemsは宣言的なAPIで、「このアイテムをPut、このアイテムをDelete」というリストを一括送信する設計になっています。この特性の違いをどうドメイン層の設計に反映するかが今回のテーマです。
まず、比較対象として以前別のプロジェクトで私がPrismaを使って作ったTransactionManagerの実装を見てみます。
こちらはコールバック関数の中でリポジトリのメソッドを呼び出すパターンです。
// TransactionManagerインターフェース(ドメイン層)
type TransactionManager = {
+ runInTransaction<T>(
+ operation: (tx?: unknown) => Promise<Result<T, E>>,
+ ): Promise<Result<T, E>>;
};
// Prismaによる実装(インフラ層)
class PrismaTransactionManager implements TransactionManager {
async runInTransaction<T>(
operation: (tx?: unknown) => Promise<Result<T, E>>,
): Promise<Result<T, E>> {
+ return await prisma.$transaction(async (tx) => {
+ return await operation(tx);
+ });
}
}
// ユースケースでの使用例
await transactionManager.runInTransaction(async (tx) => {
await userRepository.save(updatedUser, tx);
await subscriptionRepository.save(canceledSubscription, tx);
return createSuccess(undefined);
});
このパターンの特徴は以下の通りです。
- コールバック内でリポジトリのメソッドをそのまま呼び出せる
tx(トランザクションクライアント)をリポジトリに渡すことで同一トランザクション内で実行される- 操作の種類に制限がない(find、save、delete何でも可能)
Prismaの場合は$transactionが内部でトランザクションクライアントを管理してくれるため、このコールバック型が非常にフィットします。
参考:
DynamoDBでは同じアプローチが取れない
では、DynamoDBでもコールバック型にすればいいのでは?と考えるかもしれません。しかし、DynamoDBのTransactWriteItemsはRDBのトランザクションとは根本的に仕組みが異なります。
RDB(Prisma)の場合:
BEGIN TRANSACTIONでトランザクションを開始- トランザクションクライアント(
tx)経由で個別にクエリを実行 COMMITorROLLBACK
DynamoDB(TransactWriteItems)の場合:
- 実行したい操作(Put/Delete/Update/ConditionCheck)のリストを組み立てる
- リスト全体を一括送信
- 全成功 or 全失敗(アトミック)
つまり、DynamoDBにはトランザクションクライアントという概念がありません。コールバック型にしてもtxで渡せるものがないのです。結局内部でアイテムを集めて最後にまとめて送信することになり、外から見たAPIがコールバック型なだけで実態はアイテム収集になってしまいます。
そこで、DynamoDBの特性に素直に合った「アイテム配列を渡す宣言的なパターン」を採用しました。
ドメイン層の設計
ディレクトリ構成
src/
├── domain/
│ └── support/
│ └── transaction-service/
│ ├── index.ts # インターフェース定義
│ └── dummy.ts # テスト用ダミー実装
└── infrastructure/
└── transaction/
└── dynamodb-transaction-service.ts # DynamoDB実装
インターフェース定義
DDDやクリーンアーキテクチャで重要視される依存性逆転の原則(DIP)に従い、ドメイン層にインターフェースを定義し、インフラ層で実装します。こうすることで、ドメイン層やユースケース層はDynamoDBという具体的なインフラ技術に依存せず、テスト時にはダミー実装に差し替えることもできます。
/**
* トランザクション対応エンティティの種別
*/
export type EntityType = "user" | "membershipCard";
/**
* トランザクション対象のエンティティ
*/
export type TransactableEntity = User | MembershipCard;
/**
* トランザクションで実行する操作
* - put: エンティティの作成/更新
* - delete: エンティティの削除
*/
+export type TransactionItem =
+ | { operation: "put"; entity: TransactableEntity }
+ | { operation: "delete"; entity: TransactableEntity };
/**
* トランザクションサービス
*/
export type TransactionService = {
/**
* トランザクションを実行する
*
* @param items トランザクション対象のアイテム
* @throws トランザクション失敗時、またはアイテム数が上限を超えた場合にエラーをスロー
*/
+ execute(items: TransactionItem[]): Promise<void>;
};
ポイントは以下の通りです。
TransactionItemはoperation(putまたはdelete)とentity(対象のドメインモデル)のペアです- ユースケースは「何をどうしたいか」を宣言するだけで、DynamoDBの詳細を知る必要がありません
TransactableEntityはUnion型で、トランザクション対応のエンティティを列挙しています
インフラ層の実装
次に、インフラ層でDynamoDBのTransactWriteCommandを使って実装します。
const MAX_TRANSACT_ITEMS = 100;
export class DynamoDBTransactionServiceImpl implements TransactionService {
readonly #ddbDoc: DynamoDBDocumentClient;
readonly #tableNames: Record<EntityType, string>;
readonly #logger: Logger;
constructor({ ddbDoc, tableNames, logger }: DynamoDBTransactionServiceProps) {
this.#ddbDoc = ddbDoc;
this.#tableNames = tableNames;
this.#logger = logger;
}
async execute(items: TransactionItem[]): Promise<void> {
this.#logger.info("トランザクション開始", {
itemCount: items.length,
});
if (items.length === 0) {
this.#logger.debug("トランザクションアイテムが空のためスキップ");
return;
}
if (items.length > MAX_TRANSACT_ITEMS) {
throw new DatabaseError(
"トランザクションアイテム数が上限を超えています",
{
cause: {
itemCount: items.length,
maxTransactItems: MAX_TRANSACT_ITEMS,
},
},
);
}
+ const transactItems = items.map((item) => this.#toTransactWriteItem(item));
+ await this.#ddbDoc.send(
+ new TransactWriteCommand({ TransactItems: transactItems }),
+ );
this.#logger.debug("トランザクション完了", {
itemCount: items.length,
});
}
/**
* TransactionItem → DynamoDB TransactWriteItem 変換
*/
+ #toTransactWriteItem(item: TransactionItem) {
+ const { tableName, ddbItem, key } = this.#resolveEntityInfo(item.entity);
switch (item.operation) {
case "put":
return {
Put: {
TableName: tableName,
Item: ddbItem,
},
};
case "delete":
return {
Delete: {
TableName: tableName,
Key: key,
},
};
}
}
/**
* エンティティからテーブル名、DynamoDB Item、Keyを解決する
*/
+ #resolveEntityInfo(entity: TransactableEntity) {
if (entity instanceof User) {
return {
tableName: this.#tableNames.user,
ddbItem: userDdbItemFromDomain(entity),
key: { userId: entity.userId },
};
}
if (entity instanceof BaseMembershipCard) {
return {
tableName: this.#tableNames.membershipCard,
ddbItem: membershipCardDdbItemFromDomain(entity),
key: {
userId: entity.userId,
facilityCodeCreatedAt: generateFacilityCodeCreatedAt(
entity.facilityCode,
dateToIsoString(entity.createdAt),
),
},
};
}
// 新しいエンティティを追加した場合はここでコンパイルエラーになる
+ throw new ExhaustiveError(entity);
}
}
実装のポイントを解説します。
#toTransactWriteItem: ドメインのTransactionItemをDynamoDBのTransactWriteItemに変換します。operationに応じてPutまたはDeleteのオブジェクトを生成します。#resolveEntityInfo: エンティティの型からテーブル名、DynamoDB用のItem、Key情報を解決します。ここにエンティティ → DynamoDB形式への変換ロジックが集約されています。ExhaustiveError: TypeScriptのExhaustive Check(網羅性チェック)を利用して、新しいエンティティをTransactableEntityに追加した際に#resolveEntityInfoの対応漏れをコンパイル時に検出できるようにしています。MAX_TRANSACT_ITEMS: DynamoDBのTransactWriteItemsは1回のリクエストで最大100アイテムまでという制約があるため、上限チェックを行っています。
ユースケースでの使い方
実際のユースケースでの使用例です。会員証の退会処理では、本会員証のステータスを変更しつつ、同施設の仮会員証を全て削除する必要があり、これらをアトミックに実行したいケースです。
// トランザクションアイテムを構築
const transactionItems: TransactionItem[] = [
// 本会員証: inactive に変更して put
{ operation: "put", entity: inactiveMembershipCard },
// 仮会員証: delete
...temporaryCards.map(
(card): TransactionItem => ({ operation: "delete", entity: card }),
),
];
// トランザクションで会員証を保存・削除
+await this.#transactionService.execute(transactionItems);
ユースケースが非常にシンプルに書けることがわかります。「何をどうしたいか」をTransactionItemの配列として宣言するだけです。
RDB(Prisma)のコールバック型との比較
最後に、RDB(Prisma)のコールバック型との違いをまとめます。
| コールバック型(RDB/Prisma) | アイテム配列型(DynamoDB) | |
|---|---|---|
| パターン | コールバック内でリポジトリを呼ぶ | 操作対象を配列で渡す |
| 操作の指定 | repository.save(entity, tx) |
{ operation: "put", entity } |
| 戻り値 | Result<T, E>(値で結果を表現) |
Promise<void>(失敗は例外) |
| txの受け渡し | 必要(各リポジトリに渡す) | 不要(内部で完結) |
| 操作の自由度 | 高い(read/write何でも可) | put/deleteに限定 |
| DBとの相性 | RDB向き | DynamoDBの宣言的API向き |
コールバック型の方が汎用的に見えますが、DynamoDBにはトランザクションクライアントの概念がないため、コールバック型にしても恩恵が薄いです。むしろ、DynamoDBのTransactWriteItemsの宣言的な性質に素直に合わせた「アイテム配列型」の方が、シンプルで分かりやすい設計になると感じました。
新しいエンティティを追加する場合
もし新しいエンティティをトランザクション対応にしたい場合は、以下の3箇所を変更します。
EntityTypeに新しい種別を追加TransactableEntityのUnion型に新しいエンティティを追加DynamoDBTransactionServiceImplの#resolveEntityInfoに新しいエンティティの処理を追加
RDBのように上から渡す操作で完結するわけではなく、新しいItemを加えると多少Infra層を修正しないといけない点は懸念ですが、ExhaustiveErrorのおかげで、3の対応を忘れるとコンパイルエラーになるため、漏れを防ぐことができます。
おわりに
今回はDynamoDB × クリーンアーキテクチャやレイヤードアーキテクチャにおけるトランザクションの設計と実装を紹介しました。
DynamoDBのトランザクションはRDBとは仕組みが異なるため、RDBのパターンをそのまま持ってくるのではなく、DynamoDBの特性に合った設計を選択することが重要です。今回のアイテム配列型のアプローチは、DynamoDBのTransactWriteItemsの宣言的なAPIに素直にフィットしていて、ユースケースからの利用もシンプルに書けるようになりました。
参考になれば幸いです。
参考






