Golang Inversion of Control Design Pattern

Posted by Henry Du on Sunday, January 9, 2022

Golang Inversion of Control IoC

Introduction

Inversion of Control (IoC) is a programming design pattern which follows Dependency Inversion principle (DIP). It achieves decoupling the control logic and business logic. For instance, we normally will add control logic in the business logic, because it is very common in terms of human beings thinking logic. For example, we may add toggle-switch object when implement light bulb. We may also implement customized toggle-switch when making another electric device. The toggle-switch depends on the kind of electric device. Then, the various toggle-switches are made. The inversion of control is to have a generic toggle-switch with the well defined interface, the various kind of electric devices will depends on the generic toggle-switch to implement.

The IoC separates business logic (what to do) and control logic (when to do). The one example is the event handling. The event handler defines the business logic what to do. The the raised event triggers control when to do. Basically anything with the event loop, callbacks and chains of execution fall into this design pattern.

Example: Simple DB Commit and Rollback

Assuming we have a very simple key-value database. It requires to support database transactions by Commit() and Rollback().

  • Commit() will permanently apply all the DB commands.
  • Rollback() will undo all the DB commands.

This example is very preliminary to demonstrate how we can apply IoC/DIP into the practice. It assumes that the Commit() and Rollback() will not fail.

In order to decouple the control logic and business logic, the concrete database object embeds two objects: commit and rollback. It applies Golang delegation design pattern that commit and rollback objects delegate database object’s control logic.

type rollback []func()

type commit []func()

type db struct {
	data map[string]int
	rb   rollback
	cm   commit
}

The basic database interface:

type DB interface {
    // Get the value for the given key, set 'ok' to true if key exists.
	Get(key string) (int, bool)

    // Set the key to given value.
	Set(key string, val int)

    // Unset the key, making it to the last commit.
	Unset(key string)

    // Commit all DB commands, permanently apply the changes made in them.
	Commit()

    // Rollback undoes all of the DB commands issued in the recent commit. 
	Rollback()
}

func NewDB() DB {
	return &db{
		data: make(map[string]int),
		rb:   make([]func(), 0),
		cm:   make([]func(), 0),
	}
}

The Commit Control

The Commit control should decouple with the DB operations from either Set() or Unset().

func (c *commit) register(fn func()) {
	*c = append(*c, fn)
}

func (c *commit) clear() {
	fns := *c
	*c = fns[:0]
}

func (c *commit) execute() {
	fns := *c

	// execute all DB commands in the one transaction
	for _, fn := range fns {
		fn()
	}

	// assuming commit successful, clear commit list
	c.clear()
}

The Rollback Control

The Rollback control should decouple with the DB unset operations.

func (r *rollback) register(fn func()) {
	*r = append(*r, fn)
}

func (r *rollback) clear() {
	fns := *r
	*r = fns[:0]
}

func (r *rollback) execute() {
	fns := *r
	for _, fn := range fns {
		fn()
	}
	r.clear()
}

The DB Business Logic

Let’s define the Get method

func (d *db) Get(key string) (int, bool) {
	if val, ok := d.data[key]; ok {
		return val, ok
	}
	return 0, false
}

When we Set a key-value to DB, we actually register several functions to commit and rollback objects. Let them to delegate the Set operation to control real commit or rollback.

func (d *db) Set(key string, val int) {
	// register set command to the commit object
	d.cm.register(func() {
		d.set(key, val)
	})

	// for the existing key-val, register set current value the the rollback object
	// otherwise, register delete function to the rollback object
	curVal, has := d.Get(key)
	if has {
		d.rb.register(func() {
			d.set(key, curVal)
		})
	} else {
		d.rb.register(func() {
			delete(d.data, key)
		})
	}
}

func (d *db) set(key string, val int) {
	d.data[key] = val
}

When we Unset a key-value to DB, we actually register a delete command to commit object. For the rollback operation, it should be reversed to the last commit. The set command is registered to rollback object.

func (d *db) Unset(key string) {
	// register unset command to the commit object
	d.cm.register(func() {
		delete(d.data, key)
	})

	// register set command to the rollback object to set previous value
	// do nothing if the key-value doesn't exist
	curVal, has := d.Get(key)
	if has {
		d.rb.register(func() {
			d.set(key, curVal)
		})
	}
}

Finally, we could define Commit and Rollback for DB object.

func (d *db) Commit() {
	d.cm.execute()
}

func (d *db) Rollback() {
	d.rb.execute()
}

Unit Test

We could use the following unit test to verify the the code above.

func TestCommitRollback(t *testing.T) {
	tc := require.New(t)

	data := map[string]int{
		"a": 10,
		"b": 20,
	}

	db := NewDB()

	// Test Case 1: set the first entry, without commit, it is not written to DB.
	db.Set("a", data["a"])
	_, ok := db.Get("a")
	tc.False(ok)

	// Test Case 2: set another entries, then commit.
	db.Set("b", data["b"])
	db.Commit()

	a, ok := db.Get("a")
	tc.True(ok)
	tc.Equal(data["a"], a)
	b, ok := db.Get("b")
	tc.True(ok)
	tc.Equal(data["b"], b)

	// Test Case 3: unset key "a", after commit, there is only key "b" in the DB.
	db.Unset("a")
	db.Commit()
	_, ok = db.Get("a")
	tc.False(ok)
	b, ok = db.Get("b")
	tc.True(ok)
	tc.Equal(data["b"], b)

    // Test Case 4: after rollback,
	// - revert Unset("a") the key "a" is back to the DB.
	// - revert Set("b"), the key "b" should be gone
	db.Rollback()
	a, ok = db.Get("a")
	tc.True(ok)
	tc.Equal(data["a"], a)
    _, ok = db.Get("b")
	tc.False(ok)
}

I used a simple key-value DB Commit and Rollback operation to demonstrate the Inversion of Control design pattern. Basically, the control logic commit and rollback do not depend on key-value DB. Rather, when DB wants to commit or rollback, it depends on the control logic. We could reuse the same control logic for other DB object, such as the document DB. It only needs to register the different DB commands to the commit and rollback objects. The control logic doesn’t care the business logic. It eventually realizes the decoupling of control logic and business logic.

Reference

  1. Inversion of Control Wiki