Skip to main content

Transaction Management — Where to Place the Consistency Boundary

What is a transaction

A transaction is a mechanism that groups multiple database operations into a single logical unit. If all operations succeed, the transaction is committed; if even one fails, it is rolled back.

The ACID properties

A transaction guarantees the following four properties (ACID).

PropertyDescriptionExample
AtomicityEither everything succeeds or everything failsCreating the order and saving the order lines both succeed, or both fail
ConsistencyThe database is always kept in a consistent stateThe order's total amount always matches the sum of the line items
IsolationUnaffected by other concurrently running transactionsEven if users A and B update stock at the same time, consistency is maintained
DurabilityAfter a commit, data is retained even if a failure occursData after a commit completes is not lost even in a system crash
[Without Transaction]
1. Insert order ✓
2. Insert order_lines ✗ (ERROR!)
→ Result: only the order remains, leaving an inconsistent state with no line items

[With Transaction]
1. BEGIN TRANSACTION
2. Insert order ✓
3. Insert order_lines ✗ (ERROR!)
4. ROLLBACK
→ Result: all operations are undone, and consistency is maintained

Transaction management in Laravel

How DB::transaction() works

Laravel's DB::transaction() works as follows.

DB::transaction(function () {
// The operations inside run within a transaction
OrderModel::create([...]);
OrderLineModel::create([...]);

// An exception triggers an automatic rollback
if ($error) {
throw new Exception('Error!');
}

// Normal completion triggers an automatic commit
});

The details of the behavior:

  1. Automatic BEGIN: BEGIN TRANSACTION is executed the moment DB::transaction() is called
  2. Rollback on exception: if an exception occurs inside the closure, ROLLBACK happens automatically
  3. Commit on normal completion: if the closure completes normally, COMMIT happens automatically
  4. Re-throwing the exception: after a rollback, the exception is re-thrown to the caller
// Controlling the transaction manually (not recommended)
DB::beginTransaction();
try {
OrderModel::create([...]);
OrderLineModel::create([...]);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
// ↑ Using DB::transaction() automates the above
Why use DB::transaction()

Managing beginTransaction(), commit(), and rollBack() manually makes it easy to forget exception handling, and the handling of nested transactions becomes complex. Using DB::transaction() avoids these problems.

Two approaches

There are two approaches to where the transaction is managed.

Decision criteria for which to use

CaseTransaction locationReason
Updating only one aggregateInside the Repository (Approach A)The repository guarantees the aggregate's consistency
Updating multiple aggregatesInside the UseCase (Approach B)The UseCase guarantees consistency across multiple aggregates

Updating a single aggregate: transaction inside the Repository

// app/Infrastructure/Repository/EloquentOrderRepository.php
final class EloquentOrderRepository implements OrderRepositoryInterface
{
public function save(Order $order): void
{
// Transaction inside the 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'),
);
}

// The UseCase is not aware of the transaction
$this->orderRepository->save($order);

return $orderId;
}
}

Updating multiple aggregates: transaction inside the UseCase

Consider the case of decreasing stock when an order is confirmed.

