Genjector: Reflection-free Run-Time Dependency Injection framework for Go 1.18+
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:
- 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).
- A package can only rely on the Go core library. The use of other external packages is not allowed (not even unit test helpers).
- The API must be clean, intuitive, and easy to use. (I know, it’s not a big ask.)
- 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:
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:
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:
- Binding concrete implementations (structs) to specific interfaces.
- Binding implementations as pointers to structs or values.
- Binding implementations to provider methods.
- Binding implementations to already created instances.
- Define binding as a singleton instance.
- Define annotations for binding.
- Define slices and maps of implementations.
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:
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.
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
.
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.
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.
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:
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.
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.