feat: AsyncLocalStorageを用いたUnit of Work基盤を導入#126
Conversation
次のPRで実装予定の内容このPRでUoW基盤を導入した後、Repository内部のトランザクション処理を整理するPRを予定しています。 1. Repository内の
|
| 対象 | 現状 | PR2での変更 |
|---|---|---|
DrizzleMemberRepository.save() |
db.transaction(tx => ...) |
getDb() 経由に統一 |
DrizzleDiscordAccountRepository.save() |
db.transaction(tx => ...) |
getDb() 経由に統一 |
DrizzleKarteRepository.save() |
db.transaction(tx => ...) |
getDb() 経由に統一 |
DrizzleEventRepository.persistEvent() |
トランザクションなし | runInTransaction() で囲む |
| export abstract class IUseCase<Input, Output> { | ||
| /** | ||
| * ユースケースのビジネスロジックを実行する | ||
| * トランザクションなしで実行されるため、読み取り専用のUseCaseはこちらを使う | ||
| * 複数のDB書き込みを安全にまとめたい場合は run() を使うこと | ||
| */ | ||
| abstract execute(input: Input): Promise<Output>; | ||
|
|
||
| /** | ||
| * UnitOfWorkで囲んでexecuteを実行する | ||
| * トランザクション内で全てのDB操作がまとめて成功/失敗する | ||
| * 複数の集約をまたぐ書き込み操作がある場合に使用する | ||
| * | ||
| * 注意: | ||
| * - execute()内で外部API呼び出し(Discord API等)を行うと、 | ||
| * その応答待ちの間トランザクションが開きっぱなしになる | ||
| * 外部連携はトランザクション完了後に行うこと | ||
| * - トランザクションがロールバックされてもメモリ上のドメインオブジェクトは | ||
| * 元に戻らない。execute()内で取得・変更したドメインオブジェクトを | ||
| * ロールバック後に再利用しないこと | ||
| */ | ||
| async run(input: Input, unitOfWork: UnitOfWork): Promise<Output> { | ||
| return unitOfWork.run(() => this.execute(input)); | ||
| } | ||
| } |
There was a problem hiding this comment.
この設計では、execute() と run() のどちらを使うかを呼び出し側が判断します。もし複数のテーブルに書き込むようなUseCaseで execute() を使ってしまうと、途中で失敗したときに一部の書き込みだけが残り、データの整合性が壊れます。しかしこのミスはエラーにならず、正常に動いているように見えるため、問題が起きるまで気づけません。
その代わりに、execute() 自体がトランザクションを開始するようにしてはどうでしょうか。
全てのUseCaseは execute() を呼ぶだけでトランザクション内で実行されるようにするということです。
There was a problem hiding this comment.
execute()にトランザクション開始を組み込む案だと、各UseCaseが実装しているメソッドの書き換え(35個)が必要になるのではと思います。
run()のみを入口にすれば、execute()の誤用も解消でき、トランザクションでの保護も行えます。
There was a problem hiding this comment.
@Mel-906 ご返信ありがとうございます。
execute() 自体をトランザクション境界にする案を、もう少し具体的に説明させてください。
基底クラスをこう変えてみるのはどうでしょうか?
export abstract class IUseCase<Input, Output> {
async execute(input: Input): Promise<Output> {
return getUnitOfWork().run(() => this._execute(input));
}
protected abstract _execute(input: Input): Promise<Output>;
}こうすると、各UseCaseでは execute を protected _execute にリネームするだけで済みます。中身の変更はありません。
例えば、
// Before
async execute(input: RegisterMemberInput): Promise<RegisterMemberOutput> {
// After
protected async _execute(input: RegisterMemberInput): Promise<RegisterMemberOutput> {ファサード層は execute() をそのまま呼び続けるので変更不要です。
run() を唯一の入口にする案でも、execute() を外から呼べなくするために結局35個のUseCaseに protected を付ける変更は発生します。さらにファサード層でも全箇所 execute() → run(input, uow) への書き換えとUoWの生成が必要になるので、変更量はむしろ多くなります。
ちなみに protected はそのクラスとサブクラスからだけアクセスできる修飾子で、外部から _execute() を直接呼ぶとコンパイルエラーになります。詳しくはこちら:
https://typescriptbook.jp/reference/object-oriented/class/access-modifiers#protected
複数の集約をまたぐDB操作をトランザクションで安全にまとめるための仕組みを追加。 AsyncLocalStorageによりRepository側のコード変更なしでトランザクションを共有できる。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ient にリネーム 生成・取得しているのはDBそのものではなくDrizzleクライアントであるため、 実態に合った命名に修正。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PgTransactionはPgDatabaseを継承しているため、DrizzleClientを PgDatabase<NodePgQueryResultHKT, typeof schema>で定義することで DB接続とトランザクションを同じ型として扱えるようになった。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
eb138a9 to
a90e539
Compare
Summary
複数の集約をまたぐDB操作をトランザクションで安全にまとめるための Unit of Work(UoW)基盤を導入します。
AsyncLocalStorageを利用して、トランザクションを処理の流れ全体で暗黙的に共有できるようにしました。
変更内容
UnitOfWorkインターフェース(Application層): 「操作をまとめて扱う」抽象的な契約。DB/トランザクションという概念は含まないDrizzleUnitOfWork(Infrastructure層): UoWの具象実装。Drizzleのトランザクション + AsyncLocalStorageで実現client.tsの変更:getDb()がAsyncLocalStorageを確認し、トランザクション中ならそれを返す。すでにトランザクション中ならネストしないIUseCaseにrun()メソッド追加:execute()をUoWで囲んで実行する。既存コードへの破壊的変更なし使い方
既知の課題・相談したい点
1.
execute()vsrun()の選択が人間に委ねられているexecute()を直接呼んでもエラーにならないため、run()の使い忘れが起こりうる。execute()をprotectedにして強制する案もあるが、読み取り専用UseCaseにもUoWが必要になるトレードオフがある。2. Repository内部の既存トランザクションとのネスト
DrizzleMemberRepository.save()が内部でdb.transaction()を直接使っている。UoW経由のトランザクションが開始された状態で呼ばれるとSAVEPOINTによるネストが発生する。
→ 次のPRでRepository内部のトランザクションを整理予定
3. ドメインオブジェクトはロールバックされない
DBがロールバックされても、メモリ上のドメインオブジェクトは変更されたまま残る。
現状はUseCaseのexecute内でオブジェクトを取得→使い捨てにしているため問題は起きにくいが、
根本対策(出力をDTOにする / イミュータブル化)を検討すべきか?
4. 型キャスト
as unknown as DrizzleDbDrizzleのトランザクションオブジェクトと通常のDB接続は実行時に互換だが、TypeScript上は別の型。
強制キャストしているため、Drizzleのメジャーアップデートで型チェックをすり抜けて壊れる可能性がある(低リスク)。
5. テストが未実装
UoWの動作確認(ロールバック、ネスト防止)のインテグレーションテストがない。
Test plan
npm run typecheckが通ることnpm run lintが通ることexecute()で従来通り動作すること🤖 Generated with Claude Code