Martin FowlerのUnit of Workパターンは、ビジネストランザクション内での変更を追跡し、一括でデータベースに反映する仕組みを提供します。しかしPrismaはこのパターンを採用していません。代わりに$transactionという明示的なAPIを提供しています。この設計の違いが意味するものを考えてみます。
Unit of Workとは何か
Fowlerの定義を確認しましょう。
A Unit of Work keeps track of everything you do during a business transaction that can affect the database. When you’re done, it figures out everything that needs to be done to alter the database as a result of your work.
Unit of Workは、ビジネストランザクション中に行われたデータベースに影響を与える操作をすべて追跡します。処理が完了すると、変更をデータベースに反映するために必要なことをすべて把握して実行します。
具体的には、以下のような機能を持ちます。
- 新規オブジェクトの追跡(INSERT対象)
- 変更されたオブジェクトの追跡(UPDATE対象)
- 削除されたオブジェクトの追跡(DELETE対象)
- 一括コミット
Entity FrameworkのUnit of Work
.NETのEntity Frameworkは典型的なUnit of Workの実装を持っています。
using (var context = new OrderContext())
{
var order = context.Orders.Find(orderId);
order.Status = "Shipped"; // 変更を追跡
var newItem = new OrderItem { ProductId = 1, Quantity = 2 };
order.Items.Add(newItem); // 追加を追跡
context.SaveChanges(); // ここで一括コミット
}
SaveChanges()が呼ばれるまで、変更はメモリ上で追跡され、データベースには書き込まれません。
Prismaのアプローチ
Prismaには変更追跡の仕組みがありません。各操作は即座にデータベースに反映されます。
const order = await prisma.order.findUnique({ where: { id: orderId } });
// この時点ではまだDBに何も起きていない
order.status = 'Shipped';
// これも即座に実行される(前の変更とは無関係)
await prisma.orderItem.create({
data: { orderId, productId: 1, quantity: 2 }
});
// orderの変更を反映するには明示的にupdateが必要
await prisma.order.update({
where: { id: orderId },
data: { status: 'Shipped' }
});
オブジェクトのプロパティを変更しても、それは追跡されません。データベースに反映するにはupdateを明示的に呼ぶ必要があります。
$transactionでできること
Prismaの$transactionは、複数の操作をアトミックに実行する機能を提供します。
Sequential transactions(順次実行)
await prisma.$transaction([
prisma.order.update({
where: { id: orderId },
data: { status: 'Shipped' }
}),
prisma.orderItem.create({
data: { orderId, productId: 1, quantity: 2 }
}),
prisma.inventory.update({
where: { productId: 1 },
data: { quantity: { decrement: 2 } }
})
]);
配列内のすべての操作が成功するか、すべてがロールバックされます。
Interactive transactions(対話的実行)
await prisma.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: orderId } });
if (order.status !== 'Pending') {
throw new Error('Order is not pending');
}
await tx.order.update({
where: { id: orderId },
data: { status: 'Shipped' }
});
const inventory = await tx.inventory.findUnique({
where: { productId: 1 }
});
if (inventory.quantity < 2) {
throw new Error('Insufficient inventory');
}
await tx.inventory.update({
where: { productId: 1 },
data: { quantity: { decrement: 2 } }
});
});
コールバック関数内で条件分岐やエラーハンドリングを行えます。途中で例外が投げられると、すべての変更がロールバックされます。
Unit of Workが解決する問題と、Prismaが解決しない問題
変更の自動検出
Unit of Workはオブジェクトの変更を自動的に検出します。開発者は「何が変わったか」を意識する必要がありません。
// Entity Framework
order.Status = "Shipped";
order.ShippedAt = DateTime.Now;
context.SaveChanges(); // 変更されたプロパティだけがUPDATEされる
Prismaでは、変更内容を明示的に指定する必要があります。
// Prisma
await prisma.order.update({
where: { id: orderId },
data: {
status: 'Shipped',
shippedAt: new Date()
}
});
これは面倒に見えますが、逆に言えば「何がデータベースに書き込まれるか」が明確になります。
複数Repositoryにまたがるトランザクション
Unit of Workがあれば、複数のRepositoryをまたいだトランザクションを自然に扱えます。
// Unit of Workがある場合
orderRepository.Add(order);
inventoryRepository.Decrease(productId, quantity);
unitOfWork.Commit(); // 両方の変更が一括コミット
Prismaでは、$transactionのコールバック内でPrisma Clientを使う必要があります。
// Prismaの場合
await prisma.$transaction(async (tx) => {
// txを各処理に渡す必要がある
await createOrder(tx, orderData);
await decreaseInventory(tx, productId, quantity);
});
トランザクションを意識したコードを書く必要があり、Repositoryパターンとの相性はあまり良くありません。
複数Repositoryをまたぐ設計パターン
Prismaで複数の集約をまたぐトランザクションを扱う場合、いくつかの設計パターンがあります。
パターン1. トランザクションを引数で渡す
interface OrderRepository {
save(order: Order, tx?: PrismaTransaction): Promise<void>;
}
interface InventoryRepository {
decrease(productId: string, quantity: number, tx?: PrismaTransaction): Promise<void>;
}
// UseCase
async function shipOrder(orderId: string) {
await prisma.$transaction(async (tx) => {
const order = await orderRepository.findById(orderId, tx);
order.ship();
await orderRepository.save(order, tx);
await inventoryRepository.decrease(order.productId, order.quantity, tx);
});
}
シンプルですが、すべてのRepository メソッドにトランザクションを渡す必要があります。
パターン2. コンテキストオブジェクトを使う
class TransactionContext {
constructor(private tx: PrismaTransaction) {}
get orders() { return new OrderRepository(this.tx); }
get inventory() { return new InventoryRepository(this.tx); }
}
// UseCase
async function shipOrder(orderId: string) {
await prisma.$transaction(async (tx) => {
const ctx = new TransactionContext(tx);
const order = await ctx.orders.findById(orderId);
order.ship();
await ctx.orders.save(order);
await ctx.inventory.decrease(order.productId, order.quantity);
});
}
Repositoryの生成をコンテキストに委譲することで、個々のメソッドにトランザクションを渡す必要がなくなります。
パターン3. ドメインイベント + 結果整合性
そもそもトランザクションをまたがないようにする設計も考えられます。
// Orderの保存時にドメインイベントを発行
await orderRepository.save(order);
// OrderShippedイベントが発行される
// 別のプロセスでInventoryを更新
eventHandler.on('OrderShipped', async (event) => {
await inventoryRepository.decrease(event.productId, event.quantity);
});
ただし、これは結果整合性を許容できる場合にのみ使えます。
Prismaの設計が意味するもの
Prismaが Unit of Workを採用しなかったのは、おそらく意図的な設計判断です。
- 明示性 - 何がデータベースに書き込まれるかが常に明確
- シンプルさ - 変更追跡の魔法がない分、動作が予測しやすい
- ステートレス - Prisma Clientはステートを持たない
代償として、開発者はトランザクション境界を自分で管理する必要があります。これはボイラープレートが増える一方で、トランザクションの範囲が明確になるというメリットもあります。
まとめ
Unit of Workは強力なパターンですが、Prismaはあえてこれを採用していません。$transactionは「複数操作をアトミックにする」という問題だけを解決し、「変更追跡」は開発者に委ねています。
この設計の是非は、プロジェクトの要件によって異なります。複雑なドメインロジックと多くの集約を扱うなら、Unit of Workを持つORMの方が楽かもしれません。シンプルなCRUD操作が中心なら、Prismaの明示的なアプローチの方が分かりやすいでしょう。
重要なのは、使っているツールがどのような問題を解決し、何を解決しないかを理解することです。
参考文献
- Martin Fowler『Patterns of Enterprise Application Architecture』(2002)
- Transactions and batch queries
- Entity Framework Core - Saving Data