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.Injector interface 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 configuration
  • di.go: dependency injection code
  • server.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 providedIn in the @Injectable decorator, the @Service decorator, or by providing a factory in the InjectionToken configuration

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

soldering.svg