DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Please enter at least three characters to search
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Zones

Culture and Methodologies Agile Career Development Methodologies Team Management
Data Engineering AI/ML Big Data Data Databases IoT
Software Design and Architecture Cloud Architecture Containers Integration Microservices Performance Security
Coding Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Culture and Methodologies
Agile Career Development Methodologies Team Management
Data Engineering
AI/ML Big Data Data Databases IoT
Software Design and Architecture
Cloud Architecture Containers Integration Microservices Performance Security
Coding
Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance
Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks

Because the DevOps movement has redefined engineering responsibilities, SREs now have to become stewards of observability strategy.

Apache Cassandra combines the benefits of major NoSQL databases to support data management needs not covered by traditional RDBMS vendors.

The software you build is only as secure as the code that powers it. Learn how malicious code creeps into your software supply chain.

Generative AI has transformed nearly every industry. How can you leverage GenAI to improve your productivity and efficiency?

Related

  • Why I Started Using Dependency Injection in Python
  • LangChain in Action: Redefining Customer Experiences Through LLMs
  • SwiftData Dependency Injection in SwiftUI Application
  • High-Performance Go HTTP Framework Tasting

Trending

  • ITBench, Part 1: Next-Gen Benchmarking for IT Automation Evaluation
  • Navigating and Modernizing Legacy Codebases: A Developer's Guide to AI-Assisted Code Understanding
  • Introducing Graph Concepts in Java With Eclipse JNoSQL, Part 2: Understanding Neo4j
  • The Role of AI in Identity and Access Management for Organizations
  1. DZone
  2. Software Design and Architecture
  3. Security
  4. Genjector: Reflection-free Run-Time Dependency Injection framework for Go 1.18+

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

Although Generics in Go is still a relatively new feature, it supports solutions for the Dependency Injection framework that can be up to 30 times faster than its peers.

By 
Marko Milojevic user avatar
Marko Milojevic
·
Nov. 22, 22 · Analysis
Likes (1)
Comment
Save
Tweet
Share
5.5K Views

Join the DZone community and get the full member experience.

Join For Free
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:

  • 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:

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.

Data structure Dependency injection Dependency Framework Go (programming language) Injection Data Types

Published at DZone with permission of Marko Milojevic. See the original article here.

Opinions expressed by DZone contributors are their own.

Related

  • Why I Started Using Dependency Injection in Python
  • LangChain in Action: Redefining Customer Experiences Through LLMs
  • SwiftData Dependency Injection in SwiftUI Application
  • High-Performance Go HTTP Framework Tasting

Partner Resources

×

Comments
Oops! Something Went Wrong

The likes didn't load as expected. Please refresh the page and try again.

ABOUT US

  • About DZone
  • Support and feedback
  • Community research
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 100
  • Nashville, TN 37211
  • support@dzone.com

Let's be friends:

Likes
There are no likes...yet! 👀
Be the first to like this post!
It looks like you're not logged in.
Sign in to see who liked this post!