From godi to goboot: dependency injection without reflection
A string-keyed reflection container, then a compiler that resolves the whole dependency graph with go/types and writes the wiring as plain Go.
The runtime version
godi is about as small as a DI container gets. It is a map from names to instances, and a Resolve that uses reflect to fill tagged fields:
type MyStruct struct { Service *MyService `inject:"MyService"` }
err := container.Resolve(&MyStruct{})
It works, but it has the problems every runtime container has:
- Dependencies are matched by strings, so a typo fails at runtime, not at build time.
- Fields have to be exported for reflection to set them.
- Nothing knows the shape of the graph until the program runs, so there is no cycle detection, construction order or shutdown order to reason about.
The compile-time version
goboot keeps the ergonomics (annotate a type and it gets wired) and moves all the work to build time:
// @Service(name="userService", implements="UserUseCase")
type UserService struct{ repo UserRepository }
// @Transactional
// @Traced
func (s *UserService) Create(ctx context.Context, in CreateInput) (*User, error) { /* ... */ }
goboot generate ./...
It works like a small compiler, in stages:
- Annotate.
// @Name(arg=value)comments are lexed and parsed against a schema for each annotation: allowed targets, argument types and defaults. Malformed input becomes a diagnostic, never a panic. - Scan and analyze. Packages are loaded with
go/packages. Interfaces are matched withtypes.Implementson bothTand*T, so there are no strings involved. - Model. An intermediate application model that doesn’t depend on any particular router or database.
- Graph. Topological sort for construction order, cycle detection, and reverse order for shutdown.
- Generate. Go source, formatted with
go/formatandimports.
Generated code you’re meant to read
The output is a single wiring file that you commit. It is plain Go: constructors called in order, with errors checked. There is no hidden container to debug, because the container is the generated code.
Two rules keep that file trustworthy:
- Deterministic. The same input gives byte-identical output: sorted, with no timestamps. Diffs in code review show real changes only.
- Self-cleaning. Each run deletes its own previous output first, so a broken generated file can never block regeneration.
Interceptors as generated proxies
@Transactional, @Traced, @Timed, @Retry, @CircuitBreaker and @RateLimit are implemented as proxies generated around a service. There’s no runtime decoration, so the order of interceptors is visible in source.
This has one consequence. Consumers must depend on the interface that a service declares with implements=. Injecting the concrete type would bypass the proxy, so goboot rejects it with a specific diagnostic (GOBPRX001) instead of silently skipping your transaction.
Errors as diagnostics
Missing, ambiguous or cyclic dependencies are reported like compiler errors: a source position and a stable GOB* code. That is the payoff of moving DI to build time. Where godi gives you a runtime error with a string in it, goboot gives you a build failure that points at the line.