Building a bank
The purpose of this howto is to demonstrate how to model data using GoshawkDB. The main point is to avoid thinking "how would I do this in an SQL database with tables?" and instead think "if my program never needed to be restarted and so was the data store, how would I model this?". The code examples in this howto are all in Go, using the Go client for GoshawkDB. Please make sure you've read the guide to the Go client.
The code in this Howto, along with the other Howtos are available in the examples repository.
In this tutorial we're going to model a bank which has customer accounts, and transfers can occur, moving money from one account to another. The history of all transfers involving an account will be available to the customer. Because GoshawkDB supports the strong-serializability isolation level, transactions that transfer funds from one account to another are guaranteed to be safe: money cannot go missing or be invented. In data stores that support only isolation levels weaker than strong-serializability, the same transactions may not be safe.
Our root object is going to be our bank. It will contain no value, but have references (pointers) to all the customer accounts the bank has. To create the bank, we just need to ensure that the root object is empty:
package bank
import (
"encoding/json"
"errors"
"fmt"
"goshawkdb.io/client"
"time"
)
type Bank struct {
conn *client.Connection
objRef client.ObjectRef
}
func CreateBank(conn *client.Connection) (*Bank, error) {
result, _, err := conn.RunTransaction(func(txn *client.Txn) (interface{}, error) {
roots, err := txn.GetRootObjects()
if err != nil {
return nil, err
}
rootObj, found := roots["myRoot1"]
if !found {
return nil, errors.New("No root 'myRoot1' found")
}
return rootObj, rootObj.Set([]byte{})
})
if err != nil {
return nil, err
}
return &Bank{
conn: conn,
objRef: result.(client.ObjectRef),
}, nil
}
Note that we capture the ObjectRef of the root object in
the Bank
value. Thus if in the future we have
a bank not located at the root then we won't need
to change any code outside this function.
Customer accounts will have a value that contains an
account number, a customer name and a current balance. A
customer account will also have pointers to transfers that
have occurred that either debit or credit the customer
account. For simplicity, we'll use Go's JSON encoding,
hence the earlier import
of
"encoding/json"
.
type Account struct {
Bank *Bank
objRef client.ObjectRef
*account
}
type account struct {
Name string
AccountNumber uint
Balance int
}
func (b *Bank) AddAccount(name string) (*Account, error) {
acc := &account{
Name: name,
Balance: 0,
}
accObjRef, _, err := b.conn.RunTransaction(func(txn *client.Txn) (interface{}, error) {
bankObj, err := txn.GetObject(b.objRef)
if err != nil {
return nil, err
}
accounts, err := bankObj.References()
if err != nil {
return nil, err
}
acc.AccountNumber = uint(len(accounts))
accValue, err := json.Marshal(acc)
if err != nil {
return nil, err
}
accObjRef, err := txn.CreateObject(accValue)
if err != nil {
return nil, err
}
return accObjRef, bankObj.Set([]byte{}, append(accounts, accObjRef)...)
})
if err != nil {
return nil, err
}
return &Account{
Bank: b,
objRef: accObjRef.(client.ObjectRef),
account: acc,
}, nil
}
The account number is just the current number of accounts
at the time when we create the account: we're not
supporting deleting accounts at the moment. We use
the account
type to facilitate the conversion
to and from JSON, whilst the Account
is the
public type. The public Account
type exposes
the customer name and account number. This is only safe
because we do not envisage either of these details
changing: they are immutable. If they could change then it
would not be safe to make these fields public, and instead
you'd want to add methods that run transactions to
retrieve the account details from GoshawkDB.
We also need to add a method to get an account by account number: when a customer comes into our bank we expect them to know their own account number. Because the account number is the index into the list of references from our root bank object, this is very simple.
func (b *Bank) GetAccount(accNum uint) (*Account, error) {
acc := &account{}
result, _, err := b.conn.RunTransaction(func(txn *client.Txn) (interface{}, error) {
bankObj, err := txn.GetObject(b.objRef)
if err != nil {
return nil, err
}
if accounts, err := bankObj.References(); err != nil {
return nil, err
} else if accNum < uint(len(accounts)) {
accObjRef := accounts[accNum]
accValue, err := accObjRef.Value()
if err != nil {
return nil, err
}
return accObjRef, json.Unmarshal(accValue, acc)
} else {
return nil, fmt.Errorf("Unknown account number: %v", accNum)
}
})
if err != nil {
return nil, err
}
return &Account{
Bank: b,
objRef: result.(client.ObjectRef),
account: acc,
}, nil
}
We're going to support both cash deposits and transfers,
but to keep it simple, a cash deposit is just a transfer
which has no from account. So a transfer will have
a value containing the date of the transfer, the amount
transferred, and references to the account transferring
from and to. Usefully, the
Go Time
type supports the
JSON Marshaler
interface. The error handling
makes this a bit long, and there's a little duplication
which could probably be factored out, but hopefully the
following is clear, if slightly verbose:
type transfer struct {
Time time.Time
Amount int
}
type Transfer struct {
objRef client.ObjectRef
From *Account
To *Account
*transfer
}
func (dest *Account) TransferFrom(src *Account, amount int) (*Transfer, error) {
if src != nil {
if src.AccountNumber == dest.AccountNumber {
return nil, fmt.Errorf("Transfer is from and to the same account: %v", src.AccountNumber)
}
if !src.Bank.objRef.ReferencesSameAs(dest.Bank.objRef) {
return nil, fmt.Errorf("Transfer is not within the same bank!")
}
}
t := &transfer{Amount: amount}
result, _, err := dest.Bank.conn.RunTransaction(func(txn *client.Txn) (interface{}, error) {
t.Time = time.Now() // first, let's create the transfer object
transferValue, err := json.Marshal(t)
if err != nil {
return nil, err
}
destAccObjRef, err := txn.GetObject(dest.objRef)
if err != nil {
return nil, err
}
// the transfer has at least a reference to the destination account
transferReferences := []client.ObjectRef{destAccObjRef}
transferObj, err := txn.CreateObject(transferValue, transferReferences...)
if err != nil {
return nil, err
}
destReferences, err := destAccObjRef.References() // now we must update the dest account
if err != nil {
return nil, err
}
// append our transfer to the list of destination transfers
destReferences = append(destReferences, transferObj)
destValue, err := destAccObjRef.Value()
if err != nil {
return nil, err
}
destAcc := &account{}
if err = json.Unmarshal(destValue, destAcc); err != nil {
return nil, err
}
destAcc.Balance += t.Amount // destination is credited the transfer amount
destValue, err = json.Marshal(destAcc)
if err != nil {
return nil, err
}
if err = destAccObjRef.Set(destValue, destReferences...); err != nil {
return nil, err
}
if src != nil { // if we have a src, we must update the source account
srcAccObjRef, err := txn.GetObject(src.objRef)
if err != nil {
return nil, err
}
srcReferences, err := srcAccObjRef.References()
if err != nil {
return nil, err
}
// append our transfer to the list of source transfers
srcReferences = append(srcReferences, transferObj)
srcValue, err := srcAccObjRef.Value()
if err != nil {
return nil, err
}
srcAcc := &account{}
if err = json.Unmarshal(srcValue, srcAcc); err != nil {
return nil, err
}
srcAcc.Balance -= t.Amount // source is debited the transfer amount
if srcAcc.Balance < 0 {
// returning an error will abort the entire transaction.
return nil, fmt.Errorf("Account %v has insufficient funds.", src.AccountNumber)
}
srcValue, err = json.Marshal(srcAcc)
if err != nil {
return nil, err
}
if err = srcAccObjRef.Set(srcValue, srcReferences...); err != nil {
return nil, err
}
// there is a source so add a ref from the transfer to the source account
transferReferences = append(transferReferences, srcAccObjRef)
if err = transferObj.Set(transferValue, transferReferences...); err != nil {
return nil, err
}
}
return transferObj, nil
})
if err != nil {
return nil, err
}
return &Transfer{
objRef: result.(client.ObjectRef),
From: src,
To: dest,
transfer: t,
}, nil
}
Finally, let's make the API of the bank a bit nicer. Here, we'll take advantage nested transaction too to compose some of our above APIs. By using nested transactions, we ensure that the entire operation is atomic and strongly-serialized with regards to all other operations going on in the bank at the same time.
func (b *Bank) CashDeposit(accNum uint, amount int) (*Transfer, error) {
result, _, err := b.conn.RunTransaction(func(txn *client.Txn) (interface{}, error) {
account, err := b.GetAccount(accNum)
if err != nil {
return nil, err
}
return account.TransferFrom(nil, amount)
})
if err != nil {
return nil, err
}
return result.(*Transfer), nil
}
func (b *Bank) TransferBetweenAccounts(srcAccNum, destAccNum uint, amount int) (*Transfer, error) {
result, _, err := b.conn.RunTransaction(func(txn *client.Txn) (interface{}, error) {
srcAccount, err := b.GetAccount(srcAccNum)
if err != nil {
return nil, err
}
destAccount, err := b.GetAccount(destAccNum)
if err != nil {
return nil, err
}
return destAccount.TransferFrom(srcAccount, amount)
})
if err != nil {
return nil, err
}
return result.(*Transfer), nil
}
Hopefully this has shown some ideas of how to model data in GoshawkDB. As is often the case, the error handling in Go adds a fair amount of noise, and there is probably a little refactoring to be done to shrink this down further. The final use of nested transactions is powerful as it shows how small and simple APIs can be combined and composed and not risk violating atomicity. At no point did we think about tables and whilst there is more code and complexity than if we were just using Go structs alone, the object model seems to be sensible and natural.
There are a couple of brief extensions you might like to try as exercises:
- Currently we have an API to create a new empty bank,
but we have no way of resuming a bank that we'd
already created. Add an API that assumes that the bank
is in the root object and returns the
correct
*Bank
object. - Add a method to
*Account
calledActivity
that iterates through all the transfers involving the account (i.e. found in the account references) and returns a[]*Transfer
. You'll want to add methods totransfer
to extract the destination and source objects from the transfer object. Then write aString
method on*Transfer
that helpfully formats the transfer, correctly distinguishing an inter-account transfer from a cash deposit. - Use the Collections Library to store accounts rather than just a straight list. Then you add support for removal too. You might want to switch to using some form of UUId as the account number.