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 &registry{
        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.go decouples use cases from specific database implementations like GORM.
  • Panic recovery and rollback in pkg/adapter/repository/db.go ensures data consistency even when unexpected runtime errors occur during transaction execution.
  • Closure-based transaction execution allows use cases in pkg/usecase/usecase/user.go to group multiple repository operations atomically while remaining infrastructure-agnostic.
  • Dependency injection through pkg/registry/registry.go wires transaction-capable repositories into use cases without hard-coding dependencies.
  • Testability is achieved by mocking the DBRepository interface, 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →