Fearless Dependency Injection for Go
Dependency Injection is one of those terms that sounds much more dramatic than it really is. In modern frameworks, especially in the Java world, it often comes with containers, annotations, runtime magic, and a lot of ceremony.
Following my older fearless BDD for Go, where I shown how a simple naming convetion can replace heavy and not idiomatic BDD frameworks, I am going to show that the idea is way simpler.
What is the dependency injection?
Dependency Injection is a 25-dollar term for a 5-cent concept. – James Shore
Well, sometimes not.
@Component
public class Controller {
@Autowired
// Spring does the magic here
private Service myService;
}
// ... if it can find this
public interface Service {}
@Component
public class ServiceImpl implements Service {}In Go. That is the whole thing. No framework. No hidden runtime wiring. No magic. Just explicit dependencies and small functions.
type Service interface{}
type Controller struct {
service Service
}
func NewController(service Service) *Controller {
}The whole principle is that the dependencies, like the service layer are constructed outside and passed simply as a function arguments. The term injected makes things confusing. It does not help that a lot of languages and frameworks tends to hide arguments passing behind a lot of magic.
The wrong pattern
This is an opposite of DI - there is a huge piece of code implementing whole initialization in one function. This is not recommended, but viable approach for one-shot services still.
func NewController(conf Config) (*Controller, error) {
// connect to database
// open http server
// create a service layer
// start the job queue, ...
}Frameworks
Go is not immune when it comes to frameworks. A lot of Java, Clojure and other developers goes to the Go ecosystem and do not like the simplicity. Here frameworks described in my own words. However mileage may vary.
- Uber/fx - reflection based complex DI system from Uber. My experience is that this library saves one from a bit of writing in exchange of a lot of runtime errors. When it works is amazing, but debugging it before LLMs was a pain.
- goforj/wire - this resolves everything via go generate. With additions like go tool support I consider this more ergonomic than it used be. Fork of a google wire, which was archived in 2025. Errors can be tricky, but all are revealed in a compile time.
- samber/do - this uses a generics, however I had personally
found this to be even more confusing and as the dependencies are resolved using
indirection via
do.Injectorinterface in runtime. This gives again an amazing possibility of having a runtime errors, which are as readable as Java exceptions.
No framework
Clear is better than clever; Reflection is never clear.
The greatest part of the wire is that all it does is that it generates a lot
of Go functions. All the magic behind do, fx or Spring Boot committed to
git repository. Can be inspected, can be debugged - only a single go generate
needed.
When writing an MCP server for gonix project I wanted to use benefits of the approach but to not commit to any framework. One of the gonix principle is to stay light on dependencies. It turns out that with a simple conventions the whole initialization is much easier than one would say
internal/app is the package which contains all infrastructure code. The name
app is short and clear enough in the source code. I saw code bases using names
like infrastructure, dependencies or has all this code scattered in various
packages.
conf.go: code dealing with a configurationdi.go: dependency injection codeserver.go: wrapper on top of go-sdk MCP server
Dependencies
This is an example of a Provider - which is super confusing concept again. Lets stop bashing Java and check what angular documentation says.
Using
providedInin the@Injectabledecorator, the@Servicedecorator, or by providing a factory in theInjectionTokenconfiguration
I must admit that I do not understand a single word. In a simpler language
like Go, the provider is a simple piece of code returning an input for other
function. The example below returns a path to the cache directory, where
wazero can store compilation artifacts.
type CacheDir string
func GuessCacheDir() CacheDir {
cacheDir, err := os.UserCacheDir()
if err != nil {
return CacheDir("")
}
appCacheDir := filepath.Join(cacheDir, "gonix-mcp")
err = os.MkdirAll(appCacheDir, 0o755)
if err != nil {
return CacheDir("")
}
return CacheDir(cacheDir)
}And instead of a runtime magic, the type definition is all its needed to connect the provider with its counterpart. Types, what a concept! Can’t be really much easier than this.
func WasmRuntime(ctx context.Context, root Root, cache CacheDir)
(*wasm.Runtime, func(context.Context) error, error)So the whole code chain is again, driven by types, visible in IDE or editor, checked by the compiler.
// dependency 1
ctx := context.Background()
// dependency 2
root, err := app.OpenRoot(&config, flags.Root)
if err != nil {
return err
}
// dependency 3
cacheDir := app.GuessCacheDir()
runtime, _, err := app.WasmRuntime(
ctx,
root,
cacheDir,
)
if err != nil {
return err
}And this all is the simple straightforward code all the way down.
// tool: read_text_file
rtf, err := app.BuildReadTextFileTool(
config.Tools.ReadTextFile,
root,
coreutils,
)
if err != nil {
return err
}
// ...
var registrars []app.Registrar{rtf, sht, lst, lsd}
s := app.NewServer("gonix-mcp")
for _, tool := range registrars {
s.AddTool(tool)
}
return s.Run(ctx, &mcp.StdioTransport{})
}Use case: flags
One thing I’ve been struggling with is the command line flags parsing - the
default style in Go relies on global variables (or variables with a too huge
scope), popular frameworks like cobra uses a combination of init() and global
variables.
var cfgFile string
var rootCmd &cobra.Command{}
func init() {
rootCmd.PersistentFlags().StringVar(\&cfgFile, "config", "",
"config file (default locations: ., $HOME/.myapp/)")
}It turn out the command line flags are the dependency too as they change the
way how’s program supposed to be initialized. The Flag declaration tells the
code if the flag was present on a command line. The other struct simply lists
all the possible options. The type definitions are again for a further type
safety.
type Flag[T any] struct {
Value T
Present bool
}
type ConfigFlag Flag[string]
type RootFlag Flag[string]
type Flags struct {
Config ConfigFlag // -config
Root RootFlag // -root
}Following this principle lead to the most pleasant command line flags parsing I ever saw in a Go program. So if anyone was in doubt why to use it, this is the reason.
func ParseFlags(args []string) (Flags, error) {
fset := flag.NewFlagSet("gonix-mcp", flag.ExitOnError)
config := fset.String("config", ...)
root := fset.String("root", ...)
if err := fset.Parse(args); err != nil {
return Flags{}, err
}
ret := Flags{
Config: ConfigFlag{
Value: *config,
},
Root: RootFlag{
Value: *root,
},
}
fset.Visit(func(f *flag.Flag) {
switch f.Name {
case "config":
ret.Config.Present = true
case "root":
ret.Root.Present = true
}
})
return ret, nil
}And a code which needs to react on a -root flag?
func OpenRoot(config *Config, root RootFlag) (Root, error)Conclusion
The real lesson here is not to avoid frameworks at all costs. The important lessons are
- do not be afraid to make the wiring explicit and visible
- express it in code that is easy to follow and safe
- following the dependency injection pattern lead to more elegant code
Comments
With an account on the Fediverse or Mastodon, you can respond to this post. Since Mastodon is decentralized, you can use your existing account hosted by another Mastodon server or compatible platform if you don't have an account on this one. Known non-private replies are displayed below.
Learn how this is implemented on https://carlswchwan.eu.