Implementing Business Logic in Use Cases with Transaction Support in Go
In Clean Architecture, use cases implement business logic by delegating transaction management to an infrastructure-layer abstraction, ensuring atomic operations without coupling to specific database implementations.
Implementing business logic in use cases with transaction support is a critical pattern for maintaining data consistency in Clean Architecture applications. This article examines how the manakuro/golang-clean-architecture repository implements atomic transactions while keeping business rules isolated from infrastructure concerns.
Clean Architecture Layer Organization
The repository follows strict layer separation where transaction support spans multiple layers:
| Layer | Package | Responsibility | Key Interface / Struct |
|---|---|---|---|
| Domain | pkg/domain/model |
Pure data models (User) |
model.User |
| Use‑case | pkg/usecase/usecase |
Business logic (User use case) |
usecase.User interface & userUsecase struct |
| Use‑case – Repository contracts | pkg/usecase/repository |
Define contracts for persistence & transaction | UserRepository, DBRepository |
| Adapter (Repository implementation) | pkg/adapter/repository |
GORM‑based concrete implementations | userRepository, dbRepository |
| Infrastructure – Router / Datastore | pkg/infrastructure/* |
HTTP routing & DB connection | router.NewRouter, datastore.NewDB |
| Registry | pkg/registry |
Wire‑up dependencies (DI) | registry.NewRegistry |
The Transaction Abstraction Pattern
Defining the Contract in the Use Case Layer
Transaction capabilities are defined as an interface in the use-case layer to maintain independence from infrastructure details. In pkg/usecase/repository/db.go, the DBRepository interface declares the transaction contract:
type DBRepository interface {
Transaction(func(interface{}) (interface{}, error)) (interface{}, error)
}
This abstraction allows use cases to execute multiple repository operations atomically without knowing whether the underlying implementation uses GORM, SQLx, or another database driver.
Implementing Transaction Logic in the Infrastructure Layer
The concrete implementation resides in pkg/adapter/repository/db.go within the dbRepository struct. This implementation handles the complex transaction lifecycle including commit, rollback, and panic recovery:
func (r *dbRepository) Transaction(txFunc func(interface{}) (interface{}, error)) (data interface{}, err error) {
tx := r.db.Begin()
if tx.Error != nil { return nil, tx.Error }
defer func() {
if p := recover(); p != nil {
log.Print("recover")
tx.Rollback()
panic(p)
} else if err != nil {
log.Print("rollback")
tx.Rollback()
panic("error")
} else {
err = tx.Commit().Error
}
}()
data, err = txFunc(tx)
return data, err
}
This pattern ensures that any panic within the transaction closure triggers a rollback, while normal errors also abort the transaction before changes are persisted.
Implementing Business Logic in Use Cases with Transaction Support
The use case layer orchestrates business rules while delegating transaction management to the DBRepository abstraction. In pkg/usecase/usecase/user.go, the userUsecase implements the Create method:
func (uu *userUsecase) Create(u *model.User) (*model.User, error) {
data, err := uu.dBRepository.Transaction(func(i interface{}) (interface{}, error) {
// All DB operations inside this closure run inside a single transaction
u, err := uu.userRepository.Create(u)
// Additional business steps (mailing, logging, etc.) can be placed here
// ...
return u, err
})
// Cast and error handling omitted for brevity
...
}
This approach encapsulates all database operations within a transactional boundary while keeping the use case agnostic of GORM-specific transaction APIs.
Wiring Dependencies for Transactional Use Cases
The registry pattern in pkg/registry/registry.go injects the transaction-capable repository into use cases:
func NewRegistry(db *gorm.DB) *registry {
// Wire concrete repositories
userRepo := repository.NewUserRepository(db)
dbRepo := repository.NewDBRepository(db)
// Build the use‑case with transaction support
userUC := usecase.NewUserUsecase(userRepo, dbRepo)
return ®istry{
userUsecase: userUC,
// other controllers …
}
}
This dependency injection ensures that the use case receives both the specific UserRepository for data access and the generic DBRepository for transaction coordination.
Handling HTTP Requests with Transactional Use Cases
Controllers remain thin by delegating to use cases. In pkg/adapter/controller/user.go, the UserController invokes the transactional create method:
func (c *UserController) CreateUser(ctx echo.Context) error {
var u model.User
if err := ctx.Bind(&u); err != nil {
return ctx.JSON(http.StatusBadRequest, err)
}
created, err := c.usecase.Create(&u)
if err != nil {
return ctx.JSON(http.StatusInternalServerError, err)
}
return ctx.JSON(http.StatusCreated, created)
}
The controller handles HTTP-specific concerns (binding, status codes) while remaining completely unaware that the underlying use case executes within a database transaction.
Testing Transactional Business Logic
Unit tests mock the transaction abstraction to verify business logic without database dependencies:
func TestCreateUser_TransactionSuccess(t *testing.T) {
// Mock repositories
mockUserRepo := &MockUserRepository{}
mockDBRepo := &MockDBRepository{
TxFunc: func(fn func(interface{}) (interface{}, error)) (interface{}, error) {
// Directly invoke the closure without real DB
return fn(nil)
},
}
uc := usecase.NewUserUsecase(mockUserRepo, mockDBRepo)
// Set expectations on mockUserRepo.Create(...)
// ...
user := &model.User{Name: "Alice", Age: "30"}
created, err := uc.Create(user)
require.NoError(t, err)
require.Equal(t, "Alice", created.Name)
}
This testing strategy isolates business logic by simulating transaction behavior, allowing rapid feedback without requiring database infrastructure.
Summary
- Transaction abstraction in
pkg/usecase/repository/db.godecouples use cases from specific database implementations like GORM. - Panic recovery and rollback in
pkg/adapter/repository/db.goensures data consistency even when unexpected runtime errors occur during transaction execution. - Closure-based transaction execution allows use cases in
pkg/usecase/usecase/user.goto group multiple repository operations atomically while remaining infrastructure-agnostic. - Dependency injection through
pkg/registry/registry.gowires transaction-capable repositories into use cases without hard-coding dependencies. - Testability is achieved by mocking the
DBRepositoryinterface, enabling unit tests to verify transactional business logic without database connections.
Frequently Asked Questions
How does the use case layer remain independent of GORM when handling transactions?
The use case depends only on the DBRepository interface defined in pkg/usecase/repository/db.go, which accepts a generic function closure. The concrete GORM implementation in pkg/adapter/repository/db.go handles the actual transaction lifecycle, keeping GORM-specific APIs out of the use case layer.
What happens if a panic occurs inside a transaction closure?
The dbRepository.Transaction implementation includes a deferred recovery function that catches panics, executes tx.Rollback(), and then re-panics with the original value. This ensures that partial database changes are never committed when unexpected runtime errors occur.
Can multiple repository operations be combined in a single transaction?
Yes. The closure passed to Transaction in pkg/usecase/usecase/user.go can invoke multiple repository methods, perform validation, or trigger domain events. All operations within the closure execute within the same transactional boundary, committing only if the entire closure returns a nil error.
How do you test use cases that require transaction support without a real database?
Unit tests mock the DBRepository interface to immediately execute the provided closure without starting a real database transaction. This approach, demonstrated in the test example, verifies that the use case correctly orchestrates repository calls while remaining fast and isolated from infrastructure concerns.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →