17

I'm trying to follow Clean Architecture using Go. The application is a simple image management application.

I'm wondering how to best design the interfaces for my Repository layer. I don't want to combine all repository methods into one single big interface, like some examples I found do, I think in Go small interfaces are usually preferred. I don't think the usecase code concerning managing images needs to know that the repository also stores users. So I would like to have UserReader, UserWriter and ImageReader and ImageWriter. The complication is that the code needs to be transactional. There is some debate where transaction management belongs in Clean Architecture, but I think the usecase-layer needs to be able to control transactions. What belongs in a single transaction, I think, is a business rule and not a technical detail.

Now the question is, how to structure the interfaces?

Functional approach

So in this approach, I open a transaction, run the provided function and commit if there are no errors.

type UserRepository interface {
    func ReadTransaction(txFn func (UserReader) error) error
    func WriteTransaction(txFn func (UserWriter) error) error
}

type ImageRepository interface {
    func ReadTransaction(txFn func (ImageReader) error) error
    func WriteTransaction(txFn func (ImageWriter) error) error
}

Problems: No I can't easily write a user and an image in a single transaction, I would have to create an extra UserImageRepository interface for that and also provide a separate implementation.

Transaction as repository

type ImageRepository interface {
    func Writer() ImageReadWriter
    func Reader() ImageReader
}

I think this would be rather similar to the functional approach. It wouldn't solve the problem of combined use of multiple repositories, but at least would make it possible by writing a simple wrapper.

An implementation could look like this:

type BoltDBRepository struct {}
type BoltDBTransaction struct { *bolt.Tx }
func (tx *BoltDBTransaction) WriteImage(i usecase.Image) error
func (tx *BoltDBTransaction) WriteUser(i usecase.User) error
....

Unfortunately, If I implement the transaction methods like this:

func (r *BoltDBRepository) Writer() *BoltDBTransaction
func (r *BoltDBRepository) Reader() *BoltDBTransaction

because this does not implement the ImageRepository interface, so I'd need a simple wrapper

type ImageRepository struct { *BoltDBRepository }
func (ir *ImageRepository) Writer() usecase.ImageReadWriter
func (ir *ImageRepository) Reader() usecase.ImageReader

Transaction as a value

type ImageReader interface {
    func WriteImage(tx Transaction, i Image) error
}

type Transaction interface { 
    func Commit() error
}

type Repository interface {
    func BeginTransaction() (Transaction, error)
}

and a repository implementation would look something like this

type BoltDBRepository struct {}
type BoltDBTransaction struct { *bolt.Tx }

// implement ImageWriter
func (repo *BoltDBRepository) WriteImage(tx usecase.Transaction, img usecase.Image) error {
  boltTx := tx.(*BoltDBTransaction)
  ...
}

Problems: While this would work, I have to type assert at the beginning of each repository method which seems a bit tedious.

So these are the approaches I could come up with. Which is the most suitable, or is there a better solution?

tobiasH
  • 411
  • 5
  • 11
  • If you have to assert the type, the Transaction interface is incomplete. – Peter Aug 19 '18 at 08:21
  • @Peter It has to be "incomplete", because the interface should not contain references to the database implementation, e.g. `bolt.Tx` – tobiasH Aug 19 '18 at 11:02
  • I don't follow. You have to make all the methods you need to call part of the interface. Otherwise, what's the point of the interface? – Peter Aug 19 '18 at 11:14
  • To the usecase layer, the transaction is basically a token that it has to hand to the repository layer to do something. It might also be `interface{}`, I just gave it a name for clarity. The repository will create and accept tokens that are appropriate for the underlying database system. – tobiasH Aug 19 '18 at 12:42
  • is this question really go lang specific? in other questions here on stackoverflow regarding transactions and clean architecture a "common recommendation" is the "unit of work" pattern. maybe that is of help in ur case as well? – plainionist Feb 05 '19 at 18:48

