Manual Dependency Injection for Go Services
This skill walks through the 7-step process for wiring a Go service by hand — no code generation, no reflection containers. Each step maps to patterns observed in production cmd/di/ packages.
Naming Convention Taxonomy
Every function in the DI layer follows one of three tiers that communicate risk to the reader:
| Pattern | Intent | Risk |
|---|---|---|
Provide…() / provide…() |
Returns a value, no possible failure | None — safe to call anywhere |
MustProvide…() / mustProvide…() |
Builds infra that can panic on misconfiguration | Panics on bad config |
Must…() / must…() |
Starts a long-running service/process | Panics, process-level |
Rules:
- Provide tier — Use for infallible constructors. Functions that do no I/O and cannot fail. Example:
provideLogger(cfg LogConfig) zerolog.Logger. Never useMusthere. - MustProvide tier — Use for infrastructure builders that can panic (DB pools, cloud clients, OTEL init). The risk is confined to the init path — if it panics, the service never starts.
- Must tier — Use for entrypoints that spin up goroutines running servers or workers. The risk is process-level — if it panics, the process terminates.
Decision flowchart:
- Does the function return a value? → If NO → skip to question 2. If YES → Go to question 3.
- Does it perform side-effectful bootstrap that wires a global? (e.g. OTEL SDK init) → If YES → use
init…(the only valid use). If NO → error — every function must return a value or be a true bootstrap. - Does the function do I/O or have a failure path? → If NO → Use Provide tier. If YES → Can it panic instead of returning an error? → If YES → Use MustProvide tier (for infra) or Must tier (for entrypoints). If NO → Return
(T, error)instead.
Note on init…:
The init… prefix is reserved only for true one-shot side-effectful bootstrap that wires a global and does not return a usable value. Example: mustInitOTEL(ctx, cfg) wires the OTEL SDK as a global and returns nothing. Every other function that constructs and returns a dependency must use Provide… or MustProvide….
Red flags:
- Using
Muston an infallible function (overstates risk). - Using
initon a function that returns a value (misleading). - Using
Provideon a function that can panic (understates risk). - Using
new…instead ofprovide…for dependency constructors.
Step 1 — Assess the Service Topology
Before writing any DI code, map the service surface area:
- Entrypoints: How many independent servers or workers? (HTTP, gRPC, worker pool?)
- Shared infra: What is shared across all modules? (logger, DB pool, OTEL tracer)
- Module-scoped infra: What is specific to one bounded context? (repositories, searchers, bus handlers)
Output: a list of files you will create in the di/ package.
Step 2 — Bootstrap the Signal-Driven Context (Must tier)
The single public function an agent should look for: Must{ServiceName}. This follows the Must — start tier because it spins up a service.
Rules:
- Named
Must{SERVICE_NAME}(exported, panics on fatal misconfiguration). - Uses
signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)— never a rawcontext.Background()passed into the graph. - Launches the wiring function in a goroutine; blocks on
<-ctx.Done(). - Calls
cancelSignal()after unblocking to release OS resources. - Entrypoints that spin up a service always follow the
Musttier (notMustProvide), because the risk is process-level, not dependency-level.
Go snippet:
func Must{SERVICE_NAME}(cfg {MODULE}Config) {
ctx, cancelSignal := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer cancelSignal()
go run{SERVICE_NAME}(cfg, ctx)
<-ctx.Done()
}
Red flags:
- Context created inside the wiring function (signal handling is lost).
os.Exitcalled directly instead of letting the signal propagate.- No
cancelSignal()call after<-ctx.Done(). - Using
start…instead ofmust…hides the panic risk from the reader.
Step 3 — Wire Common Providers (Provide tier)
A struct bundles all cross-cutting singletons. This follows the Provide tier because it builds a value struct with no failure path.
Rules:
- One unexported struct per service that bundles all cross-cutting singletons.
- Initialise OTEL first — it is a dependency of the logger and every tracing wrapper.
- Pass the struct by value everywhere — it is cheap (all fields are interfaces or pointers).
- No package-level
varglobals. - If a provider function cannot fail, name it
Provide…(exported) orprovide…(unexported). Never prefix it withMust.
Go snippet:
type commonProviders struct {
logger zerolog.Logger
timeProvider timeprovider.Provider
uuidProvider uuidprovider.Provider
healthcheck healthcheck.Checker
tracer trace.Tracer
}
func provideCommon(ctx context.Context, cfg {MODULE}Config) commonProviders {
tracer := mustInitOTEL(ctx, cfg) // exception: true side-effectful bootstrap
logger := zerolog.New(os.Stdout).With().Caller().Logger()
return commonProviders{
logger: logger,
timeProvider: timeprovider.New(),
uuidProvider: uuidprovider.New(),
healthcheck: healthcheck.New(tracer),
tracer: tracer,
}
}
Red flags:
- Global
var logger/var dbat package level. - Initialising the logger before OTEL (tracer won't be wired into it).
- Using
Muston an infallible constructor (overstates risk).
Step 4 — Build Infrastructure Components (MustProvide tier)
Private builders for each infrastructure adapter. These follow the MustProvide tier because they can panic on misconfiguration.
Rules:
- Private lowercase functions:
mustProvide{Component}(unexported) orMustProvide{Component}(exported). - Signature:
(ctx context.Context, cfg {Module}Config, logger zerolog.Logger) *ConcreteType - On error: log the reason then
panic(fmt.Errorf(...))— fail fast, fail loud. - Return the concrete infra type here (the interface boundary is applied in Step 5).
- Infallible constructors (no I/O, cannot fail) use
provide{Component}— e.g.provideLogger(cfg LogConfig) zerolog.Logger.
Go snippet (DB pool):
func mustProvidePostgres(ctx context.Context, cfg PostgresConfig, logger zerolog.Logger) *pgxpool.Pool {
pool, err := pgxpool.New(ctx, cfg.ConnString())
if err != nil {
logger.Error().Err(err).Msg("failed to connect to postgres")
panic(fmt.Errorf("postgres connection: %w", err))
}
if err := pool.Ping(ctx); err != nil {
logger.Error().Err(err).Msg("postgres health check failed")
panic(fmt.Errorf("postgres ping: %w", err))
}
return pool
}
Go snippet (infallible constructor):
func provideLogger(cfg LogConfig) zerolog.Logger {
return zerolog.New(os.Stdout).
With().
Level(zerolog.Level(cfg.Level)).
Caller().
Logger()
}
Red flags:
- Using
mustInit*for a function that returns a dependency value (misleading — usemustProvide*). - Returning
(T, error)from a must-builder — callers should not handle infra init errors. - Swallowing errors with
_ = err. - Initialising infra inside
main()or the wiring function directly.
Step 5 — Apply Decorator/Tracing Wrappers (Provide tier)
Enforce the D of SOLID at the wiring layer. These follow the Provide tier because they are pure value constructors that never do I/O and cannot fail.
Rules:
- One private builder per component that needs a tracing wrapper:
provide{Name}WithTracing(unexported). - The builder: (a) constructs the plain adapter, (b) wraps it with the tracing decorator, (c) returns the domain interface — not the concrete struct.
- The tracer name follows the pattern
"{component-name}-tracer". - Components without tracing wrappers return their domain interface directly via a simple
provide{Name}builder. - Decorator/tracing wrappers never do I/O and cannot fail — they are pure value constructors and must use the
Providetier.
Go snippet:
type Repository interface {
Get(ctx context.Context, id string) (Entity, error)
}
func provideOrderRepositoryWithTracing(repo *postgres.OrderRepository, tracer trace.Tracer) Repository {
return tracestorage.NewDecorator(repo, tracer, "order-repository-tracer")
}
func provideOrderRepository(db *pgxpool.Pool, tracer trace.Tracer) Repository {
repo := postgres.NewOrderRepository(db)
return provideOrderRepositoryWithTracing(repo, tracer)
}
Red flags:
- Returning
*ConcretePostgresRepositoryinstead ofdomain.Repository. - Sharing a single tracer instance across all components (each gets its own named tracer).
- Wiring the plain adapter directly into the bus without a tracing wrapper when one exists.
- Using
new…instead ofprovide…for dependency constructors (obscures the tier).
Step 6 — Register Buses with Must* Functions (MustProvide tier)
Group handler registration by bounded context × layer. Bus construction follows the MustProvide tier because it can panic on duplicate registration.
Rules:
- Two initialisation functions:
mustProvideCommandBusandmustProvideQueryBus(private). — Note:init…is reserved for side-effectful bootstrap with no return value. Bus construction returns a value → usemustProvide…. - Call
syncBus.Use(OTELMiddleware(...))before registering any handler. - Group handler registration into public
MustRegister{Module}{Layer}functions (e.g.MustRegisterOrderCommands,MustRegisterOrderQueries). - All function signatures accept only domain interfaces, never concrete infrastructure types.
MustRegisterpanics on duplicate handler registration — this is intentional: it is a build-time safety check.
Go snippet:
func mustProvideCommandBus(tracer trace.Tracer) *agenticcommand.Bus {
bus := agenticcommand.New()
bus.Use(otelcommand.NewMiddleware(tracer))
return bus
}
func MustRegisterOrderCommands(bus *agenticcommand.Bus, repo domain.OrderRepository) {
handler := cmdorder.NewCreateHandler(repo)
agenticcommand.MustRegister(bus, "order.create", handler)
}
Red flags:
- Registering handlers before attaching middleware.
- Accepting
*postgres.OrderRepositoryinstead ofdomain.OrderRepositoryinMustRegister*. - One giant
mustProvideCommandBusfunction with all registrations inline (no grouping by module).
Step 7 — Start Entrypoints (Must tier)
Launch servers and workers in goroutines. These follow the Must tier because they launch long-running processes.
Rules:
- Private
must{Name}Server/must{Name}Workerfunctions per entrypoint. - Accepts
commonProviders+ buses as arguments — nothing global, nothing from closure. - Launches the server/worker in a goroutine.
- The parent
run{ServiceName}function assembles all components and calls allmust*functions; then returns — theMust*entrypoint is what blocks. - Any function that launches a goroutine with a server/worker and panics on failure follows the
Musttier.
Go snippet:
func mustHTTPServer(addr string, providers commonProviders, cmdBus *agenticcommand.Bus, queryBus *agenticquery.Bus) {
mux := http.NewServeMux()
// register routes...
server := &http.Server{Addr: addr, Handler: mux}
go func() {
providers.logger.Info().Str("addr", addr).Msg("starting http server")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
panic(err)
}
}()
}
Red flags:
- Starting servers before all buses are fully registered.
- Passing individual providers (logger, tracer, etc.) as separate args instead of
commonProviders. http.ListenAndServecalled without a goroutine (blocks the wiring function).- Using
start…instead ofmust…hides the panic risk from the reader.
File Layout
| File | Responsibility | Naming tier |
|---|---|---|
{service}_service.go |
Must* entrypoint + run* wiring orchestrator |
Must |
common.go |
commonProviders struct + provideCommon |
Provide |
observability.go |
OTEL initialisation | MustProvide (init exception) |
postgres.go / {infra}.go |
mustProvide* infra builders |
MustProvide |
aws.go / {cloud}.go |
Cloud client builders | MustProvide |
command.go |
mustProvideCommandBus + MustRegister*Commands |
MustProvide |
query.go |
mustProvideQueryBus + MustRegister*Queries |
MustProvide |
{module}_commands.go |
MustRegister{Module}Commands for large modules |
Must |
{module}_queries.go |
MustRegister{Module}Queries for large modules |
Must |
{module}_repos.go |
Repository builders for a module | Provide |
{module}_searchers.go |
Searcher/reader builders with tracing wrappers | Provide |
Source: soulcodex/agentic — distributed by TomeVault.