# Handling Database Transactions Across Multiple Operations in Go: A Clean Architecture Approach

> Master Go database transactions across multiple operations. Learn a clean architecture approach using repository interfaces to keep business logic database-agnostic. Optimize your Go apps today.

- Repository: [manato/golang-clean-architecture](https://github.com/manakuro/golang-clean-architecture)
- Tags: how-to-guide
- Published: 2026-03-06

---

**Use a repository interface with a Transaction method that accepts a callback, deferring commit/rollback logic to the adapter layer while keeping business logic database-agnostic.**

The `manakuro/golang-clean-architecture` repository demonstrates how to manage atomic database operations in Go without coupling your core logic to specific ORM implementations. By encapsulating transaction semantics in the adapter layer, you can execute multiple repository operations atomically while maintaining the inversion-of-control principle central to Clean Architecture.

## Transaction Abstraction in the Domain Layer

The domain layer defines a minimal contract for transaction management through the `DBRepository` interface. This abstraction ensures that use-case code remains ignorant of GORM internals or any specific database driver details.

### Defining the DBRepository Interface

In [`pkg/usecase/repository/db.go`](https://github.com/manakuro/golang-clean-architecture/blob/main/pkg/usecase/repository/db.go), the interface requires only a single `Transaction` method:

```go
type DBRepository interface {
    Transaction(func(interface{}) (interface{}, error)) (interface{}, error)
}

```

This signature accepts a callback function that receives a generic `interface{}` representing the transaction handle, allowing the implementation to pass any database-specific transaction object while the business logic treats it as an opaque dependency.

## Concrete Implementation in the Adapter Layer

The adapter layer provides the concrete implementation using GORM's transaction API. This is where the actual transaction lifecycle management—begin, commit, and rollback—occurs.

### GORM Transaction Wrapper

The `dbRepository` struct in [`pkg/adapter/repository/db.go`](https://github.com/manakuro/golang-clean-architecture/blob/main/pkg/adapter/repository/db.go) implements the `Transaction` method to wrap database operations in atomic units:

```go
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
}

```

The implementation uses a **deferred function** to guarantee transaction cleanup. If the callback panics, the deferred block recovers the panic, rolls back the transaction, and re-panics. If the callback returns an error, it rolls back; otherwise, it commits. This ensures data consistency even when operations fail unexpectedly.

## Orchestrating Transactions in Use Cases

Use-case code orchestrates multiple repository operations by wrapping them inside the transaction callback. This approach keeps business logic focused on workflow while delegating persistence concerns to the adapter.

### Wrapping Multiple Operations

The user creation flow in [`pkg/usecase/usecase/user.go`](https://github.com/manakuro/golang-clean-architecture/blob/main/pkg/usecase/usecase/user.go) demonstrates this pattern:

```go
func (uu *userUsecase) Create(u *model.User) (*model.User, error) {
    data, err := uu.dBRepository.Transaction(func(i interface{}) (interface{}, error) {
        // i is the *gorm.DB transaction
        u, err := uu.userRepository.Create(u)
        // additional steps (mailing, logging, etc.) could also use the same tx
        return u, err
    })
    // type-assert the result back to the domain model
    user, ok := data.(*model.User)
    if !ok {
        return nil, errors.New("cast error")
    }
    return user, err
}

```

The use-case receives the transaction object as `interface{}` and delegates to repositories that internally type-assert it back to `*gorm.DB`. If any step returns an error, the deferred rollback in the adapter aborts the entire operation, preserving atomicity.

## Extending Transactions Across Multiple Repositories

To perform multiple distinct repository actions within a single transaction—such as creating a user and an audit log—extend the callback to share the transaction handle across repositories:

```go
data, err := dbRepo.Transaction(func(tx interface{}) (interface{}, error) {
    txDB := tx.(*gorm.DB)                    // type-assert to GORM DB
    user, err := userRepo.WithTx(txDB).Create(u) // repo that accepts a TX
    if err != nil { return nil, err }
    // another repo using the same transaction
    if err := auditRepo.WithTx(txDB).LogCreation(user.ID); err != nil {
        return nil, err
    }
    return user, nil
})

```

Repositories that support transactions typically expose a `WithTx` method or constructor that accepts the `*gorm.DB` instance, ensuring all operations execute against the same underlying database transaction.

## Dependency Injection and Wiring

The registry layer wires concrete implementations into the use-case layer, maintaining the Clean Architecture dependency rule where inner layers depend on abstractions, not concrete implementations.

In [`pkg/registry/user.go`](https://github.com/manakuro/golang-clean-architecture/blob/main/pkg/registry/user.go), the `dbRepository` is injected alongside the domain-specific repository:

```go
u := usecase.NewUserUsecase(
    repository.NewUserRepository(r.db),
    repository.NewDBRepository(r.db), // <-- transaction provider
)

```

This configuration ensures the use-case receives a fully configured `DBRepository` capable of managing transactions, while remaining decoupled from GORM-specific initialization code found in [`pkg/infrastructure/datastore/db.go`](https://github.com/manakuro/golang-clean-architecture/blob/main/pkg/infrastructure/datastore/db.go).

## Summary

- **Abstract transaction management** through a minimal `DBRepository` interface in the domain layer to keep business logic database-agnostic.
- **Implement transaction semantics** in the adapter layer using deferred functions to handle panics, errors, and commits automatically.
- **Orchestrate atomic operations** by wrapping multiple repository calls in a callback passed to the `Transaction` method.
- **Share transaction handles** across repositories by passing the concrete database transaction object (e.g., `*gorm.DB`) through the generic `interface{}` callback parameter.
- **Wire dependencies** through the registry layer to maintain inversion of control and keep core logic free from infrastructure concerns.

## Frequently Asked Questions

### How does the Transaction method handle panics?

The `Transaction` implementation in [`pkg/adapter/repository/db.go`](https://github.com/manakuro/golang-clean-architecture/blob/main/pkg/adapter/repository/db.go) uses a deferred function that recovers from panics, executes a rollback to ensure database consistency, and then re-panics to maintain the original error propagation. This prevents partial commits when unexpected runtime errors occur.

### Why use interface{} instead of *gorm.DB in the callback signature?

The `interface{}` type in the `DBRepository` interface decouples the domain layer from GORM-specific types. This allows the core business logic to remain ignorant of the underlying persistence technology, making it possible to swap GORM for another ORM or database driver without modifying use-case code.

### Can this pattern work with other ORMs besides GORM?

Yes. While the concrete implementation in [`pkg/adapter/repository/db.go`](https://github.com/manakuro/golang-clean-architecture/blob/main/pkg/adapter/repository/db.go) uses GORM's `Begin()`, `Rollback()`, and `Commit()` methods, you can implement the same `DBRepository` interface using any ORM or raw `database/sql` package. Simply adapt the transaction lifecycle methods to match your chosen library's API while maintaining the same callback-based interface.

### How do I ensure multiple repositories use the same transaction?

Pass the transaction handle received in the callback (e.g., `tx.(*gorm.DB)`) to each repository method that needs to participate in the transaction. Repositories should expose methods or constructors that accept the transaction object, such as `WithTx(*gorm.DB)`, ensuring all database operations execute within the same atomic unit managed by the deferred commit/rollback logic.