メインコンテンツまでスキップ

トランザクション管理 — 整合性の境界をどこに置くか

トランザクションとは

トランザクションは、複数のデータベース操作を1つの論理的な単位としてまとめる仕組みです。すべての操作が成功すればコミット(確定)され、1つでも失敗すればロールバック(取り消し)されます。

ACID特性

トランザクションは以下の4つの特性(ACID)を保証します。

特性説明
Atomicity(原子性)すべて成功するか、すべて失敗するか注文作成と注文明細の保存は両方成功、または両方失敗
Consistency(一貫性)データベースが常に整合性のある状態に保たれる注文の合計金額が常に明細の合計と一致
Isolation(分離性)同時実行される他のトランザクションの影響を受けないユーザーAとBが同時に在庫を更新しても整合性が保たれる
Durability(永続性)コミット後は障害があってもデータが保持されるコミット完了後のデータはシステムクラッシュでも失われない
[Without Transaction]
1. Insert order ✓
2. Insert order_lines ✗ (ERROR!)
→ Result: 注文だけが残り、明細がない不整合な状態

[With Transaction]
1. BEGIN TRANSACTION
2. Insert order ✓
3. Insert order_lines ✗ (ERROR!)
4. ROLLBACK
→ Result: すべての操作が取り消され、整合性が保たれる

Laravelのトランザクション管理

DB::transaction()の動作

LaravelのDB::transaction()は以下のように動作します。

DB::transaction(function () {
// この中の操作がトランザクション内で実行される
OrderModel::create([...]);
OrderLineModel::create([...]);

// 例外が発生すると自動的にロールバック
if ($error) {
throw new Exception('Error!');
}

// 正常終了すると自動的にコミット
});

動作の詳細:

  1. 自動BEGIN: DB::transaction()が呼ばれた時点でBEGIN TRANSACTIONが実行される
  2. 例外でロールバック: クロージャ内で例外が発生すると自動的にROLLBACK
  3. 正常終了でコミット: クロージャが正常に終了すると自動的にCOMMIT
  4. 例外の再スロー: ロールバック後、例外は呼び出し元に再スローされる
// 手動でトランザクションを制御する場合(非推奨)
DB::beginTransaction();
try {
OrderModel::create([...]);
OrderLineModel::create([...]);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
// ↑ DB::transaction()を使えば上記の処理を自動化できる
DB::transaction()を使う理由

手動でbeginTransaction()commit()rollBack()を管理すると、例外ハンドリングを忘れたり、ネストしたトランザクションの扱いが複雑になります。DB::transaction()を使うことで、これらの問題を回避できます。

2つのアプローチ

トランザクションを管理する場所には2つのアプローチがあります。

使い分けの基準

ケーストランザクションの場所理由
1つの集約のみ更新Repository内(アプローチA)集約の整合性はリポジトリが保証
複数の集約を更新UseCase内(アプローチB)複数集約にまたがる整合性はUseCaseが保証

1集約の更新:Repository内でトランザクション

// app/Infrastructure/Repository/EloquentOrderRepository.php
final class EloquentOrderRepository implements OrderRepositoryInterface
{
public function save(Order $order): void
{
// Repository内でトランザクション
DB::transaction(function () use ($order) {
$orderModel = OrderModel::updateOrCreate(
['id' => $order->id()->value()],
[
'status' => $order->status()->value,
'shipping_prefecture' => $order->shippingAddress()->prefecture(),
'shipping_city' => $order->shippingAddress()->city(),
'shipping_street' => $order->shippingAddress()->street(),
]
);

$this->saveOrderLines($orderModel, $order->orderLines());
});
}
}
// app/Application/UseCase/Order/CreateOrderUseCase.php
final class CreateOrderUseCase
{
public function __construct(private readonly OrderRepositoryInterface $orderRepository) {}

public function execute(CreateOrderCommand $command): OrderId
{
$orderId = $this->orderRepository->nextIdentity();
$shippingAddress = new ShippingAddress(
$command->prefecture,
$command->city,
$command->street
);
$order = Order::create($orderId, $shippingAddress);

foreach ($command->items as $item) {
$order->addItem(
$this->orderRepository->nextLineIdentity(),
new ProductId($item['productId']),
$item['quantity'],
new Money($item['unitPrice'], 'JPY'),
);
}

// UseCaseはトランザクションを意識しない
$this->orderRepository->save($order);

return $orderId;
}
}

複数集約の更新:UseCase内でトランザクション

注文確定時に在庫を減らすケースを考えます。

// app/Application/UseCase/Order/ConfirmOrderUseCase.php
final class ConfirmOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly InventoryRepositoryInterface $inventoryRepository,
) {}

