Prismaのトランザクション管理をリポジトリ層からユースケース層へ移行した設計改善
Prismaを使用したアプリケーションで、トランザクション管理の責務をリポジトリ層からユースケース層へ移行した設計改善について解説します。クリーンアーキテクチャの原則に沿った責務分離により、保守性と柔軟性が向上した事例を紹介します。
背景と課題
Prismaを使用したアプリケーション開発において、当初はリポジトリ層内でprisma.$transaction()を用いてトランザクション管理を実施していました。
しかし、この設計には以下の課題がありました。
主な課題:
- 複数のリポジトリメソッドにまたがる処理のトランザクション制御が困難
- ユースケース層でビジネスロジックの整合性を保証できない
- 後続の処理でエラーが発生した時にロールバックがされない
解決アプローチ
Prismaトランザクションの制御をユースケース層でも実装することで、ビジネスロジックの単位でトランザクション境界を明確に定義できるようにしました。
改善前:リポジトリ層でのトランザクション管理
// リポジトリ層で個別にトランザクションを管理
class TestRepository {
async create(tx: Prisma.TransactionClient, test1Data: Test1Data, test2Data: Test2Data) {
return this.prisma.$transaction(async (tx) => {
const test1 = await tx.test1.create({ data: test1Data });
await tx.test2.create({ data: test2Data });
return test1;
});
}
}
// ユースケース層:トランザクション境界を制御
class CreateTest1UseCase {
async execute(input: Test1Input) {
const test1 = await this.test1Repo.create(tx, input.test1Data);
await this.test2Repo.create(tx, { test1Id: test1.id, ...input.test2Data }); // ここでエラーが発生した場合、ロールバックされない
return test1;
}
}
改善後:ユースケース層でのトランザクション管理
PrismaのTransactionClientは、トランザクションを管理するためのクラスです。 PrismaのTransactionClientを引数に受け取る設計にしました。
// リポジトリ層:TransactionClient を引数に受け取る設計
class TestRepository {
async create(tx: Prisma.TransactionClient, userData: UserData) {
return tx.user.create({ data: userData });
}
}
// ユースケース層:トランザクション境界を制御
class CreateUserUseCase {
async execute(input: CreateUserInput) {
return this.prisma.$transaction(async (tx) => {
// トランザクション引数を引数に渡すようにしました。同時リクエストが発生した場合、デッドロックを防ぐためです。
const user = await this.userRepo.create(tx, input.userData);
await this.profileRepo.create(tx, { userId: user.id, ...input.profileData });
return user;
});
}
}
インターフェースによる抽象化
トランザクション管理ロジックを一元化するため、インターフェースを定義して抽象化を行いました。
// インターフェース定義(ポート)
interface IPrismaTransaction {
execute<T>(...)
}
// 実装クラス(アダプタ)
class PrismaTransaction implements IPrismaTransaction {
constructor(private prisma: PrismaClient) {}
async execute<T>(...): Promise<T> {
...
}}
// ユースケース層での利用
class CreateTest1UseCase {
async execute(input: Test1Input) {
return this.transaction.execute(
async (tx) => {
const test1 = await this.test1Repo.create(tx, input.test1Data);
await this.test2Repo.create(tx, { test1Id: test1.id, ...input.test2Data });
return test1;
});
}
}
抽象化の利点:
- 依存性の逆転(DIP): ユースケース層がPrismaに直接依存しない
- テスタビリティ向上: モックによる単体テストが容易
- 設定の一元管理: デフォルト値やエラーハンドリングを統一
設計のポイント
責務の明確化:
ユースケース層がビジネストランザクションの境界を定義し、リポジトリ層は純粋にデータアクセスのみを担当します。
柔軟性の向上:
同じリポジトリメソッドをトランザクション内外で再利用可能になり、異なるユースケースで柔軟に組み合わせられます。
データ整合性の保証:
Serializable分離レベルと適切なタイムアウト設定により、クリティカルな処理のデータ整合性を担保します。
パフォーマンス考慮:
不必要に大きなトランザクションスコープを作らないよう、ビジネス要件に基づいて適切な境界を設定することも重要です。
まとめ
トランザクション管理をユースケース層に移行し抽象化を導入することで、 クリーンアーキテクチャの原則に沿った明確な責務分離を実現できました。