Dependency Injection in Go, Reduced to Field Tags
Source: Dev.to
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:""`
}
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}
}
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
Run the generator:
injector generate ./...
It produces plain Go code:
func NewContainer() *Container {
cfg := NewDatabaseConfig()
db := NewDatabase(cfg)
user := NewUserService(db)
return &Container{
UserService: user,
}
}
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}
}
The container exposes only interfaces. Concrete implementations remain private, keeping 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`
}
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
GitHub:
Feedback is very welcome — especially around the design trade‑offs and edge cases.