public function execute(OrderId $orderId): void
{
// 複数集約を更新するため、UseCase内でトランザクション管理
DB::transaction(function () use ($orderId) {
// 1. Order集約を取得・更新
$order = $this->orderRepository->findById($orderId)
?? throw new DomainException('注文が見つかりません');

$order->confirm();
$this->orderRepository->save($order);

// 2. Inventory集約を更新(在庫を減らす)
foreach ($order->orderLines() as $line) {
$inventory = $this->inventoryRepository->findByProductId($line->productId())
?? throw new DomainException('在庫情報が見つかりません');

$inventory->decrease($line->quantity());
$this->inventoryRepository->save($inventory);
}
});
}
}

ネストしたトランザクションの扱い

Laravelでは、DB::transaction()がネストした場合、Savepoint機能が使われます。

Savepointとは

Savepointは、トランザクション内の特定地点をマークし、その地点までロールバックできる機能です。

[Nested Transaction Flow]

DB::transaction(function () { ← Transaction Level 1: BEGIN
// 操作1

DB::transaction(function () { ← Transaction Level 2: SAVEPOINT trans2
// 操作2
}); ← RELEASE SAVEPOINT trans2(正常終了)

DB::transaction(function () { ← Transaction Level 3: SAVEPOINT trans3
// 操作3
throw new Exception(); ← ROLLBACK TO SAVEPOINT trans3(例外)
});

}); ← COMMIT(最外層)

実際の動作:

// UseCase内でトランザクション開始
DB::transaction(function () { // BEGIN TRANSACTION

// Repository内でもDB::transaction()を呼んでいる
$this->orderRepository->save($order); // SAVEPOINT sp1 → ... → RELEASE sp1

$this->inventoryRepository->save($inventory); // SAVEPOINT sp2 → ... → RELEASE sp2

}); // COMMIT(最外層のみ実際にコミット)
ネストしたトランザクションの重要な性質
  1. 最外層のみがコミット: ネストされた内側のトランザクションは、最外層が成功したときのみコミットされる
  2. 内側の失敗は全体に波及: 内側のトランザクションで例外が発生すると、外側にも伝播し、全体がロールバックされる
  3. Repository内でトランザクションを張っていても安全: UseCase内でトランザクションを張れば、全体が1つのトランザクションとして扱われる

ネストしたトランザクションの実例

// UseCase: 複数集約を更新(トランザクション管理)
final class ConfirmOrderUseCase
{
public function execute(OrderId $orderId): void
{
// 最外層トランザクション
DB::transaction(function () use ($orderId) {
// Repository内でもトランザクションを使用(Savepointになる)
$order = $this->orderRepository->findById($orderId);
$order->confirm();
$this->orderRepository->save($order); // 内部でDB::transaction()

foreach ($order->orderLines() as $line) {
$inventory = $this->inventoryRepository->findByProductId($line->productId());
$inventory->decrease($line->quantity());
$this->inventoryRepository->save($inventory); // 内部でDB::transaction()
}
// すべて成功 → COMMIT
});
}
}

そのため、Repository内でトランザクションを張っていても、UseCase内でトランザクションを張れば、全体が1つのトランザクションとして扱われます。

DB::transaction()をUseCaseで直接使うことについて

厳密なクリーンアーキテクチャでは、DB::transaction()はインフラ層の詳細であり、UseCase層で直接使うべきではないという考え方もあります。

// より厳密な実装(オプション)
interface TransactionManagerInterface
{
public function execute(callable $callback): mixed;
}

final class LaravelTransactionManager implements TransactionManagerInterface
{
public function execute(callable $callback): mixed
{
return DB::transaction($callback);
}
}

しかし、本書ではLaravelの実用性を重視し、DB::transaction()を直接使用しています。これはLaravelプロジェクトでの一般的なプラクティスであり、テストでもRefreshDatabaseトレイトにより問題なく動作します。

プロジェクトの要件に応じて、抽象化レベルを選択してください。

楽観的ロック(Optimistic Locking)

同時更新による競合を防ぐには、楽観的ロックが有効です。

[Problem: Lost Update]

User A: read order (version 1)
User B: read order (version 1)
User A: update order → save (version 2)
User B: update order → save (overwrites A's changes!)

[Solution: Optimistic Locking]

User A: read order (version 1)
User B: read order (version 1)
User A: update order → save (version 1 → 2) ✓
User B: update order → save (version 1 → ?) ✗ Conflict!

Laravelでの実装

// マイグレーション
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('status');
// ...
$table->unsignedBigInteger('version')->default(1); // バージョンカラム
$table->timestamps();
});

// Eloquent Model
final class OrderModel extends Model
{
protected $fillable = ['status', 'version', /* ... */];
}

