You get a bonus - 1 coin for daily activity. Now you have 1 coin

Transactions in DDD: Repositories or Services?

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.

Transactions in DDD: Repositories or Services?

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.

Short answer

Transactions should be opened and managed at the service layer (Application Service)
Not in repositories

Why the service layer specifically?

In DDD there is a separation of responsibilities:

1. Repository

  • Responsible only for data access

  • Knows nothing about business operations

  • Must not manage transactions

Its job: save(), find(), delete()

2. Service layer (Application Service)

  • 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

Example

Suppose there is a use case:
“Create an order and withdraw money from an account”

This is one business operation → one transaction.

The right approach (transaction in the service)

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;
  }
}
 } 

The bad approach (transactions in the repository)

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

How this relates to aggregates

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

Exceptions

Sometimes the following are acceptable:

Unit of Work

  • implemented at the infrastructure level

  • but used from the service layer

Domain Events + eventual consistency

  • then the transaction is confined to a single aggregate

  • everything else goes through events

The resulting architecture

Transactions in DDD: Repositories or Services?

When it is acceptable to put transactions in the repository in a DDD context

It is acceptable if:

  1. the operation works with only one aggregate

  2. 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.

What does it mean

“the operation works with only one aggregate”

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

What does it mean

“atomic save()”

It means:

You save one aggregate in its entirety
and this happens as a single action (a single transaction)

Example — an acceptable case of a transaction in the repository

Use-case:

A user updates their profile

This is:

  • 1 aggregate → User

  • one operation → save(user)

Aggregate

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;
 }
} 

Repository (with a local transaction — acceptable)

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

Service

class UserService {
    public function changeEmail(int $userId, string $email)
    {
     $user = $this->repo->find($userId);
     $user->changeEmail($email);
     $this->repo->save($user);
     }
 }
the service does not manage the transaction, because it is already atomic

An example of when this CANNOT be done

Use-case:

Create an order and withdraw money

This is already:

  • the Order aggregate

  • the Account aggregate

two aggregates

The bad option (transactions in the repositories)

$orderRepo->save($order);
 // transaction 1
$accountRepo->withdraw($money);
 // transaction 2 

if the second one fails — the order has already been created

The right option

$this->transaction->begin();
$orderRepo->save($order);
$accountRepo->withdraw($money);
$this->transaction->commit(); 

transaction at the service level

A simple rule

Allowed in the repository

if:

  • 1 aggregate is involved

  • it is a single save operation

Not allowed in the repository

if:

  • several aggregates

  • several repositories

  • a business process (use-case)

More examples

allowed in the repository

  • UserRepository::save(user)

  • OrderRepository::save(order)

  • CartRepository::save(cart)

not allowed — needed in the service

  • create an order + withdraw money

  • register a user + send an email + create a profile

  • transfer money between accounts

Conclusions

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.

See also

  • DDD
  • OOP
  • OOD

Comments

To leave a comment

If you have any suggestion, idea, thanks or comment, feel free to write. We really value feedback and are glad to hear your opinion.
To reply

Lectures and tutorial on "Object oriented programming"

Terms: Object oriented programming