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()
}
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()
}
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()
}
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
}
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)