Genjector: Reflection-free Run-Time Dependency Injection framework for Go 1.18+

I haven’t been writing much lately. It’s not that I wouldn’t like to do it, but it’s hard to get time off from a regular job. And being in a position to write some new code is even less realistic. It’s no surprise that I felt like Alice in Wonderland when I hit the keyboard last weekend.

I’ve been thinking for a while now about providing a dependency injection framework for Go that wouldn’t be based on reflection. Everything I tried before March of this year was a dead end.

Why March? Because in March, Google released a new version of Go, which allowed us to use Generics. So, this summer, I used my time at the beach, as any good software engineer should, to think about new possibilities for dependency injection.

I finally finished that mental exercise, and last weekend I got the chance to write it down. And, here it is — The Genjector Package. Got it? Generics and injector. Cute.

Some Ground Rules

Before I started writing the package, I had to decide on the minimum requirements I wanted to have for this package. After all, it would be foolish to invest time in something that has no meaningful purpose.

So, here is the shortlist:

  1. Direct use of Reflection is not allowed. Indirect use via the fmt package is possible, but not in a lucky scenario (so only for formatting errors).
  2. A package can only rely on the Go core library. The use of other external packages is not allowed (not even unit test helpers).
  3. The API must be clean, intuitive, and easy to use. (I know, it’s not a big ask.)
  4. The package must belong to the best-performing group of its peers. (An even easier request.)

Finally, after defining these rules, I could start the project. And to make sure that the content of the go.mod file stays the same until the end of the project:

Go
 
module github.com/ompluscator/genjector

go 1.18


Benchmark

To comply with the fourth requirement from the shortlist, I had to start with benchmark tests. I found a list of dependency injection frameworks for Go that support runtime injection.

The benchmark tests are located inside the _benchmark folder of the Genjector package. Here are the basic uses of the most commonly used packages that pass runtime criteria. And below, you can see the current results:

Shell
 
goos: darwin
goarch: amd64
pkg: github.com/ompluscator/genjector/_benchmark
cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz
Benchmark
Benchmark/github.com/golobby/container/v3
Benchmark/github.com/golobby/container/v3-8         	 2834061	       409.6 ns/op
Benchmark/github.com/goava/di
Benchmark/github.com/goava/di-8                     	 4568984	       261.9 ns/op
Benchmark/github.com/goioc/di
Benchmark/github.com/goioc/di-8                     	19844284	        60.66 ns/op
Benchmark/go.uber.org/dig
Benchmark/go.uber.org/dig-8                         	  755488	      1497 ns/op
Benchmark/flamingo.me/dingo
Benchmark/flamingo.me/dingo-8                       	 2373394	       503.7 ns/op
Benchmark/github.com/samber/do
Benchmark/github.com/samber/do-8                    	 3585386	       336.0 ns/op
Benchmark/github.com/ompluscator/genjector
Benchmark/github.com/ompluscator/genjector-8        	21460600	        55.71 ns/op
Benchmark/github.com/vardius/gocontainer
Benchmark/github.com/vardius/gocontainer-8          	60947049	        20.25 ns/op
Benchmark/github.com/go-kata/kinit
Benchmark/github.com/go-kata/kinit-8                	  733842	      1451 ns/op
Benchmark/github.com/Fs02/wire
Benchmark/github.com/Fs02/wire-8                    	25099182	        47.43 ns/op
PASS


During the implementation, I had to run the benchmark tests multiple times to always make sure that the results match the basic idea. Whenever I got results that fell outside the 20–80ms range (just personal choice, nothing reasonable), I would make the necessary changes to bring it down to the desired performance.

In the end, I can say that I am more than satisfied with the result. The Genjector package supports as many features as the packages with the most features on the list (such as Dingo) but is among the fastest. All packages that match its performance cover far fewer features.

I’m so proud I could cry.

Basic Usage

Therefore, when the fourth condition was met, it was only (“only”) about respecting the other three. And for that, I made a list of features that would be nice to have included in the package. Fortunately, they all ended up being part of the package:

And with this list, I wanted to close the first version of the package. In the following code examples, you can see how to use Genjector in practice.

Binding Pointers and Values to Interfaces

No matter how hard I tried, I was unable to create a proper instance of some generic type T if that type is a pointer to some type. At least no solution allows directly creating an instance of such a type without using reflection and without getting a nil value at the end.

Conversely, creating values (thus non-pointer structures) is as simple as it gets:

Go
 
package main

import "fmt"

func CreateWithNew[T any]() T {
	return *new(T)
}

func CreateWithDeclaration[T any]() T {
	var empty T
	return empty
}

func main() {
	fmt.Println(CreateWithNew[*int]())
	// Output: <nil>
	fmt.Println(CreateWithDeclaration[*int]())
	// Output: <nil>

	fmt.Println(CreateWithNew[int]())
	// Output: 0
	fmt.Println(CreateWithDeclaration[int]())
	// Output: 0
}


