2 min readgoboot

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:

  1. 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.
  2. Scan and analyze. Packages are loaded with go/packages. Interfaces are matched with types.Implements on both T and *T, so there are no strings involved.
  3. Model. An intermediate application model that doesn’t depend on any particular router or database.
  4. Graph. Topological sort for construction order, cycle detection, and reverse order for shutdown.
  5. Generate. Go source, formatted with go/format and imports.

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.

← all writing