3 Answers3

6

Repository is a representation of a place that keep your datas, so is an architectural element.

Transaction is a technical detail that resolve a non-functional requisit (atomic operations), so it must be used like an internal reference or private function in architectural element.

In this case, if your repository was written like:

type UserRepository interface {
    func Keep(UserData) error
    func Find(UUID) UserData
}

type ImageRepository interface {
    func Keep(ImageData) error
    func Find(UUID) ImageData
}

Transactional approach is an implementation details, so you can create an "implementation" of UserRepository and ImageRepository that is being used like an internal reference.

type UserRepositoryImpl struct {
    Tx Transaction
}

func (r UserRepository) func Keep(UserData) error { return r.Tx.On(...)} 
func (r UserRepository) func Find(UUID) UserData { return r.Tx.WithResult(...)}

In this way you can keep user and image in a single transaction too.

For example, if a client has references to userRepository and imageRepository and if it is responsible of userData and imageData and it also desires to keep both data on single transaction then:

//open transaction and set in participants
tx := openTransaction()
ur := NewUserRepository(tx)
ir := NewImageRepository(tx)
//keep user and image datas
err0 := ur.Keep(userData)
err1 := ir.Keep(imageData)
//decision
if err0 != nil || err1 != nil {
  tx.Rollback()
  return
}
tx.Commit()

This is clean, objective, and work fine in Onion Architecture, DDD and 3-layers architecture(Martin Fowler)!

In Onion Architecture:

  • Entities: user and image (without business rules)
  • Usecase: repository interface (application rules: keep user and image)
  • Controller: A/N
  • DB/Api: client, tx, repositories implementations
charly3pins
  • 389
  • 1
  • 11