// 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
{
// Because multiple aggregates are updated, manage the transaction inside the UseCase
DB::transaction(function () use ($orderId) {
// 1. Retrieve and update the Order aggregate
$order = $this->orderRepository->findById($orderId)
?? throw new DomainException('Order not found');

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

// 2. Update the Inventory aggregate (decrease stock)
foreach ($order->orderLines() as $line) {
$inventory = $this->inventoryRepository->findByProductId($line->productId())
?? throw new DomainException('Inventory not found');

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

Handling nested transactions

In Laravel, when DB::transaction() is nested, the savepoint feature is used.

What is a savepoint

A savepoint is a feature that marks a specific point within a transaction and lets you roll back to that point.

[Nested Transaction Flow]

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

DB::transaction(function () { ← Transaction Level 2: SAVEPOINT trans2
// operation 2
}); ← RELEASE SAVEPOINT trans2 (normal completion)

DB::transaction(function () { ← Transaction Level 3: SAVEPOINT trans3
// operation 3
throw new Exception(); ← ROLLBACK TO SAVEPOINT trans3 (exception)
});

}); ← COMMIT (outermost)

The actual behavior:

// Start the transaction inside the UseCase
DB::transaction(function () { // BEGIN TRANSACTION

// DB::transaction() is also called inside the Repository
$this->orderRepository->save($order); // SAVEPOINT sp1 → ... → RELEASE sp1

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

}); // COMMIT (only the outermost actually commits)
Important properties of nested transactions
  1. Only the outermost commits: a nested inner transaction is committed only when the outermost one succeeds
  2. An inner failure propagates to the whole: if an exception occurs in an inner transaction, it propagates outward and the whole thing is rolled back
  3. Safe even if the Repository opens a transaction: if you open a transaction inside the UseCase, the whole thing is treated as a single transaction

A concrete example of a nested transaction

// UseCase: updates multiple aggregates (transaction management)
final class ConfirmOrderUseCase
{
public function execute(OrderId $orderId): void
{
// Outermost transaction
DB::transaction(function () use ($orderId) {
// The Repository also uses a transaction internally (it becomes a savepoint)
$order = $this->orderRepository->findById($orderId);
$order->confirm();
$this->orderRepository->save($order); // DB::transaction() internally

foreach ($order->orderLines() as $line) {
$inventory = $this->inventoryRepository->findByProductId($line->productId());
$inventory->decrease($line->quantity());
$this->inventoryRepository->save($inventory); // DB::transaction() internally
}
// All succeed → COMMIT
});
}
}

Therefore, even if you open a transaction inside the Repository, opening a transaction inside the UseCase makes the whole thing be treated as a single transaction.

On using DB::transaction() directly in the UseCase

In strict clean architecture, there is a view that DB::transaction() is an infrastructure-layer detail and should not be used directly in the UseCase layer.

// A stricter implementation (optional)
interface TransactionManagerInterface
{
public function execute(callable $callback): mixed;
}

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

However, this book prioritizes Laravel's practicality and uses DB::transaction() directly. This is a common practice in Laravel projects, and it works without issue in tests thanks to the RefreshDatabase trait.

Choose the level of abstraction according to your project's requirements.

Optimistic locking

To prevent conflicts from concurrent updates, optimistic locking is effective.

[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!

Implementation in Laravel

// Migration
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('status');
// ...
$table->unsignedBigInteger('version')->default(1); // version column
$table->timestamps();
});

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

Version management in the domain entity

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

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

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

Conflict detection in the repository

// 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) // use the current version as a condition
->update([
'status' => $order->status()->value,
'version' => $order->version(),
// ...
]);

if ($affected === 0) {
// No rows to update = the version has changed = a conflict
throw new OptimisticLockException(
'This was updated by another user. Please reload and try again.'
);
}
});
}
}

Handling new creation

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

if ($exists) {
// Update: apply optimistic locking
$this->updateWithOptimisticLock($order);
} else {
// New creation: save with version 1
$this->create($order);
}
});
}

Defining the exception

// app/Domain/Shared/Exception/OptimisticLockException.php
final class OptimisticLockException extends DomainException
{
public function __construct(string $message = 'A concurrent update conflict occurred')
{
parent::__construct(
message: $message,
errorCode: 'OPTIMISTIC_LOCK_CONFLICT',
);
}
}
Optimistic locking vs pessimistic locking

In DDD applications, there are two approaches to preventing concurrent-update conflicts.

MethodCharacteristicSuitable casePerformance
Optimistic lockingDetect the conflict at update timeWhen conflicts are rare (many web apps)Fast (no lock waiting)
Pessimistic lockingAcquire a lock at read timeWhen conflicts are frequent (e.g. concurrent stock updates)Slow (lock waiting occurs)

How optimistic locking works

User A: read (version 1) → processing...
User B: read (version 1) → processing...
User A: update (version 1 → 2) ✓ success
User B: update (version 1 → ?) ✗ conflict error (version is already 2)

Pro: because there is no lock at read time, performance is higher.

Con: on a conflict it becomes an error, and the user has to retry.

How pessimistic locking works

User A: read with lock → processing... (other users wait)
User B: read with lock → waiting...
User A: update → commit → release the lock
User B: read with lock → start processing

Pro: it reliably prevents conflicts.

Con: lock waiting occurs, which may degrade performance.

Implementing pessimistic locking in Laravel

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

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

// Usage inside the UseCase
DB::transaction(function () {
// Retrieve stock with a pessimistic lock (other transactions wait here)
$inventory = $this->inventoryRepository->findByProductIdForUpdate($productId);
$inventory->decrease(5);
$this->inventoryRepository->save($inventory);
// After the commit, other transactions proceed once the lock is released
});

Decision criteria for which to use

For many web applications, optimistic locking is suitable.

When to choose optimistic locking:

  • Cases where concurrent updates are rare, such as order updates or editing a user profile
  • When you want to prioritize response speed

When to choose pessimistic locking:

  • Cases where concurrent updates happen frequently, such as stock updates during a sale
  • When you do not want to ask the user to retry on a conflict error
  • Cases where exactly one transaction must process at a time, such as a seat-reservation system

Summary

ScenarioTransaction locationReason
Creating a new orderRepositoryUpdates only one aggregate (Order)
Confirming an order + decreasing stockUseCaseUpdates two aggregates (Order, Inventory)
Cancelling an orderRepositoryUpdates only one aggregate (Order)
Cancelling an order + restoring stockUseCaseUpdates two aggregates
Preventing concurrent-update conflictsRepositoryDetect with optimistic locking

Reference resources

In the next chapter, we will take a detailed look at error handling.