DEV Community

Atsushi Miyamoto
Atsushi Miyamoto

Posted on

Confused, Aggregate Transaction Boundaries in Domain-Driven Design

What is an Aggregate?

An aggregate is extracted as a unit to maintain invariant conditions and brings order to the operation of objects. It consists of boundaries and a root. The boundary of an aggregate defines what is included in the aggregate. The root of an aggregate is a specific object within it. All external operations on the aggregate are conducted via the aggregate root. By not exposing objects within the aggregate boundary to the outside, the invariant conditions within the aggregate are maintained.

It is reasonable to consider aggregates as units of the repository

e.g.


// aggregates
package repository

type UserRepository interface {
    Create(user entity.User) error
    Delete(userId int) error
}

Enter fullscreen mode Exit fullscreen mode

Transaction Boundaries

Scope of a Transaction.

In DDD, it is ideal to consider an aggregate should be the same as a transaction boundary.

Disadvantages of spanning transactions across aggregates:

  • Spanning a transaction across aggregates A and B expresses the start and end of the transaction in the application layer, obscuring the consistency level of each aggregate.
  • Technical changes (like ORM or storage changes) necessitate modifications in the use case/domain layer, leading to fragile code.
  • Spanning aggregates naturally widens the transaction scope, increasing the likelihood of db locks.

e.g.

This sample code hides the creation/commit/rollback of tx in infrastructure, but is fragile due to its dependency on the db client in the use case/domain layer. It is sort of anti-pattern. will explain later.

// Infrastructure layer

func (t *TransactionRepository) Do(ctx context.Context, runner func(tx *ent.Tx) error) error {
    // Start transaction
    tx, err := t.client.Tx(t.ctx)

    if err != nil {
        return err
    }
    err = runner(tx)

    if err != nil {
        // Rollback process
        if err := tx.Rollback(); err != nil {
            return err
        }
        return err
    }

    // Commit process
    if err := tx.Commit(); err != nil {
        return err
    }
    return nil


// Usecase layer
func (u *lessonUseCase) TransactionSample(ctx context.Context) error {
    // Has dependency with db client
    err := u.TransactionRepository.Do(ctx, func(tx *ent.Tx) error {

        // Transaction processing here
        newUser, err := userRepo.create(tx, ×××)
        if err != nil {
            return err
        }
        err := userGroupRepo.create(tx, newUser, ×××)

        return nil

    })

    if err != nil {
        return err
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

In real-world project, Of course, there are many challenging cases:

  • e.g. Creating a User and also wanting to update a Organization etc...

Solutions

1. Merge Aggregates

If crossing aggregates with tx is problematic, merge them together (as seen in some DDD books).

Advantages:

  • Maintains consistency within the aggregate Disadvantages:
  • Larger aggregates might lead to performance issues due to increased db locks
    • Merging different aggregates into one (e.g., A and B into C) complicates the code
    • Application logic becomes more infrastructure-dependent. Hard to debug and loos testability.

2. Eventual Consistency

Accept temporary data breaches.

Advantages:

  • No need to change aggregates - Transaction boundaries remain within existing aggregates, keeping the scope narrow

Disadvantages:

  • Temporary loss of consistency
  • Implementing measures for maintaining consistency can be cumbersome
  • Handling failures in mid-process (e.g. queue)
    • e.g. if creating a user succeeds but creating a organization fails, the user might be removed
  • Handling failures in recovery

3. Transaction Across Aggregates

This is an anti-pattern.

Advantages:

  • Maintains consistency
  • No need to modify aggregates

Disadvantages:

  • Fragile to changes
  • Wider transaction scope may lead to locks

Summary

This post delves into the intricacies of aggregate transaction boundaries within DDD, highlighting the balance between consistency, performance, and system design. Practical solutions and their trade-offs are discussed, providing insights for effective DDD implementation in real-world scenarios.

Honestly, I am still confused and don't know solution. It is really depends on you and your team.
Also I did not mention about modeling in this time. I think modeling is key factor to find better solution.

Happy coding!

Top comments (0)