Clean architecture and transactions in Golang

I want to narrate my approach to using transactions with repository patterns using clean architecture.

Problem

In a classic service, we have use cases separately with repositories. Use cases know nothing about databases and storage. Repositories don't know about business logic. So, which layer should raise a transaction?

I suppose the most reliable and convenient layer for transactions is a use case. Use cases may know about the necessity to run transaction. And it doesn't matter for a use case if transactions really work in repositories.

And how should we work with transactions in a variety of repositories in one use case? Let's start with a simple example. Imagine that we have pages with likes. For pages we use one repository and for like another one. And we want to remove likes when the page is removed.

package repository

type PageRepository interface {
    Delete(id int64) error
}

type LikeRepository interface {
    DeleteByPageId(id int64) error
}

In our use case, we want to delete all likes after the page has been deleted.

package example

type MySuperService struct {
   pageRepository repository.PageRepository
   likeRepository repository.LikeRepository
}

func (m *MySuperService) DeletePage(id int64) error {
    if err := m.likeRepository.DeleteByPageId(id); err != nil {
       return err
    }
    if err := m.pageRepository.Delete(id); err != nil {
        return err
    }
    return nil
}

Looks good, but where are transactions? And which of the repositories should start a transaction?

Solution

This solution is suitable when all repositories use the same databases. We add a field to the service's struct. It will be used for transaction management and for running transactions by calling this field's method BeginTx.

package example

type MySuperService struct {
   transaction    transaction.Transactioner
   pageRepository repository.PageRepository
   likeRepository repository.LikeRepository
}

func (m *MySuperService) DeletePage(id int64) error {
    // Start transaction
    tx, err := m.transaction.BeginTx(ctx)
    if err != nil {   
        return fmt.Errorf(`transaction error %w`, err)
    }
    // Execute query
    if err := tx.LikeRepository().DeleteByPageId(id); err != nil {
       if err := tx.Rollback(ctx); err != nil {
          return fmt.Errorf(`rollback error %w`, err)
       }
       return fmt.Errorf(`can't remove likes %w`, err)
    }
    // Execute next query
    if err := tx.PageRepository().Delete(id); err != nil {
       if err := tx.Rollback(ctx); err != nil {
          return fmt.Errorf(`rollback error %w`, err)
       }
       return fmt.Errorf(`can't remove page %w`, err)
    }
    // All done, commit!
    if err := tx.Commit(ctx); err != nil {
        return fmt.Errorf(`commit error %w`, err)
    }
    return nil
}

We have added a new dependency in our service and then could use it for transaction management. Service calls transaction, but service knows nothing about the repository layer's internals such as working with a database. Transaction interface looks like an abstraction for a database and should implement methods to work with transaction. And the main idea in this approach is methods in transaction struct which returns repositories with already started transaction.

package transactionimport 

type Transactioner interface {
   BeginTx(ctx context.Context, opts ...*TxOptions) (Transaction, error)
}

type Transaction interface {
   Commit(ctx context.Context) error   
   Rollback(ctx context.Context) error
   
   // Methods return repositories with transaction
   LikeRepository() repository.LikeRepository
   PageRepository() repository.PageRepository
}

Any database could be used for transaction management with this approach to transaction interface. It's easy to use and maintain.

Transactioner interface has only one method to start transaction and return Transaction interface that has methods for repositories for commit and rollback.

But if we use a variety of independent storage at the repository level, there should be another solution. Just clean and simple!