There are workable solutions to get a non-null pointer, but not without using reflection. Since it goes against the nature of this library, I decided to split pointer and value binding into two methods.

The first method is AsPointer, which uses two generic types. The first represents the type for which we want to define the binding. The second represents the type used as the concrete implementation for the binding.

Go
 
package main

import (
	"fmt"

	"github.com/ompluscator/genjector"
)

type IWeatherService interface {
	Temperature() float64
}

type WeatherService struct {
	value float64
}

func (s *WeatherService) Init() {
	s.value = 31.0
}

func (s *WeatherService) Temperature() float64 {
	return s.value
}

func main() {
	genjector.MustBind(genjector.AsPointer[IWeatherService, *WeatherService]())

	instance := genjector.MustNewInstance[IWeatherService]()

	fmt.Println(instance.Temperature())
	// Output: 31
}


We can see the example above. To bind an implementation (a pointer to WeatherService) to an interface (IWeatherService), we use the MustBind method. It just wraps the Bind method so it doesn’t return errors, but it panics when an error occurs.

The next call is a call to the MustNewInstance method, which returns a concrete instance of the bound interface (IWeatherService). Since the pointer to the WeatherService struct is defined as implementation, we will get an instance of that struct as the method’s response.

If the implementing structure (like WeatherService) has an Init method attached to this struct’s pointer, that method will be called when the instance is created, so it can initialize the struct’s fields (or even call the Genjector package to get other instances).

We should use binding by the pointer in case the structure pointer respects the interface (here, the Temperature method is attached to the WeatherService pointer). If this is already the case with the value, we can use another method, AsValue.

Go
 
package main

import (
	"fmt"

	"github.com/ompluscator/genjector"
)

type IUser interface {
	Token() string
}

type Anonymous struct {
	token string
}

func (s *Anonymous) Init() {
	s.token = "some JWT"
}

func (s Anonymous) Token() string {
	return s.token
}

func main() {
	genjector.MustBind(genjector.AsValue[IUser, Anonymous]())

	instance := genjector.MustNewInstance[IUser]()

	fmt.Println(instance.Token())
	// Output: some JWT
}


In the second example, we used the AsValue method. In this case, we bound a non-pointer Anonymous struct to the IUser interface (the Token method is attached to the value). This code would also work if we wanted to bind a pointer to the Anonymous structure.

In the latter case, the Init method is also executed, but again, to be possible, the method must be attached to a struct’s pointer, not a value.

Binding Implementations as Singletons and With Annotations

We can bind singletons to implementations. The Genjector package allows different approaches to fulfill this requirement.

The first approach is to use the AsSingleton option as an argument to the Bind method. With that option, we mark that binding as one that should create an instance only once and reuse it later.

Go
 
package main

import (
	"fmt"

	"github.com/ompluscator/genjector"
)

type IWeatherService interface {
	Temperature() float64
}

type WeatherService struct {
	value float64
}

func (s *WeatherService) Init() {
	s.value = 31.0
}

func (s *WeatherService) Temperature() float64 {
	return s.value
}

func main() {
	var counter = 0

	genjector.MustBind(
		genjector.AsProvider[IWeatherService](func() (*WeatherService, error) {
			counter++
			return &WeatherService{
				value: 21,
			}, nil
		}),
		genjector.AsSingleton(),
	)

	instance := genjector.MustNewInstance[IWeatherService]()

	fmt.Println(instance.Temperature())
	// Output: 21

	instance = genjector.MustNewInstance[IWeatherService]()

	fmt.Println(instance.Temperature())
	// Output: 21

	instance = genjector.MustNewInstance[IWeatherService]()
	instance = genjector.MustNewInstance[IWeatherService]()
	instance = genjector.MustNewInstance[IWeatherService]()
	instance = genjector.MustNewInstance[IWeatherService]()

	fmt.Println(counter)
	// Output: 1
}


In this example, we used a different way to define a concrete implementation. We used the provider method or simply the constructor in Go. This method provides a new instance of the WeatherService structure but first raises a global variable (counter).

In this case, when we use the provider method, the Genjector package does not call the Init method of the WeatherService structure because it assumes that the full initialization is done by the provider method.

As we have defined that our instance should be used as a singleton, the global variable is raised only once since the provider method is called only once. All other initializations used the same instance.

Another way to get a singleton is to use the AsInstance method, which allows binding an already-created instance to a specific implementation.

Go
 
package main

import (
	"fmt"

	"github.com/ompluscator/genjector"
)

type IWeatherService interface {
	Temperature() float64
}

type WeatherService struct {
	value float64
}

func (s *WeatherService) Init() {
	s.value = 31.0
}

