DEV Community

Nguyễn Long
Nguyễn Long

Posted on

Handling Transactions Without Breaking Service-Adapter Decoupling in Hexagonal Architecture with Golang

Handling Transactions Without Breaking Service-Adapter Decoupling in Hexagonal Architecture with Golang

Introduction

When building applications in Go using hexagonal architecture (also known as Ports and Adapters), maintaining a clear separation between the service layer and the adapter layer is essential.

One challenge that developers often face is managing database transactions within this architecture without violating service-adapter decoupling.
I will walk you through a common problem of transaction handling and how to refactor your Go code to solve it.

Problem: Transactions and Decoupling

  • In hexagonal architecture, the service (business logic) should not be aware of infrastructure details such as transaction management.
  • The service layer should focus purely on business rules and defer infrastructure operations like database interactions to the adapter layer.
  • Transaction handling, like db.Begin(), often ends up being tightly coupled to the service layer, leading to design that violates the principle of separation.

Consider the following example, where transaction logic leaks into the service layer:

// UserService (Service Layer)
type UserService struct {
    userRepo port.UserRepo
    logRepo  port.LogRepo
}

func (u *UserService) CreateUser(ctx context.Context, user domain.User) error {
    // Transaction begins in the adapter
    tx := u.userRepo.BeginTransaction(ctx)

    // Create user
    createdUser, err := u.userRepo.CreateUser(ctx, user)
    if err != nil {
        tx.Rollback()
        return err
    }

    // Create task for the user
    task, err := u.userRepo.CreateTask(ctx, createdUser)
    if err != nil {
        tx.Rollback()
        return err
    }

    // Log the task
    err = u.logRepo.LogTask(ctx, task)
    if err != nil {
        tx.Rollback()
        return err
    }

    // Commit transaction if all steps succeed
    return tx.Commit()
}
Enter fullscreen mode Exit fullscreen mode

This example is a basic operation for for User and Task Management System. Here is the break down:

  • First, user will be created
  • After user created, some Task instances will create for that user
  • After task creation, the system will log the task for the user.

At this point we may noticed that if any of this task fail, then it should be rollback, otherwise, the actions will be commited.

But, What's Wrong with This Approach?

  • At first glance while this might seem functional, the CreateUser method in the service layer is managing the transaction (BeginTransaction, Rollback, Commit), which should be the responsibility of the adapter layer.
  • Handling transactions directly in the service? -> violate the decoupling principle. -> harder to maintain and scale your code, as the service layer now knows too much about the infrastructure

A Better Solution: Apply Unit of work pattern

The Unit of Work pattern groups multiple operations into a single transaction and ensures that all changes are committed or rolled back as a unit. The Unit of Work itself can manage the transaction across repositories. It acts as a coordinator for all database changes that happen during a business operation.

type UnitOfWork struct {
    db *sql.DB
}

func (uow *UnitOfWork) Begin(ctx context.Context) (context.Context, error) {
    tx, err := uow.db.Begin()
    if err != nil {
        return ctx, err
    }
    uow.tx = tx
    return context.WithValue(ctx, TransactionKey, tx), nil
}

func (uow *UnitOfWork) Commit() error {
    return uow.tx.Commit()
}

func (uow *UnitOfWork) Rollback() error {
    return uow.tx.Rollback()
}

Enter fullscreen mode Exit fullscreen mode

In this case, the UnitOfWork is passed around to each repository, ensuring all operations within a transaction are coordinated. The service layer simply calls the Unit of Work to start and complete transactions.

UserService now will be:

func (u *UserService) CreateUser(ctx context.Context, user domain.User) error {
    uow := UnitOfWork{}
    ctx, err := uow.Begin(ctx, u.db)
    if err != nil {
        return err
    }

    createdUser, err := u.userRepo.CreateUser(ctx, user)
    if err != nil {
        uow.Rollback()
        return err
    }

    task, err := u.userRepo.CreateTask(ctx, createdUser)
    if err != nil {
        uow.Rollback()
        return err
    }

    err = u.logRepo.LogTask(ctx, task)
    if err != nil {
        uow.Rollback()
        return err
    }

    return uow.Commit()
}
Enter fullscreen mode Exit fullscreen mode

Repo Layer: It will get the transaction from the context:

// UserRepoImpl (Adapter Layer)
type UserRepoImpl struct {
    db *sql.DB
}

func (u *UserRepoImpl) CreateUser(ctx context.Context, user domain.User) (domain.User, error) {
    // Use the transaction from context
    tx := ctx.Value(TransactionKey).(*sql.Tx)
    // Execute user creation within the transaction
    result, err := tx.Exec("INSERT INTO users ...")
    if err != nil {
        return domain.User{}, err
    }
    // Logic to create and return the new user
    return createdUser, nil
}

func (u *UserRepoImpl) CreateTask(ctx context.Context, user domain.User) (domain.Task, error) {
    tx := ctx.Value(TransactionKey).(*sql.Tx)
    // Execute task creation within the transaction
    result, err := tx.Exec("INSERT INTO tasks ...")
    if err != nil {
        return domain.Task{}, err
    }
    return task, nil
}
Enter fullscreen mode Exit fullscreen mode

By making use of callback and pass transaction via context, We can get the benefits:

  • Service layer no longer handles transaction management. It simply focuses on business logic.
  • All transaction-related concerns are handled in the repository (adapter) layer, making it easier to change or extend the infrastructure layer without affecting the service.
  • The Unit Of Work method can be reused across different service methods that require transaction handling, reducing code duplication.
  • Easier to test because it’s not tied to the database context.

Conclusion

Decoupling transaction management from the service layer and moving it to the adapter layer helps you adhere to the principles of hexagonal architecture. This refactor not only keeps the layers clean and independent but also improves the maintainability and scalability of your application. In Golang, this approach can be easily implemented using contexts to pass transactions and callback functions to execute business logic within the transaction scope.

Ref:

Top comments (0)