DEV Community

Tetsuro Mikami
Tetsuro Mikami

Posted on

Dependency Injection in Go, Reduced to Field Tags

Dependency Injection (DI) in Go often feels heavier than it should be.

Once a project grows, you start seeing patterns like:

  • Large provider sets
  • Dedicated wiring files (for tools like wire)
  • Initialization order that humans must carefully maintain
  • DI-specific code spreading across the codebase

Over time, the amount of code written for DI itself can rival the actual business logic.

I wanted to see how far DI could be simplified in Go.

That experiment became injector.


The Core Idea

All dependency injection is declared using struct field tags.

Nothing else.

No provider sets.
No DSL.
No runtime reflection.

The container declares what is injected.
Providers declare how values are constructed.


The Container

A container is just a struct. Fields marked with the inject tag are managed by injector.

type Container struct {
    UserService service.UserService `inject:""`
}
Enter fullscreen mode Exit fullscreen mode

The inject tag is intentionally minimal:

  • It is a marker only
  • It carries no configuration by default
  • It simply means “this field is injected by injector”

Providers

A provider is any top-level function that returns a value.

func NewUserService(db infra.Database) service.UserService {
    return &userService{DB: db}
}
Enter fullscreen mode Exit fullscreen mode

Rules are simple:

  • The function must be top-level (no receiver)
  • Parameters are dependencies
  • The return value is the provided type

Injector discovers providers automatically via static analysis.


Generated Code

Running the generator:

injector generate ./...
Enter fullscreen mode Exit fullscreen mode

Produces plain Go code:

func NewContainer() *Container {
    cfg := NewDatabaseConfig()
    db := NewDatabase(cfg)
    user := NewUserService(db)

    return &Container{
        UserService: user,
    }
}
Enter fullscreen mode Exit fullscreen mode

There is no runtime magic.
The generated code is readable, debuggable, and type-safe.


Interface-First by Default

Injector works naturally with interfaces.

type UserService interface {
    Register(name, password string) error
}

func NewUserService(db infra.Database) UserService {
    return &userService{DB: db}
}
Enter fullscreen mode Exit fullscreen mode

The container exposes only interfaces.
Concrete implementations remain private.

This keeps application boundaries clean without adding DI-specific abstractions.


Handling Multiple Providers

If multiple providers return the same type, injector requires an explicit choice.

type Container struct {
    _ config.DatabaseConfig `inject:"provider:NewPrimaryDatabaseConfig"`
    UserService service.UserService `inject`
}
Enter fullscreen mode Exit fullscreen mode

A blank (_) field:

  • Does not expose the dependency
  • Declares a provider override
  • Applies globally within the container

Provider selection remains centralized and explicit.


Why Field Tags?

Go structs already represent dependency lists.

Adding extra configuration layers often makes DI harder to reason about, not easier.

By limiting DI declarations to field tags:

  • Dependencies are visible at a glance
  • Configuration stays local
  • The mental model stays small

DI should be infrastructure, not the focus of the codebase.


Status

Injector is intentionally small and opinionated.

The current goal is not feature breadth, but clarity:

  • Marker-based containers
  • Provider-based resolution
  • Compile-time dependency graphs

Future ideas exist, but they can wait until real-world usage drives them.


Links

Feedback is very welcome — especially around the design trade-offs and edge cases.

Top comments (0)