Transactions in Go Hexagonal Architecture

Khaled Karam
The Qonto Way
Published in
6 min readAug 3, 2023

Qonto has hundreds of microservices collaborating with each other to deliver our product’s features to our customers. To keep each service’s domain fully separated from those of external systems, we chose to employ an appropriate architectural pattern: hexagonal architecture using Go. You can read more about hexagonal architecture in this article written by Alistair Cockburn, one of its co-creators.

The image below provides a simple visual presentation of this hexagonal architecture.

Aiming to use this pattern across our applications, Qonto has faced certain challenges. In this article, we’ll revisit how we achieved atomic operations involving databases while still maintaining the separation between domain and database layers. Let’s begin by presenting a use case that clarifies the problem.

Use case: cashing checks

Cashing checks is one way Qonto’s customers can deposit money into their accounts. To do this, the first step is to register the check in our app, filling in information including the account number and deposit amount.

Check registration triggers an HTTP request to one of our microservices. Upon receiving this request, the responsible application must persist a record in the database and then publish a message through Kafka. Our core banking system is responsible for consuming this message and crediting the user’s account.

We want the application to either succeed in both persisting the record and publishing the message, or for neither thing to happen. Failing to achieve that means opening our systems to inconsistent states and nasty bugs.

To develop our solution, we’ll start from a barebones application and a straightforward (albeit fallible) implementation of a service method. This both publishes a message and persists a record in the database:

// Domain model
// The business logic will interact with this object
type Cheque struct {
Code string
}

// Interface used to publish messages to Kafka
// Will be made accessible to our service through dependency injection
type Producer interface {
Publish(ctx context.Context, msg map[string]string) error
}

// Interface used to persist models in a relational database
// Will be made accessible to our service through dependency-injection
type ChequeRepo interface {
Create(ctx context.Context, chq Cheque) error
}

// Service contains all operations related to business domain
type Service struct {
producer Producer
chequeRepo ChequeRepo
}

// Method to be called when handling incoming HTTP request
func (s *Service) Register(ctx context.Context, chq Cheque) error {
if err := s.chequeRepo.Create(ctx, chq); err != nil {
return err
}

// ⚠ If this fails, a record will be persisted but no message will be published
return s.producer.Publish(ctx, map[string]string{"event": "created"})
}

Once this is done, we’ve established a common ground to start getting into it further. But first, we need to picture how we want the final solution to look. Explicitly, we want:

  • No hidden magic.
  • The database interaction must remain an abstraction: a Go interface.
  • No data leakage between repositories.
  • To ensure the business domain is the responsible domain for deciding whether an operation is atomic.

Domain service implementation: first draft

From the service perspective, we want the repository to implement a practical interface for bundling different procedures in a single atomic operation. The repository contract gains a new Atomic method:

type AtomicCallback = func(r ChequeRepo) error

type ChequeRepo interface {
...
Atomic(ctx context.Context, chq AtomicCallback) error
}

Notice how the service is then responsible for requesting an atomic operation with a set of procedures, defined in the callback. All of the other concerns, like commits and rollbacks, are left for the repository to implement.

Here’s what our service method looks like now:

func (s *Service) Register(ctx context.Context, chq Cheque) error {
// saving the cheque and publishing message are executed atomically
return s.chequeRepo.Atomic(ctx, func(r ChequeRepo) error {
if err := r.Create(ctx, chq); err != nil {
return err
}

return s.producer.Publish(ctx, map[string]string{"event": "created"})
})
}

This is looking good. However, a new business requirement came in: we also need to persist an event alongside the check. We’ll need to extend that signature to add an EventRepo .

Second draft

We want to avoid the scenario in which either the ChequeRepo or the EventRepo are responsible for an atomic operation that persists both kinds of records. So, we need to define a wrapper for all repositories:

type AtomicCallback = func(r Repository) error

type Repository interface {
Atomic(context.Context, AtomicCallback) error
Cheques() ChequeRepo
Events() EventRepo
}

