Skip to content

feat: AsyncLocalStorageを用いたUnit of Work基盤を導入#126

Draft
Mel-906 wants to merge 4 commits intodevelopfrom
feature/unit-of-work
Draft

feat: AsyncLocalStorageを用いたUnit of Work基盤を導入#126
Mel-906 wants to merge 4 commits intodevelopfrom
feature/unit-of-work

Conversation

@Mel-906
Copy link
Copy Markdown

@Mel-906 Mel-906 commented Mar 26, 2026

Summary

複数の集約をまたぐDB操作をトランザクションで安全にまとめるための Unit of Work(UoW)基盤を導入します。
AsyncLocalStorageを利用して、トランザクションを処理の流れ全体で暗黙的に共有できるようにしました。

変更内容

  • UnitOfWork インターフェース(Application層): 「操作をまとめて扱う」抽象的な契約。DB/トランザクションという概念は含まない
  • DrizzleUnitOfWork(Infrastructure層): UoWの具象実装。Drizzleのトランザクション + AsyncLocalStorageで実現
  • client.ts の変更: getDb() がAsyncLocalStorageを確認し、トランザクション中ならそれを返す。すでにトランザクション中ならネストしない
  • IUseCaserun() メソッド追加: execute() をUoWで囲んで実行する。既存コードへの破壊的変更なし

使い方

// 従来通り(トランザクションなし)
await useCase.execute(input);

// トランザクションあり
const uow = new DrizzleUnitOfWork();
await useCase.run(input, uow);

既知の課題・相談したい点

1. execute() vs run() の選択が人間に委ねられている

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 DrizzleDb

Drizzleのトランザクションオブジェクトと通常のDB接続は実行時に互換だが、TypeScript上は別の型。
強制キャストしているため、Drizzleのメジャーアップデートで型チェックをすり抜けて壊れる可能性がある(低リスク)。

5. テストが未実装

UoWの動作確認(ロールバック、ネスト防止)のインテグレーションテストがない。

Test plan

  • npm run typecheck が通ること
  • npm run lint が通ること
  • UoWのインテグレーションテストを追加する(別PR可)
  • 既存のUseCaseが execute() で従来通り動作すること

🤖 Generated with Claude Code

@Mel-906
Copy link
Copy Markdown
Author

Mel-906 commented Mar 26, 2026

次のPRで実装予定の内容

このPRでUoW基盤を導入した後、Repository内部のトランザクション処理を整理するPRを予定しています。

1. Repository内の db.transaction()getDb() 経由に統一

以下の3つのRepositoryが save() 内で db.transaction(async (tx) => {...}) を使い、コールバック内で tx を直接操作しています。
これを getDb() 経由に変更し、UoWのトランザクションが開始されていればそれに乗るようにします。

  • DrizzleMemberRepository.save() — members + memberDomainEvents
  • DrizzleDiscordAccountRepository.save() — discordAccounts + discordAccountDomainEvents
  • DrizzleKarteRepository.save() — kartes + karteAssignees

2. DrizzleEventRepository.persistEvent() をトランザクションで囲む

現状、5つのDB操作(event upsert → 不要exhibit削除 → exhibit upsert → memberEvents同期 → memberExhibits同期)がトランザクションなしで実行されています。
runInTransaction() で囲み、集約単位の原子性を確保します。

変更の対応表

対象 現状 PR2での変更
DrizzleMemberRepository.save() db.transaction(tx => ...) getDb() 経由に統一
DrizzleDiscordAccountRepository.save() db.transaction(tx => ...) getDb() 経由に統一
DrizzleKarteRepository.save() db.transaction(tx => ...) getDb() 経由に統一
DrizzleEventRepository.persistEvent() トランザクションなし runInTransaction() で囲む

Comment on lines 8 to 32
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));
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この設計では、execute() と run() のどちらを使うかを呼び出し側が判断します。もし複数のテーブルに書き込むようなUseCaseで execute() を使ってしまうと、途中で失敗したときに一部の書き込みだけが残り、データの整合性が壊れます。しかしこのミスはエラーにならず、正常に動いているように見えるため、問題が起きるまで気づけません。

その代わりに、execute() 自体がトランザクションを開始するようにしてはどうでしょうか。
全てのUseCaseは execute() を呼ぶだけでトランザクション内で実行されるようにするということです。

Copy link
Copy Markdown
Author

@Mel-906 Mel-906 Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KinjiKawaguchi

execute()にトランザクション開始を組み込む案だと、各UseCaseが実装しているメソッドの書き換え(35個)が必要になるのではと思います。
run()のみを入口にすれば、execute()の誤用も解消でき、トランザクションでの保護も行えます。

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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では executeprotected _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

Comment thread src/infrastructure/drizzle/client.ts Outdated
Comment thread src/infrastructure/drizzle/client.ts Outdated
@Mel-906
Copy link
Copy Markdown
Author

Mel-906 commented Mar 28, 2026

91008a2
にて関数・型の名前が不正確で合った点を修正しました。
createDbとさらに追加で、getDb , DrizzleDbについて修正を行いました。
getDb → getClient
createDb → createClient
DrizzleDb  → DrizzleClient

6d5f1a6
にて強制型キャストを除去しました。
DrizzleClient の型定義を ReturnType から PgDatabase<NodePgQueryResultHKT, typeof schema> に変更しました。

Mel-906 and others added 3 commits April 25, 2026 01:40
複数の集約をまたぐ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>
@Mel-906 Mel-906 force-pushed the feature/unit-of-work branch from eb138a9 to a90e539 Compare April 24, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants