Skip to main content

Dependency Injection and Configuration

For developers with .NET (C#) experience, this guide covers constructor injection, the Composition Root, Wire / fx, and environment-variable-based configuration.

1. The Go Approach to DI​

In ASP.NET Core you register services in IServiceCollection and a runtime container resolves the dependencies. Go has no standard DI container, so the starting point is wiring everything by hand in main.

The principles of DI in Go are:

  • Take dependencies as arguments of a constructor function (NewXxx): list what you need as parameters and keep it in struct fields
  • Define interfaces on the consumer side: declare only the minimal set of methods in the package that depends on them, not in the implementation package
  • Concentrate wiring in one place (the Composition Root): usually the main package
  • Don't use a container until you need one: manual DI is enough for most projects

It helps to think of "DI = passing dependencies in from outside" rather than "DI = a container".

2. Mapping to .NET​

.NET (C#)Go
IServiceCollection / DI containerManual DI (wire in main) / google/wire / uber-go/fx
Constructor injectionNewXxx(dep1, dep2) function
services.AddScoped<IFoo, Foo>()Call NewFoo and pass the result to its consumers
Interfaces often defined next to the implementationDefined in the consumer's package
Program.cs / Composition RootThe run() function in main.go
Lifetimes (Singleton / Scoped / Transient)Explicit scope management (where you create an instance and how far you pass it)
IHostedService / IDisposableStart / Shutdown / Close methods, defer, fx Lifecycle
IOptions<T> / appsettings.jsonA config struct + environment variables (envconfig / viper, etc.)
Layered IConfiguration (json → env → args)Flags → env vars → defaults, composed by hand or with viper
Options validation (ValidateDataAnnotations)A Validate() method / a validator library
Moq / NSubstituteHand-written fakes (easy because interfaces are small)

3. Constructor Injection​

3.1 The Basic Form​

The equivalent of C# constructor injection is a factory function named NewXxx.

// C#
public class UserService
{
private readonly IUserRepository _repo;
private readonly ILogger<UserService> _logger;

public UserService(IUserRepository repo, ILogger<UserService> logger)
{
_repo = repo;
_logger = logger;
}
}
// Go
type UserService struct {
repo UserRepository
logger *slog.Logger
}

func NewUserService(repo UserRepository, logger *slog.Logger) *UserService {
return &UserService{repo: repo, logger: logger}
}

Follow these conventions:

  • Name the constructor New + the type name and return the concrete type (*UserService) ("accept interfaces, return structs")
  • Keep dependencies in fields, and don't rely on global variables or init() inside methods
  • Take required dependencies as arguments; do any nil check inside New

3.2 Define Interfaces on the Consumer Side​

In C# you often put IUserRepository in the same project as the repository implementation. In Go, put the interface in the package that needs it. Because Go interfaces are satisfied implicitly, the implementation doesn't need to know the interface exists.

// service package: the consumer declares only the methods it needs
package service

type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
Save(ctx context.Context, u *User) error
}

type UserService struct {
repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
// postgres package: exposes a concrete type without importing the interface
package postgres

type UserRepository struct {
db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}

func (r *UserRepository) FindByID(ctx context.Context, id int64) (*service.User, error) { /* ... */ }
func (r *UserRepository) Save(ctx context.Context, u *service.User) error { /* ... */ }

The benefits are:

  • Interfaces stay small (only the methods actually used)
  • The implementation package doesn't depend on the consumer package (dependencies point in one direction)
  • Test fakes are easy to write by hand

For more on interfaces, see Methods and Interfaces.

3.3 Functional Options​

When there are many optional dependencies or settings, use the Functional Options pattern so the argument list doesn't balloon. The standard approach is to pass required dependencies as arguments and optional settings as options.

type Option func(*Server)

func WithLogger(l *slog.Logger) Option {
return func(s *Server) { s.logger = l }
}

func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}

func NewServer(handler http.Handler, opts ...Option) *Server {
s := &Server{
handler: handler,
logger: slog.Default(),
timeout: 30 * time.Second, // default
}
for _, opt := range opts {
opt(s)
}
return s
}

srv := NewServer(h, WithTimeout(10*time.Second))

4. The Composition Root (Manual DI)​

Instead of listing builder.Services.AddXxx(...) in Program.cs, in main.go you create instances in dependency order and pass each one to the next constructor.

4.1 Split main and run​

Keep main responsible only for the exit code and move the real work into run() error. This prevents os.Exit from skipping defer and makes the code easier to test.

// cmd/api/main.go
package main

import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"example.com/app/internal/config"
"example.com/app/internal/handler"
"example.com/app/internal/postgres"
"example.com/app/internal/service"

_ "github.com/jackc/pgx/v5/stdlib" // registers the pgx driver for database/sql
)

func main() {
if err := run(); err != nil {
slog.Error("fatal", "err", err)
os.Exit(1)
}
}

func run() error {
// 1. Configuration (the IOptions<T> equivalent)
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
}

// 2. Logger
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

// 3. Infrastructure (DB connection)
db, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()

// 4. Assemble in dependency order (the equivalent of a list of AddScoped / AddSingleton calls)
repo := postgres.NewUserRepository(db)
svc := service.NewUserService(repo, logger)
h := handler.New(svc, logger)

// 5. Start the server and shut down gracefully
srv := &http.Server{
Addr: cfg.Addr,
Handler: h.Routes(),
ReadHeaderTimeout: 5 * time.Second,
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

errCh := make(chan error, 1)
go func() {
logger.Info("listening", "addr", cfg.Addr)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()

select {
case err := <-errCh:
return fmt.Errorf("serve: %w", err)
case <-ctx.Done():
logger.Info("shutting down")
}

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}

Key points:

  • The dependency graph is visible as the order of the code. There is no container magic, and you can trace it with your IDE's go-to-definition
  • Dependency cycles are caught at compile time (as undefined variables)
  • Cleanup is stacked with defer, so you don't have to think about IDisposable disposal order (last in, first out)
  • For graceful shutdown details, see context and Web API Development

4.2 Thinking About Lifetimes​

Go has no Singleton / Scoped / Transient distinction. A lifetime is determined by where you create an instance and who you pass it to.

.NET lifetimeHow to achieve it in Go
SingletonCreate it once in main and pass it to consumers. It is used from many goroutines, so make it concurrency-safe
Scoped (per request)Create it inside the handler each time, or use context.Context to carry only request-specific values
TransientCall NewXxx wherever you need one
Don't put dependencies in the context

context.Context is not a replacement for a DI container. Stuffing repositories or services into context.WithValue is an anti-pattern. Only put request-specific values in the context, such as request IDs and trace information.

Make Singletons Concurrency-Safe​

As with ASP.NET Core singletons, instances created in main are called concurrently from multiple requests (goroutines).

  • *sql.DB and *http.Client are safe for concurrent use (they are designed to be shared)
  • For types with mutable state, use sync.Mutex or design them to be stateless

Scoped Equivalent: Per-Request Dependencies​

Dependencies that live only within a request, such as a DB transaction, are handled through factories or functions.

// Express the transaction boundary as a function
type TxManager interface {
WithTx(ctx context.Context, fn func(ctx context.Context, repo UserRepository) error) error
}

func (s *UserService) Rename(ctx context.Context, id int64, name string) error {
return s.tx.WithTx(ctx, func(ctx context.Context, repo UserRepository) error {
u, err := repo.FindByID(ctx, id)
if err != nil {
return err
}
u.Name = name
return repo.Save(ctx, u)
})
}

See Data Access for details.

5. Wire and fx​

These are options for when the project grows and the wiring code in main becomes long.

5.1 google/wire (Code Generation)​

Wire is a tool that generates wiring code from the type signatures of your constructors. It uses no runtime reflection, and the output is ordinary Go code.

// wire.go
//go:build wireinject

package main

import (
"github.com/google/wire"

"example.com/app/internal/config"
"example.com/app/internal/postgres"
"example.com/app/internal/service"
)

func InitializeApp(cfg config.Config) (*App, func(), error) {
wire.Build(
openDB,
postgres.NewUserRepository,
// Bind the interface to its implementation
wire.Bind(new(service.UserRepository), new(*postgres.UserRepository)),
service.NewUserService,
newApp,
)
return nil, nil, nil // replaced by the generated code
}
# Generate wire_gen.go
go run github.com/google/wire/cmd/wire ./cmd/api
  • Missing dependencies and cycles are detected at compile time (not as runtime errors)
  • The generated code looks like a hand-written main, so it is easy to debug
  • If a constructor returns (T, func(), error), cleanup functions are handled as well
  • Check the repository's maintenance status before adopting it

5.2 uber-go/fx (Runtime)​

fx is a runtime DI framework that resolves dependencies with reflection. It feels close to Microsoft.Extensions.DependencyInjection + IHostedService, and provides lifecycle management (start and stop hooks).

func main() {
fx.New(
fx.Provide(
config.Load,
newLogger,
openDB,
// Provide a concrete type as an interface
fx.Annotate(
postgres.NewUserRepository,
fx.As(new(service.UserRepository)),
),
service.NewUserService,
handler.New,
newHTTPServer,
),
fx.Invoke(func(*http.Server) {}), // something that must be created at startup
).Run()
}

func newHTTPServer(lc fx.Lifecycle, cfg config.Config, h *handler.Handler) *http.Server {
srv := &http.Server{Addr: cfg.Addr, Handler: h.Routes()}

lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
return err
}
go srv.Serve(ln)
return nil
},
OnStop: func(ctx context.Context) error {
return srv.Shutdown(ctx)
},
})
return srv
}
  • fx.Provide is the equivalent of AddSingleton (fx instances are essentially singletons)
  • Missing dependencies are detected at runtime (at startup)
  • Trade-offs include harder-to-follow stack traces and the learning curve of reflection

