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
mainpackage - 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 container | Manual DI (wire in main) / google/wire / uber-go/fx |
| Constructor injection | NewXxx(dep1, dep2) function |
services.AddScoped<IFoo, Foo>() | Call NewFoo and pass the result to its consumers |
| Interfaces often defined next to the implementation | Defined in the consumer's package |
Program.cs / Composition Root | The run() function in main.go |
| Lifetimes (Singleton / Scoped / Transient) | Explicit scope management (where you create an instance and how far you pass it) |
IHostedService / IDisposable | Start / Shutdown / Close methods, defer, fx Lifecycle |
IOptions<T> / appsettings.json | A 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 / NSubstitute | Hand-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
nilcheck insideNew
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 aboutIDisposabledisposal 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 lifetime | How to achieve it in Go |
|---|---|
| Singleton | Create 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 |
| Transient | Call NewXxx wherever you need one |
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.DBand*http.Clientare safe for concurrent use (they are designed to be shared)- For types with mutable state, use
sync.Mutexor 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.Provideis the equivalent ofAddSingleton(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
| Approach | Good for | Caveats |
|---|---|---|
| Manual DI | Small to medium projects, up to a few dozen dependencies | main gets long (but stays simple) |
| Wire | Many dependencies, when you want compile-time verification | Needs a generation step, which adds to the build process |
| fx | Large services that want modularization with lifecycle management | Resolved 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.
| Library | Characteristics |
|---|---|
| caarlos0/env | Maps environment variables via struct tags. Lightweight, suited to env-only setups |
| kelseyhightower/envconfig | Also struct-tag based. Long track record |
| spf13/viper | Unifies YAML / JSON / env vars / flags. Feature-rich but with many dependencies |
| knadh/koanf | A 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
Configby 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()orLogValue()to mask them - Don't keep it in a global variable: something like
config.Globalmakes 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
| Pitfall | Explanation | What to do |
|---|---|---|
| Reaching for a container first | Manual DI is the standard in Go | Wire things in main first and consider Wire / fx only when needed |
| Putting interfaces on the implementation side | Putting IUserRepository next to the implementation tends to produce huge interfaces | Define only the needed methods in the consumer's package |
| One huge interface with every method | Makes fakes painful to write in tests | Split into roughly 1 to 3 methods |
| Returning interfaces | If NewXxx returns an interface, consumers can't use the implementation's other features | Return the concrete type and abstract on the receiving side |
Initializing in init() or globals | Side effects make tests and initialization order hard to control | Initialize explicitly from main |
Stuffing dependencies into context | Not type-safe and hides the dependency graph | Pass them as constructor arguments |
nil interfaces | Putting a typed nil into an interface makes it != nil | Don't return a nil pointer of a concrete type (Methods and Interfaces) |
| Circular dependencies | Packages 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 global | Hard to swap in tests | Pass around the return value of Load() |
| Not validating config | The app starts with bad values and fails while running | Call 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()inmain) and create instances in dependency order. Do cleanup withdefer. - 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.Setenvare enough for swapping dependencies in tests.