Aristofanio Garcia
  • 1,103
  • 8
  • 13
  • 1
    Thanks for the answer! I think that's basically similiar to my "Transaction as value" approach, but moving the transaction to a field of the repository struct reduces the number of type assertions necessary. In which layer would you open/commit the transactions? Because I still think that the usecase layer should be able to control transaction scope, even if the actual transaction itself is a technicality. – tobiasH Aug 19 '18 at 12:38
  • Following Clean Architecture Usercase Layer don't is enabled to reference transaction (DB Layer). So open/commit/rollback must be in externalest layer. – Aristofanio Garcia Aug 19 '18 at 13:15
  • 2
    Well that's where we seem to have a difference of opinion. I think for a lot of applications you can probably get by by wrapping the transaction around the whole of the usecase execution, but there are cases where more control is required, and then I believe it's a business layer decision which operations need to be atomic. That also seems be [Uncle Bob's view (search for "transaction" on the page)](http://blog.cleancoder.com/uncle-bob/2016/01/04/ALittleArchitecture.html). – tobiasH Aug 19 '18 at 16:44
  • Architectural design can be build in several ways and to solve several problems, but you say: Clean Architecture. I suppose you want to work on these concepts. If it is not to work with these concepts you can place transational control anywhere. If it is to work with these concepts the location is at the most external layer. Because the dependency rules of this architecture, there is no internal control that can not be done externally. Anyway, I just answered your question based on the concepts of this architecture. Thanks. – Aristofanio Garcia Aug 20 '18 at 08:21
  • I know this question is old but on what layer exactly should they be written in DDD ? what would be an external layer to the repository and service in this case ? because usually the db calls would be sent from the service layer. could you develop a little ? You'd make as a new layer between service and repo ? – jayD Jun 01 '20 at 17:56
  • DDD is not an architecture of fact, but an systematic approach that contains architectural principles. Eric Evans use Layers Architecture to describe theses principles. In this case, Repository is splitted in contract and implementation. The contract is a domain element and the implementation is infrastructure. Transaction is a transversal element and can be allocated in Application Layer, that is the place of implementation of the use cases. – Aristofanio Garcia Jun 02 '20 at 02:57
0

if you repo must keep some state fields

type UserRepositoryImpl struct {
    db Transaction
    someState bool
}

func (repo *UserRepositoryImpl) WithTx(tx Transaction) *UserRepositoryImpl {
    newRepo := *repo
    repo.db = tx
    return &newRepo
}

func main() {
    repo := &UserRepositoryImpl{ 
        db: connectionInit(),
        state: true,
    }

    repo.DoSomething()

    tx := openTransaction()
    txrepo := repo.WithTx(tx)

    txrepo.DoSomething()
    txrepo.DoSomethingElse()
}
0

Keep the Repositories as they are, do not try to solve the transactional API idea there. You need a separate repository registry to control how your repositories will be initialized and how they behave; atomic operations and e.t.c. Here's a good example:

file: internal/repository/registry.go

package repository

import (
    "context"

    "github.com/kataras/app/image"
)

type TransactionFunc = func(Registry) error

type Registry interface {
    NewImageRepository() image.Repository
    // more repo initialization funcs...

    InTransaction(context.Context, func(Registry) error) error
}

file: internal/repository/registry/postgres.go

package registry

import (
    "context"
    "fmt"

    "github.com/kataras/app/image"
    "github.com/kataras/app/internal/repository"

    "github.com/kataras/pg" // your or 3rd-party database driver package.
)

type PostgresRepositoryRegistry struct {
    db *pg.DB
}

var _ repository.Registry = (*PostgresRepositoryRegistry)(nil)

func NewPostgresRepositoryRegistry(db *pg.DB) *PostgresRepositoryRegistry {
    return &PostgresRepositoryRegistry{
        db: db,
    }
}

func (r *PostgresRepositoryRegistry) NewImageRepository() image.Repository {
    return image.NewPostgresRepository(r.db)
}


// The important stuff!
func (r *PostgresRepositoryRegistry) InTransaction(ctx context.Context, fn repository.TransactionFunc) (err error) {
    if r.db.IsTransaction() {
        return fn(r)
    }

    var tx *pg.DB
    tx, err = r.db.BeginDatabase(ctx)
    if err != nil {
        return
    }

    defer func() {
        if p := recover(); p != nil {
            _ = tx.RollbackDatabase(ctx)
            panic(p)
        } else if err != nil {
            rollbackErr := tx.RollbackDatabase(ctx)
            if rollbackErr != nil {
                err = fmt.Errorf("%w: %s", err, rollbackErr.Error())
            }
        } else {
            err = tx.CommitDatabase(ctx)
        }
    }()

    newRegistry := NewPostgresRepositoryRegistry(tx)
    err = fn(newRegistry)

    return
}

Now, at your domain service level you can just inject a repository.Registry, e.g. PostgresRepositoryRegistry.

file: internal/service/image_service.go

package service

import (
    "context"

    "github.com/kataras/app/internal/repository"
)

type ImageService struct {
    registry repository.Registry
}

func NewImageService (registry repository.Registry) *ImageService {
    return &ImageService {
        registry: registry ,
    }
}

func (s *ImageService) DoSomeWork(ctx context.Context, ...) error {
    images := s.registry.NewImageRepository()
    images.DoSomeWork(ctx, ...)
}

// Important stuff!
func (s *ImageService) DoSomeWorkInTx(ctx context.Context, inputs [T]) error {
    return s.registry.InTransaction(ctx, func(r repository.Registry) error) {
        images := r.NewImageRepository()
        for _, in := range inputs {
            if err := images.DoSomeWork(); err!=nil {
                  return err // rollback.
            }
        }

        return nil
    }

}

Use the ImageService at your APIs routes.

db, err := pg.Open(...)
// handleError(err)
repoRegistry := registry.NewPostgresRepositoryRegistry(db)
imageService := service.NewImageService(repoRegistry)

// controller := &MyImageController{Service: imageService}

You can use Iris for dependnecy injection.

kataras
  • 835
  • 9
  • 14