func (s *WeatherService) Temperature() float64 {
	return s.value
}

func main() {
	genjector.MustBind(
		genjector.AsInstance[IWeatherService](&WeatherService{
			value: 11,
		}),
		genjector.WithAnnotation("first"),
	)

	genjector.MustBind(
		genjector.AsInstance[IWeatherService](&WeatherService{
			value: 41,
		}),
		genjector.WithAnnotation("second"),
	)

	first := genjector.MustNewInstance[IWeatherService](genjector.WithAnnotation("first"))

	fmt.Println(first.Temperature())
	// Output: 11

	second := genjector.MustNewInstance[IWeatherService](genjector.WithAnnotation("second"))

	fmt.Println(second.Temperature())
	// Output: 41
}


In the second example, we didn’t need to use the AsSingleton option, but to have a more interesting example, there is a practical use of annotations using the WithAnnotation option.

Here we have linked two instances, each labeled differently (“first” and “second” — so convenient, right?). From the moment we link them, the Genjector package will always use them for a specific implementation and will not create new instances.

Later in the code, we can get them by calling the NewInstance (or MustNewInstance) method and providing the right annotation in the WithAnnotation option.

Some More Complex Examples

When the Bind method is called, the Genjector package creates an instance of the binding interface and stores it in the Container. By default, such a Container is global, defined within the package itself.

In case we want to use our own special Container, we can establish such a function as in the following example:

Go
 
package main

import (
	"fmt"

	"github.com/ompluscator/genjector"
)

// definition for IWeatherService & WeatherService

func main() {
	container := genjector.NewContainer()

	genjector.MustBind(
		genjector.AsPointer[IWeatherService, *WeatherService](),
		genjector.WithContainer(container),
	)

	instance := genjector.MustNewInstance[IWeatherService](genjector.WithContainer(container))

	fmt.Println(instance.Temperature())
	// Output: 31
}


To create a new Container instance, we should use the NewContainer method. After that, to use that instance as storage for bindings, we should pass it to all calls to the Bind and NewInstance methods as the WithContainer option.

Slices and Maps

We can also define slices and maps of implementations. In many cases, this can happen when we want to provide a list of adapters, which should be named (maps) or not (slices).

Here we will try out using maps. For slices, you can check out the Genjector project and get more insights from examples.

Go
 
package main

import (
	"fmt"

	"github.com/ompluscator/genjector"
)

type Continent struct {
	name        string
	temperature float64
}

type IWeatherService interface {
	Temperature(name string) string
}

type WeatherService struct {
	continents map[string]Continent
}

func (s *WeatherService) Init() {
	s.continents = genjector.MustNewInstance[map[string]Continent]()
}

func (s *WeatherService) Temperature(name string) string {
	continent, ok := s.continents[name]
	if !ok {
		return "there is no such continent"
	}

	return fmt.Sprintf(`Temperature for continent "%s" is %.2f.`, continent.name, continent.temperature)
}

func main() {
	genjector.MustBind(genjector.AsPointer[IWeatherService, *WeatherService]())

	genjector.MustBind(
		genjector.InMap("africa", genjector.AsInstance[Continent](Continent{
			name:        "Africa",
			temperature: 41,
		})),
	)

	genjector.MustBind(
		genjector.InMap("europe", genjector.AsInstance[Continent](Continent{
			name:        "Europe",
			temperature: 21,
		})),
	)

	genjector.MustBind(
		genjector.InMap("antartica", genjector.AsInstance[Continent](Continent{
			name:        "Antartica",
			temperature: -41,
		})),
	)

	instance := genjector.MustNewInstance[IWeatherService]()

	fmt.Println(instance.Temperature("africa"))
	// Output: Temperature for continent "Africa" is 41.00.

	fmt.Println(instance.Temperature("europe"))
	// Output: Temperature for continent "Europe" is 21.00.

	fmt.Println(instance.Temperature("antartica"))
	// Output: Temperature for continent "Antartica" is -41.00.
}


In the map example, we can see a customized version of the WeatherService structure. It now contains a map of Continent instances, containing information about the temperature of each.

To meet this requirement, he had to use the Bind method with the InMap option, which defines the binding as part of the map. Inside the InMap method, we can use any of the bindings we’ve already tried, but it will still wrap them with information about their position in the map — by defining their keys.

This allows us to define as many different adapters as we want, in all different ways, and place them in the same cluster (map or slice). Later, if we want to initialize the complete map from the Genjector package, we should request the map with the desired key and value, as we did in the Init method of the WeatherService structure.

Conclusion

The Genjector package is a nice alternative to Go’s dependency injection framework. It only relies on generics and doesn’t use any reflection, so it has good performance.

For now, I plan to support future package upgrades, maintenance, and bug fixes. Also, I encourage any input from you as well.

 

 

 

 

Top