5.3 Which to Choose​

ApproachGood forCaveats
Manual DISmall to medium projects, up to a few dozen dependenciesmain gets long (but stays simple)
WireMany dependencies, when you want compile-time verificationNeeds a generation step, which adds to the build process
fxLarge services that want modularization with lifecycle managementResolved at runtime, so it takes some getting used to

The Go community generally starts with manual DI and adopts a tool only once it becomes truly painful. Introducing a container from day one out of .NET habit tends to clash with Go's "explicit and simple" philosophy and make things more complicated.

6. Configuration​

The equivalent of ASP.NET Core's appsettings.json + IOptions<T> is a pattern where you define a config struct, load and validate it once at startup, and pass only the needed parts to each constructor. Following the Twelve-Factor App, configuring through environment variables is the norm.

6.1 Loading with Only the Standard Library​

// internal/config/config.go
package config

import (
"errors"
"fmt"
"net"
"os"
"time"
)

type Config struct {
Addr string
DatabaseURL string
LogLevel string
ShutdownTimeout time.Duration
}

func Load() (Config, error) {
cfg := Config{
// defaults
Addr: ":8080",
LogLevel: "info",
ShutdownTimeout: 10 * time.Second,
}

if v, ok := os.LookupEnv("APP_ADDR"); ok {
cfg.Addr = v
}
if v, ok := os.LookupEnv("APP_LOG_LEVEL"); ok {
cfg.LogLevel = v
}
if v, ok := os.LookupEnv("APP_SHUTDOWN_TIMEOUT"); ok {
d, err := time.ParseDuration(v)
if err != nil {
return Config{}, fmt.Errorf("APP_SHUTDOWN_TIMEOUT: %w", err)
}
cfg.ShutdownTimeout = d
}

// required
cfg.DatabaseURL = os.Getenv("DATABASE_URL")

if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}

// Validate is the equivalent of ValidateOnStart()
func (c Config) Validate() error {
var errs []error
if c.DatabaseURL == "" {
errs = append(errs, errors.New("DATABASE_URL is required"))
}
if _, _, err := net.SplitHostPort(c.Addr); err != nil {
errs = append(errs, fmt.Errorf("APP_ADDR %q: %w", c.Addr, err))
}
return errors.Join(errs...) // return multiple errors together
}

os.Getenv can't distinguish between unset and empty. Use os.LookupEnv when you need to.

6.2 Using a Library​

As the number of environment variables grows, the boilerplate does too, so many projects use a library that lets you declare them with struct tags.

LibraryCharacteristics
caarlos0/envMaps environment variables via struct tags. Lightweight, suited to env-only setups
kelseyhightower/envconfigAlso struct-tag based. Long track record
spf13/viperUnifies YAML / JSON / env vars / flags. Feature-rich but with many dependencies
knadh/koanfA lighter alternative to viper. Combines multiple sources
import "github.com/caarlos0/env/v11"

