Substate
Substate provides the gensubstate
tool which generates implementations of Dependency Injection interfaces.
Installation
Run go install github.com/nickcorin/substate/cmd/gensubstate
.
NOTE: Make sure that your $GOBIN
has been added to $PATH
.
What are "Dependency Injection Interfaces"?
Consider that you're writing an application that contains some global state object which holds your client dependencies. It could look something like this:
// state.go
package state
type State struct {
fooClient foo.Client // Some interface to communicate with Foo.
barClient bar.Client // Some interface to communicate with Bar.
}
func New() (*State, error) {
var s State
...
fooClient, err := foo.NewClient()
if err != nil {
return nil, err
}
s.fooClient = fooClient
...
return s, nil
}
func (s *State) FooClient() foo.Client {
return s.fooClient
}
...
Providing these dependencies to your packages might look something like this:
// main.go
package main
import (
"log"
"project/bar"
"project/foo"
"project/state"
)
func main() {
s, err := state.New()
if err != nil {
log.Fatal(err)
}
if err := foo.InteractWithBar(s); err != nil {
log.Fatal(err)
}
if err := bar.InteractWithFoo(s); err != nil {
log.Fatal(err)
}
}
// foo.go
package foo
import (
"project/state"
)
func InteractWithBar(s *state.State) error {
return s.BarClient().Ping()
}
// bar.go
package bar
import (
"project/state"
)
func InteractWithFoo(s *state.State) error {
return s.FooClient().Ping()
}
The problem with this is that you're providing a FooClient
to the foo
package and a BarClient
to the bar
package. You've also introduced a plethora of transitive dependencies to each package - by importing the state
package you're also effectively importing anything that it imports. This import list can become quite large since the state
package will end up importing all your application's dependencies.
Additionally, to unit test this package you will need to import state
and things can quickly become quite convoluted.
Introducing Substate
.
In each sub-package that would normally require state
, define a Substate
interface which contains a subset of the accessor methods implemented on State
.
// substate.go
package foo
import (
"project/bar"
)
//go:generate gensubstate
type Substate interface {
BarClient() bar.Client
}
// foo.go
package foo
func InteractWithFoo(s Substate) error {
return s.BarClient().Ping()
}
You have now removed the need to import state
anywhere within foo
, while not needing to change the code in the main
package since the methods in Substate
are a subset of the methods implemented on the global State
struct.
Great! But what about testing?
Using the gensubstate
tool, running go generate
on the foo
package generates this file:
// substate_gen.go
// Code generated by gensubstate at foo/substate.go; DO NOT EDIT.
package foo
import (
"testing"
"project/bar"
)
// NewSubstateForTesting returns an implementation of Substate which can be used
// for testing.
func NewSubstateForTesting(_ *testing.TB, injectors ...Injector) *substate {
var s substate
for _, injector := range injectors {
injector.Inject(&s)
}
return &s
}
type Injector interface {
Inject(*substate)
}
// InjectorFunc defines a convenience type making it easy to implement
// Injectors.
type InjectorFunc func(*substate)
// Inject implements the Injector interface.
func (fn InjectorFunc) Inject(s *substate) {
fn(s)
}
// WithBarClient returns an Injector which sets the barclient on substate.
func WithBarClient(barClient BarClient) InjectorFunc {
return func(s *substate) {
s.barClient = barClient
}
}
type substate struct {
barClient bar.Client
}
// BarClient implements the Substate interface.
func (s *substate) BarClient() bar.Client {
return s.barClient
}
This might look quite confusing at first, that's okay!
Essentially, an unexposed struct is generated which implements your Substate
interface along with some utility functions.
Let's see what a unit test might look like using this:
// foo_test.go
package foo_test
import (
"testing"
"github.com/stretchr/testify"
"project/foo"
"project/bar"
)
func TestInteractWithBar(t *testing.T) {
mockClient := bar.NewMockClient()
// Provide the Mock client to Substate using the generated InjectorFunc.
s := foo.NewSubstateForTesting(t, foo.WithBarClient(mockClient))
// You can now test InteractWithBar!
err := foo.InteractWithBar(s)
require.NoError(t, err)
}