type Service struct {
producer Producer
repository Repository
}

func (s *Service) Register(ctx context.Context, chq Cheque) error {
// saving the cheque, event, and publishing message are done atomically
return s.repository.Atomic(ctx, func(r Repository) error {
if err := r.Cheques().Create(ctx, chq); err != nil {
return err
}

event := map[string]string{"event": "created"}
if err := r.Events().Create(ctx, event); err != nil {
return err
}

return s.producer.Publish(ctx, event)
})
}

From the service perspective, this interface is easy to use and does not leak repository concerns into the business logic. What’s the best way to implement that interface?

Repository implementation

To keep things simple, we’ll focus on relational databases only in this section and use the standard database/sql library.

For Repository to be able to create a transaction on the repositories it wraps, we need the ability to initialize them with a *sql.DB or *sql.Tx. Since both structs don’t share an interface, we need to define an interface with the shared method between them.

To make it clear, Store will be the name of the implementation of the repositories defined in the service:

// DB interface with the shared method between sql.DB and sql.Tx
type DB interface {
QueryRow(query string, args ...any) *sql.Row
}

type ChequeStore struct {
db DB
}

func NewChequeStore(db DB) *ChequeStore {
return &ChequeRepo{
db: db,
}
}

Finally, we need to implement DataStore and its Atomic method

type DataStore struct {
db *sql.DB
chequeStore *ChequeStore
eventStore *EventStore
}

func NewDataStore(db *sql.DB) *DataStore {
return &DataStore{
db: db,
chequeStore: NewChequeStore(db),
eventStore: NewEventStore(db),
}
}

func (ds *DataStore) ChequeStore() ChequeRepo {
return ds.chequeStore
}

func (ds *DataStore) EventStore() EventRepo {
return ds.eventStore
}

// withTx creates new repository instances with *sql.Tx to
// commit and rollback the operations based on the return of the atomic callback.
func (ds *DataStore) withTx(tx *sql.Tx) *DataStore {
newDataStore := NewDataStore(ds.db)
newDataStore.chequeStore = NewChequeStore(tx)
newDataStore.eventStore = NewEventStore(tx)
return newDataStore
}

// Atomic rolls back the operations if the callback returns an error
func (dr *DataStore) Atomic(ctx context.Context, cb func(ds Datastore) error) (err error) {
tx, err := dr.db.BeginTx(ctx, nil)
if err != nil {
return err
}

defer func() {
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
err = fmt.Errorf("tx err: %v, rb err: %v", err, rbErr)
}
} else {
err = tx.Commit()
}
}()

dataStoreTx := dr.WithTx(tx)
err = cb(dataStoreTx)
return
}

The trick is that the database operation is committed or rolled back based on whether the callback is returning an error. This method does not take panics into account, but it’s easy to be implemented with recover() if needed.

In the end, we managed to enforce atomic database operations while adhering to the hexagonal architecture concepts. We avoided leaking technical details into our domain logic, and, above all, there’s no magic included.

This ensured a smooth recovery after incidents. Rerunning dead letters or retrying HTTP requests becomes a no-brainer when there’s no residue from the previous failed run.

About Qonto

Qonto is a finance solution designed for SMEs and freelancers founded in 2016 by Steve Anavi and Alexandre Prot. Since our launch in July 2017, Qonto has made business financing easy for more than 250,000 companies.

As a business owner, you’ll save time thanks to Qonto’s streamlined account set-up, an intuitive day-to-day user experience with unlimited transaction history, accounting exports, and a practical expense management feature.

Stay in control while being able to give your teams more autonomy via real-time notifications and a user-rights management system.

Benefit from improved cash-flow visibility by means of smart dashboards, transaction auto-tagging, and cash-flow monitoring tools.

Lastly, enjoy stellar customer support at a fair and transparent price.

Interested in joining a challenging and game-changing company? Consult our job offers!

--

--