type Config struct {
Addr string `env:"APP_ADDR" envDefault:":8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
LogLevel string `env:"APP_LOG_LEVEL" envDefault:"info"`
ShutdownTimeout time.Duration `env:"APP_SHUTDOWN_TIMEOUT" envDefault:"10s"`
}

func Load() (Config, error) {
cfg, err := env.ParseAs[Config]()
if err != nil {
return Config{}, fmt.Errorf("parse env: %w", err)
}
return cfg, nil
}

For local development you can load a .env file with something like joho/godotenv, but in production use the platform's environment variables (containers, Cloudflare, Kubernetes, etc.) and never commit .env.

6.3 How to Pass Configuration​

Rather than injecting the whole struct everywhere like IOptions<T>, we recommend passing each component only the values it needs.

// Bad: taking the whole config hides what the component really depends on
func NewUserService(cfg config.Config, repo UserRepository) *UserService

// Good: take only the values needed
func NewUserService(repo UserRepository, cacheTTL time.Duration) *UserService

// For components with many settings, define a dedicated Options struct
type ServerOptions struct {
Addr string
ReadTimeout time.Duration
WriteTimeout time.Duration
}

func NewServer(h http.Handler, opts ServerOptions) *http.Server

6.4 Guidelines for Configuration​

  • Load and validate once at startup: exit immediately if it's invalid (fail fast). Don't read environment variables while handling requests
  • Treat it as immutable after loading: pass Config by value and avoid mutating it through pointers
  • Keep defaults in code: don't give defaults to values that must be set in production (such as connection strings)
  • Don't log secrets: implement String() or LogValue() to mask them
  • Don't keep it in a global variable: something like config.Global makes swapping in tests and parallel tests difficult
// Keep secrets out of slog output
type Secret string

func (Secret) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }
func (Secret) String() string { return "[REDACTED]" }

7. Swapping Dependencies in Tests​

If you define small interfaces on the consumer side, you only need to pass a hand-written fake, with no mocking library.

type fakeRepo struct {
users map[int64]*service.User
err error
}

func (f *fakeRepo) FindByID(ctx context.Context, id int64) (*service.User, error) {
if f.err != nil {
return nil, f.err
}
u, ok := f.users[id]
if !ok {
return nil, service.ErrUserNotFound
}
return u, nil
}

func (f *fakeRepo) Save(ctx context.Context, u *service.User) error {
f.users[u.ID] = u
return f.err
}

func TestUserService_Rename(t *testing.T) {
repo := &fakeRepo{users: map[int64]*service.User{1: {ID: 1, Name: "old"}}}
svc := service.NewUserService(repo, slog.New(slog.NewTextHandler(io.Discard, nil)))

if err := svc.Rename(context.Background(), 1, "new"); err != nil {
t.Fatal(err)
}
if got := repo.users[1].Name; got != "new" {
t.Errorf("Name = %q, want %q", got, "new")
}
}

For tests of environment-dependent configuration, t.Setenv automatically restores the original value when the test ends.

func TestLoad(t *testing.T) {
t.Setenv("DATABASE_URL", "postgres://localhost/test")
t.Setenv("APP_ADDR", ":9999")

cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
if cfg.Addr != ":9999" {
t.Errorf("Addr = %q", cfg.Addr)
}
}

For more on testing, see Testing in Go.

8. Common Pitfalls for C# Developers​

PitfallExplanationWhat to do
Reaching for a container firstManual DI is the standard in GoWire things in main first and consider Wire / fx only when needed
Putting interfaces on the implementation sidePutting IUserRepository next to the implementation tends to produce huge interfacesDefine only the needed methods in the consumer's package
One huge interface with every methodMakes fakes painful to write in testsSplit into roughly 1 to 3 methods
Returning interfacesIf NewXxx returns an interface, consumers can't use the implementation's other featuresReturn the concrete type and abstract on the receiving side
Initializing in init() or globalsSide effects make tests and initialization order hard to controlInitialize explicitly from main
Stuffing dependencies into contextNot type-safe and hides the dependency graphPass them as constructor arguments
nil interfacesPutting a typed nil into an interface makes it != nilDon't return a nil pointer of a concrete type (Methods and Interfaces)
Circular dependenciesPackages can't import each other in a cycle (compile error)Put the interface on the consumer side to invert the dependency (Project Layout)
Keeping config globalHard to swap in testsPass around the return value of Load()
Not validating configThe app starts with bad values and fails while runningCall Validate() at startup and fail fast

9. Summary​

  • DI in Go is basically passing dependencies as arguments to constructor functions (NewXxx); a DI container is not required.
  • Concentrate wiring in the Composition Root (run() in main) and create instances in dependency order. Do cleanup with defer.
  • Define interfaces on the consumer side and keep them small. Constructors return concrete types.
  • There is no lifetime distinction; it depends on where you create an instance and who you pass it to. Make singletons concurrency-safe, and handle request-specific things with functions or factories rather than context.
  • When wiring gets complicated, consider Wire (compile-time verification) or fx (which also manages lifecycle). Start with manual DI.
  • Load configuration into a struct and validate it once at startup (fail fast). Environment variables are the baseline, and each component receives only the values it needs.
  • Hand-written fakes and t.Setenv are enough for swapping dependencies in tests.

References​