ドメインエンティティでのバージョン管理

// app/Domain/Order/Order.php
final class Order
{
private function __construct(
private readonly OrderId $id,
private OrderStatus $status,
private int $version, // バージョン
// ...
) {}

public function version(): int
{
return $this->version;
}

public function incrementVersion(): void
{
$this->version++;
}
}

リポジトリでの競合検出

// app/Infrastructure/Repository/EloquentOrderRepository.php
final class EloquentOrderRepository implements OrderRepositoryInterface
{
public function save(Order $order): void
{
DB::transaction(function () use ($order) {
$currentVersion = $order->version();
$order->incrementVersion();

$affected = OrderModel::where('id', $order->id()->value())
->where('version', $currentVersion) // 現在のバージョンを条件に
->update([
'status' => $order->status()->value,
'version' => $order->version(),
// ...
]);

if ($affected === 0) {
// 更新対象がない = バージョンが変わっている = 競合
throw new OptimisticLockException(
'他のユーザーによって更新されました。再度読み込んでください。'
);
}
});
}
}

新規作成時の処理

public function save(Order $order): void
{
DB::transaction(function () use ($order) {
$exists = OrderModel::where('id', $order->id()->value())->exists();

if ($exists) {
// 更新:楽観的ロックを適用
$this->updateWithOptimisticLock($order);
} else {
// 新規作成:バージョン1で保存
$this->create($order);
}
});
}

例外の定義

// app/Domain/Shared/Exception/OptimisticLockException.php
final class OptimisticLockException extends DomainException
{
public function __construct(string $message = '同時更新の競合が発生しました')
{
parent::__construct(
message: $message,
errorCode: 'OPTIMISTIC_LOCK_CONFLICT',
);
}
}
楽観的ロック vs 悲観的ロック

DDDアプリケーションでは、同時更新の競合を防ぐために2つのアプローチがあります。

方式特徴適したケースパフォーマンス
楽観的ロック更新時に競合を検出競合が稀な場合(Webアプリの多く)高速(ロック待ちなし)
悲観的ロック読み取り時にロック取得競合が頻繁な場合(在庫の同時更新など)低速(ロック待ちが発生)

楽観的ロックの仕組み

ユーザーA: read (version 1) → 処理中...
ユーザーB: read (version 1) → 処理中...
ユーザーA: update (version 1 → 2) ✓ 成功
ユーザーB: update (version 1 → ?) ✗ 競合エラー(version が既に2になっている)

利点: 読み取り時にロックしないため、パフォーマンスが高くなります。

欠点: 競合発生時はエラーになり、ユーザーは再試行が必要になります。

悲観的ロックの仕組み

ユーザーA: read with lock → 処理中...(他のユーザーは待機)
ユーザーB: read with lock → 待機中...
ユーザーA: update → commit → ロック解放
ユーザーB: read with lock → 処理開始

利点: 確実に競合を防げます。

欠点: ロック待ちが発生し、パフォーマンスが低下する可能性があります。

Laravelでの悲観的ロックの実装

// app/Infrastructure/Repository/EloquentInventoryRepository.php
public function findByProductIdForUpdate(ProductId $productId): ?Inventory
{
$model = InventoryModel::where('product_id', $productId->value())
->lockForUpdate() // SELECT ... FOR UPDATE(行ロック)
->first();

return $model ? $this->toDomain($model) : null;
}

// UseCase内での使用
DB::transaction(function () {
// 悲観的ロックで在庫を取得(他のトランザクションはここで待機)
$inventory = $this->inventoryRepository->findByProductIdForUpdate($productId);
$inventory->decrease(5);
$this->inventoryRepository->save($inventory);
// コミット後、他のトランザクションがロック解放を待って進行
});

使い分けの判断基準

多くのWebアプリケーションでは楽観的ロックが適しています。

楽観的ロックを選ぶ場合:

  • 注文の更新、ユーザープロフィールの編集など、同時更新が稀なケース
  • レスポンス速度を重視したい場合

悲観的ロックを選ぶ場合:

  • セール期間中の在庫更新など、同時更新が頻繁に発生するケース
  • 競合エラーでの再試行をユーザーに求めたくない場合
  • 座席予約システムなど、確実に1つのトランザクションだけが処理すべき場合

まとめ

場面トランザクションの場所理由
新規注文作成Repository1集約(Order)のみ更新
注文確定 + 在庫減少UseCase2集約(Order, Inventory)を更新
注文キャンセルRepository1集約(Order)のみ更新
注文キャンセル + 在庫戻しUseCase2集約を更新
同時更新の競合防止Repository楽観的ロックで検出

参考リソース

次のチャプターでは、エラーハンドリングについて詳しく見ていきます。