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?
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.
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
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?
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:
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With