In this example we are going to use Redis transactions by utilising Client.Watch feature. It is same logic as the Atomicity principle of database transactions - "either all occur, or nothing occurs". In our case, if one command fails all the others fail too.


We have a bank system where an account holder might have multiple accounts. When we create an account holder, we will try to create accounts as well. However, if something goes wrong along the line, we will not create anything at all. We are going to create 3 different hashes. One for the root element Holder. The other two for the Account elements.


Structure


{
"ID": "144c8dcb89dc293f55c68cc74adda88b",
"FirstName": "Al",
"MiddleName": "",
"LastName": "Pacino",
"Accounts": [
{
"Type": "Savings",
"Number": "20202020",
"SortCode": "20-20-20",
"Active": false
},
{
"Type": "Current",
"Number": "10101010",
"SortCode": "10-10-10",
"Active": true
}
]
}

Application layout


├── docker-compose.yaml
└── internal
├── domain
│   ├── account
│   │   ├── account.go
│   │   ├── cache.go
│   │   └── holder.go
│   └── repository
│   └── account.go
└── storage
├── account.go
└── account_test.go

Files


docker-compose.yaml


version: "3"

services:
redis:
image: redis:6.0.6-alpine
command: redis-server --requirepass pass
ports:
- 6379:6379

internal/domain/account/cache.go


package account

import "fmt"

func CacheHashRootKey(holderID string) string {
return "app:holder:" + holderID
}

func CacheHashHolderField() string {
return "holder"
}

func CacheHashAccountField(accountType Type) string {
return fmt.Sprintf("account:%s", accountType)
}

internal/domain/account/holder.go


package account

import "encoding/json"

type Holder struct {
ID string
FirstName string
MiddleName string
LastName string
Accounts []*Account
}

func (h *Holder) MarshalBinary() ([]byte, error) {
return json.Marshal(h)
}

func (h *Holder) UnmarshalBinary(data []byte) error {
if err := json.Unmarshal(data, &h); err != nil {
return err
}

return nil
}

internal/domain/account/account.go


package account

import "encoding/json"

type Type string

const (
TypeCurrent Type = "Current"
TypeSavings Type = "Savings"
)

type Account struct {
Type Type
Number string
SortCode string
Active bool
}

func (a *Account) MarshalBinary() ([]byte, error) {
return json.Marshal(a)
}

func (a *Account) UnmarshalBinary(data []byte) error {
if err := json.Unmarshal(data, &a); err != nil {
return err
}

return nil
}

internal/domain/repository/account.go


package repository

import (
"context"

"github.com/inanzzz/cache/internal/domain/account"
)

type Account interface {
// Create creates a new account holder with/without actual accounts.
Create(ctx context.Context, holder account.Holder) error
}

internal/storage/account.go


You can purposely remove MarshalBinary and UnmarshalBinary methods in the structs to make transactions fail in order to reproduce rollbacks.


package storage

import (
"context"
"fmt"

"github.com/inanzzz/cache/internal/domain/account"
"github.com/go-redis/redis/v8"
)

type Account struct {
rds *redis.Client
}

func NewAccount(rds *redis.Client) Account {
return Account{rds: rds}
}

func (a Account) Create(ctx context.Context, holder account.Holder) error {
accounts := holder.Accounts
// We do not want the inner structures within the parent because we will store them separately.
holder.Accounts = nil

err := a.rds.Watch(ctx, func(tx *redis.Tx) error {
// You can run more commands here. You will use `tx` though.
// e.g. tx.HGET()
// Note: `tx` is not part of transactional `pipe` below so any "SET" operation will be independent.

_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
// 1- Set holder
if _, err := pipe.HSetNX(
ctx,
account.CacheHashRootKey(holder.ID),
account.CacheHashHolderField(),
&holder,
).Result(); err != nil {
return fmt.Errorf("create: holder: %w", err)
}
//tx.Expire()
//

// 2- Set accounts within holder
for _, acc := range accounts {
if _, err := pipe.HSetNX(
ctx,
account.CacheHashRootKey(holder.ID),
account.CacheHashAccountField(acc.Type),
acc,
).Result(); err != nil {
return fmt.Errorf("create: account: %w", err)
}
//pipe.Expire()
}
//

return nil
})

return err
})
if err != nil {
return fmt.Errorf("create: transaction: %w", err)
}

return nil
}

internal/storage/account_test.go


package storage

import (
"context"
"testing"

"github.com/inanzzz/cache/internal/domain/account"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
)

func TestAccount_Create(t *testing.T) {
rds := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "pass",
})

storage := NewAccount(rds)

holder := account.Holder{
ID: "144c8dcb89dc293f55c68cc74adda88b",
FirstName: "Al",
MiddleName: "",
LastName: "Pacino",
Accounts: []*account.Account{
{
Type: account.TypeCurrent,
Number: "10101010",
SortCode: "10-10-10",
Active: true,
},
{
Type: account.TypeSavings,
Number: "20202020",
SortCode: "20-20-20",
Active: false,
},
},
}

assert.NoError(t, storage.Create(context.Background(), holder))
}

Redis CLI


localhost:6379> KEYS *
1) "app:holder:144c8dcb89dc293f55c68cc74adda88b"

localhost:6379> HGETALL app:holder:144c8dcb89dc293f55c68cc74adda88b
1) "account:Savings"
2) "{\"Type\":\"Savings\",\"Number\":\"20202020\",\"SortCode\":\"20-20-20\",\"Active\":false}"
3) "holder"
4) "{\"ID\":\"144c8dcb89dc293f55c68cc74adda88b\",\"FirstName\":\"Al\",\"MiddleName\":\"\",\"LastName\":\"Pacino\",\"Accounts\":null}"
5) "account:Current"
6) "{\"Type\":\"Current\",\"Number\":\"10101010\",\"SortCode\":\"10-10-10\",\"Active\":true}"