Lecture
The question of where to manage transactions in an architecture built on the principles of Domain-Driven Design arises for nearly every developer once they move beyond a simple CRUD approach and begin building a domain model. At first glance it may seem that a transaction is simply a technical detail of database work — and so it belongs in the repository, since the repository is exactly what persists data. In DDD, however, a transaction is not only a technical mechanism — above all, it is the boundary of integrity of a business operation, and this is exactly what determines its correct place in the architecture.
In DDD the system is divided into several layers: the domain model, the infrastructure, and the application (service) layer. The repository belongs to the infrastructure and plays a strictly limited role — it provides an interface for saving and retrieving aggregates while hiding the details of storage. It does not know what business operation is taking place, does not understand the use-case context, and does not decide which changes must be atomic. Its job — take an aggregate and write it to storage.
The service layer, by contrast, exists precisely to describe the system's use cases. It is here that what the user does from a business point of view is defined: placing an order, transferring money, registering, updating a profile. These scenarios often involve several steps, touch several aggregates, and require that the entire operation either complete in full or not happen at all. In other words, it is here that the notion of atomicity emerges as a business requirement. Therefore the boundaries of a transaction naturally coincide with the boundaries of a use case, which means they must be managed at the service level.
When a transaction is placed inside the repository, it starts to reflect not a business operation but a separate technical action — for example, a single call to the save method. In simple cases this creates no problems if what's involved is saving a single aggregate as a whole. Imagine a user changing their email. The entire operation is confined to a single User aggregate, and saving that aggregate can indeed be performed atomically inside the repository method. In such a scenario, a local transaction inside the repository does not violate the model, because the boundary of the aggregate and the boundary of the transaction coincide.
As soon as a use case touches more than one aggregate, however, the situation changes. For example, creating an order and withdrawing funds from an account — these are already two different entities of the domain, two aggregates, each with its own repository. If each repository opens and completes its own transaction independently, the business operation splits into two independent transactions. If an error occurs between them, the system ends up in an inconsistent state: the order may be created while the money is not withdrawn, or vice versa. This is already a violation of the domain's invariants, and fixing it after the fact is far harder than correctly defining the transaction boundaries from the start.

This is precisely why, in classic DDD, transactions are treated as part of the application layer. The service opens a transaction, calls the necessary methods of the domain model and repositories, and then either commits the changes or rolls them back on error. In this scheme the repositories remain “dumb” from the standpoint of business logic, while the service manages the integrity of the operation. This makes the system predictable and easier to maintain, because any developer can look at the service and immediately understand where an atomic business operation begins and where it ends.
Sometimes the confusion arises because modern ORMs and libraries implement the Unit of Work pattern and manage transactions themselves when persisting entities. This can create the impression that transactions “live” in the repository. In practice, though, this is merely a technical implementation of the commit mechanism, and the decision about when to start and end a transaction still has to be made at the service or application level. The infrastructure can help, but it must not define the boundaries of the business operation.
In DDD (Domain-Driven Design) transactions — are not about storing data but about managing the consistency of business operations. So the right place for them — is the application level (Application / Service Layer), not the repository layer.
Transactions should be opened and managed at the service layer (Application Service)
Not in repositories
In DDD there is a separation of responsibilities:
Responsible only for data access
Knows nothing about business operations
Must not manage transactions
Its job: save(), find(), delete()
Orchestrates the business operation
Calls several repositories
Manages the transaction's lifecycle
This is exactly where it is clear:
where the business operation begins
where it ends
what must be atomic
Suppose there is a use case:
“Create an order and withdraw money from an account”
This is one business operation → one transaction.
class OrderService {
public function createOrder(CreateOrderCommand $cmd) {
$this->transactionManager->begin();
try {
$order = Order::create($cmd->data);
$this->orderRepository->save($order);
$this->paymentService->charge($cmd->userId, $cmd->amount);
$this->transactionManager->commit();
} catch (\Throwable $e) {
$this->transactionManager->rollback();
throw $e;
}
}
}
class OrderRepository {
public function save(Order $order)
{
$this->beginTransaction(); // bad
// save
$this->commit();
}
}
Why it's bad:
if 2 aggregates need to be saved, there will be 2 separate transactions
atomicity is violated
the integrity of the business operation cannot be managed
In DDD there is a rule:
One transaction = one aggregate (ideally)
But in practice:
sometimes an operation touches several aggregates
then the service coordinates everything within a single transaction
Sometimes the following are acceptable:
implemented at the infrastructure level
but used from the service layer
then the transaction is confined to a single aggregate
everything else goes through events

It is acceptable if:
the operation works with only one aggregate
and it is an atomic save()
for example:
UserRepository::save(user)
BUT as soon as:
there are several aggregates
there are several repositories
there is a business process
the transaction must move up — into the service
Let's work through a concrete example so this becomes completely clear.
In DDD an aggregate (Aggregate) — is a cluster of entities with a single root (Aggregate Root) that guarantees consistency.
Example of an aggregate:
Order (Aggregate Root) ├── OrderItem ├── ShippingAddress └── PaymentInfo
All of this — is a SINGLE Order aggregate
It means:
You save one aggregate in its entirety
and this happens as a single action (a single transaction)
A user updates their profile
This is:
1 aggregate → User
one operation → save(user)
class User {
private string $email;
private string $name;
public function changeEmail(string $email) {
// domain rules
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new DomainException("Invalid email");
}
$this->email = $email;
}
}
class UserRepository {
public function save(User $user) {
$this->db->transaction(function() use ($user) {
// UPDATE users SET ...
});
}
}
here everything is fine, because:
a single aggregate is saved
the operation is atomic
there is no coordination with other repositories
class UserService {
public function changeEmail(int $userId, string $email)
{
$user = $this->repo->find($userId);
$user->changeEmail($email);
$this->repo->save($user);
}
}
Create an order and withdraw money
This is already:
the Order aggregate
the Account aggregate
two aggregates
$orderRepo->save($order); // transaction 1 $accountRepo->withdraw($money); // transaction 2
if the second one fails — the order has already been created
$this->transaction->begin(); $orderRepo->save($order); $accountRepo->withdraw($money); $this->transaction->commit();
transaction at the service level
if:
1 aggregate is involved
it is a single save operation
if:
several aggregates
several repositories
a business process (use-case)
UserRepository::save(user)
OrderRepository::save(order)
CartRepository::save(cart)
create an order + withdraw money
register a user + send an email + create a profile
transfer money between accounts
if the operation is simply saving a single aggregate as a whole,
then the transaction can be encapsulated inside the repository
But:
if the operation is a business process,
then the transaction must be at the service layer level
✔ In DDD, transactions are placed at the service level (Application Layer)
✔ Repositories should be dumb (CRUD)
✔ The service manages the atomicity of the business operation
Thus, the answer to the question of where to place transactions depends on exactly what we want to protect with their boundaries. If it is a matter of simply saving a single aggregate, it is acceptable to encapsulate the technical transaction inside the repository. But as soon as a full-fledged business scenario appears, involving several actions or aggregates, the transaction must be raised to the service layer level and cover the entire use case. This is consistent with the core principle of DDD: technical details are subordinate to the domain model, not the other way around.
In the end, a simple and practical rule can be formulated: a transaction must coincide with the boundary of a business operation. And the place where a business operation is described in DDD — is the service layer. That is precisely why transactions should be managed there in most real systems.
Comments