Dependency Injection in Go Without Frameworks
Coming from Java or C#, you might reach for a DI container in Go. Don't. Go's simplicity makes manual dependency injection not just viable but preferable. Frameworks like Spring solve problems that Go doesn't have.
Here's how to do DI in Go the idiomatic way.
Why Go Doesn't Need DI Containers
In Java, you need DI containers because:
- Constructors are verbose (no named parameters)
- No first-class functions
- Annotation-driven configuration is the norm
- Complex object graphs with lifecycle management
Go has none of these problems:
- Struct literals with named fields
- First-class functions
- Explicit is better than magic
- Simple object lifecycles (create, use, done)
The Go philosophy: if you can see the code, you can understand it. DI containers hide the wiring.
Pattern 1: Constructor Injection
The foundation of DI in Go. Dependencies go in the constructor.
// repository.go
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) GetByID(ctx context.Context, id string) (*User, error) {
// Uses r.db
}
// service.go
type UserService struct {
repo *UserRepository
cache Cache
mailer Mailer
}
func NewUserService(repo *UserRepository, cache Cache, mailer Mailer) *UserService {
return &UserService{
repo: repo,
cache: cache,
mailer: mailer,
}
}
Main as Composition Root
All wiring happens in main(). This is your composition root—the one place where you see how everything connects.
// main.go
func main() {
// Infrastructure
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
redisClient := redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_URL"),
})
defer redisClient.Close()
smtpClient := smtp.NewClient(os.Getenv("SMTP_HOST"))
// Repositories
userRepo := NewUserRepository(db)
orderRepo := NewOrderRepository(db)
// Services
cache := NewRedisCache(redisClient)
mailer := NewSMTPMailer(smtpClient)
userService := NewUserService(userRepo, cache, mailer)
orderService := NewOrderService(orderRepo, userService)
// HTTP handlers
userHandler := NewUserHandler(userService)
orderHandler := NewOrderHandler(orderService)
// Router
mux := http.NewServeMux()
mux.Handle("/users", userHandler)
mux.Handle("/orders", orderHandler)
log.Fatal(http.ListenAndServe(":8080", mux))
}
Everything is explicit. No magic, no reflection, no XML files. Open main.go and you see exactly how your app is wired.
Pattern 2: Interface Segregation
Define interfaces where they're consumed, not where they're implemented. This is key to testable Go code.
// WRONG: Big interface defined by the implementer
// user/repository.go
type Repository interface {
GetByID(ctx context.Context, id string) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, filter Filter) ([]*User, error)
Count(ctx context.Context) (int, error)
}
// GOOD: Small interface defined by the consumer
// auth/service.go
type UserGetter interface {
GetByEmail(ctx context.Context, email string) (*User, error)
}
type AuthService struct {
users UserGetter // Only needs one method
}
func NewAuthService(users UserGetter) *AuthService {
return &AuthService{users: users}
}
Benefits:
- Easy to mock in tests (one method to implement)
- Clear dependencies (you see exactly what's needed)
- Decoupled packages (no shared interface definitions)
// auth/service_test.go
type mockUserGetter struct {
user *User
err error
}
func (m *mockUserGetter) GetByEmail(ctx context.Context, email string) (*User, error) {
return m.user, m.err
}
func TestAuthService_Login(t *testing.T) {
mock := &mockUserGetter{
user: &User{ID: "123", Email: "test@example.com"},
}
svc := NewAuthService(mock)
// Test...
}
Pattern 3: Functional Options
When constructors have many optional parameters, use functional options.
type Server struct {
host string
port int
timeout time.Duration
maxConns int
logger Logger
metrics Metrics
tlsConfig *tls.Config
}
// Option is a function that configures Server
type Option func(*Server)
func WithHost(host string) Option {
return func(s *Server) {
s.host = host
}
}
func WithPort(port int) Option {
return func(s *Server) {
s.port = port
}
}
func WithTimeout(d time.Duration) Option {
return func(s *Server) {
s.timeout = d
}
}
func WithLogger(l Logger) Option {
return func(s *Server) {
s.logger = l
}
}
func WithTLS(config *tls.Config) Option {
return func(s *Server) {
s.tlsConfig = config
}
}
func NewServer(opts ...Option) *Server {
// Defaults
s := &Server{
host: "localhost",
port: 8080,
timeout: 30 * time.Second,
maxConns: 100,
logger: defaultLogger,
metrics: noopMetrics,
}
// Apply options
for _, opt := range opts {
opt(s)
}
return s
}
// Usage
server := NewServer(
WithHost("0.0.0.0"),
WithPort(443),
WithTLS(tlsConfig),
WithLogger(zapLogger),
)
When to Use Functional Options
- Many optional parameters (3+)
- Sensible defaults exist for most parameters
- Public API where backward compatibility matters
- Builder-like configuration where order doesn't matter
Don't use for:
- Simple constructors with 1-3 required parameters
- Internal code where readability beats flexibility
Pattern 4: Config Structs
For complex configuration, a config struct is clearer than many options:
type DatabaseConfig struct {
Host string
Port int
Database string
Username string
Password string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}
func NewDatabase(cfg DatabaseConfig) (*sql.DB, error) {
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s",
cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.Database)
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
return db, nil
}
// Usage
db, err := NewDatabase(DatabaseConfig{
Host: "localhost",
Port: 5432,
Database: "myapp",
Username: "app",
Password: os.Getenv("DB_PASSWORD"),
MaxOpenConns: 25,
MaxIdleConns: 5,
ConnMaxLifetime: 5 * time.Minute,
})
Config structs work well with environment parsing:
func LoadDatabaseConfig() DatabaseConfig {
return DatabaseConfig{
Host: env.GetString("DB_HOST", "localhost"),
Port: env.GetInt("DB_PORT", 5432),
Database: env.GetString("DB_NAME", "myapp"),
Username: env.GetString("DB_USER", "postgres"),
Password: env.GetString("DB_PASSWORD", ""),
MaxOpenConns: env.GetInt("DB_MAX_OPEN_CONNS", 25),
}
}
Pattern 5: Wire for Large Applications
For applications with many dependencies, Google's Wire generates the wiring code.
// wire.go
//go:build wireinject
package main
import "github.com/google/wire"
func InitializeApp() (*App, error) {
wire.Build(
// Infrastructure
NewDatabase,
NewRedisClient,
// Repositories
NewUserRepository,
NewOrderRepository,
// Services
NewUserService,
NewOrderService,
// Handlers
NewUserHandler,
NewOrderHandler,
// App
NewApp,
)
return nil, nil
}
Run wire and it generates wire_gen.go:
// wire_gen.go (generated)
func InitializeApp() (*App, error) {
db, err := NewDatabase()
if err != nil {
return nil, err
}
redisClient := NewRedisClient()
userRepository := NewUserRepository(db)
orderRepository := NewOrderRepository(db)
userService := NewUserService(userRepository)
orderService := NewOrderService(orderRepository, userService)
userHandler := NewUserHandler(userService)
orderHandler := NewOrderHandler(orderService)
app := NewApp(userHandler, orderHandler)
return app, nil
}
Provider Sets for Organization
Group related providers:
var DatabaseSet = wire.NewSet(
NewDatabase,
NewUserRepository,
NewOrderRepository,
)
var ServiceSet = wire.NewSet(
NewUserService,
NewOrderService,
NewPaymentService,
)
var HandlerSet = wire.NewSet(
NewUserHandler,
NewOrderHandler,
)
func InitializeApp() (*App, error) {
wire.Build(
DatabaseSet,
ServiceSet,
HandlerSet,
NewApp,
)
return nil, nil
}
When to Use Wire
- Large applications with 20+ injectable dependencies
- Team projects where consistent wiring matters
- Compile-time safety is important (Wire fails at compile time if wiring is wrong)
Don't use for:
- Small to medium applications
- Learning projects
- When explicit wiring in
main()is still manageable
Anti-Patterns to Avoid
1. Global Variables
// WRONG: Global state
var db *sql.DB
var userRepo *UserRepository
func init() {
db, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))
userRepo = NewUserRepository(db)
}
func GetUser(id string) (*User, error) {
return userRepo.GetByID(context.Background(), id)
}
// RIGHT: Inject dependencies
type Handler struct {
users *UserRepository
}
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
user, err := h.users.GetByID(r.Context(), id)
// ...
}
2. Service Locator
// WRONG: Service locator pattern
type Container struct {
services map[string]any
}
func (c *Container) Get(name string) any {
return c.services[name]
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
userService := container.Get("userService").(*UserService) // Runtime error if missing
// ...
}
// RIGHT: Explicit dependencies
type Handler struct {
users *UserService
}
func NewHandler(users *UserService) *Handler {
return &Handler{users: users} // Compile-time error if missing
}
3. Hidden Dependencies
// WRONG: Hidden dependency on time.Now
func (s *Service) CreateOrder(userID string) (*Order, error) {
return &Order{
ID: uuid.New().String(),
UserID: userID,
CreatedAt: time.Now(), // Hidden dependency, hard to test
}
}
// RIGHT: Inject time function
type Service struct {
now func() time.Time
}
func NewService(now func() time.Time) *Service {
if now == nil {
now = time.Now
}
return &Service{now: now}
}
func (s *Service) CreateOrder(userID string) (*Order, error) {
return &Order{
ID: uuid.New().String(),
UserID: userID,
CreatedAt: s.now(), // Testable!
}
}
Testing with DI
The payoff of proper DI is easy testing:
func TestOrderService_Create(t *testing.T) {
// Stub dependencies
userGetter := &stubUserGetter{
user: &User{ID: "user-1", Name: "Test"},
}
orderRepo := &stubOrderRepo{}
nowFunc := func() time.Time {
return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
}
// Create service with test dependencies
svc := NewOrderService(userGetter, orderRepo, nowFunc)
// Test
order, err := svc.Create(context.Background(), CreateOrderRequest{
UserID: "user-1",
Items: []Item{{SKU: "ABC", Qty: 2}},
})
require.NoError(t, err)
assert.Equal(t, "user-1", order.UserID)
assert.Equal(t, nowFunc(), order.CreatedAt)
}
Choosing Your Approach
- Manual DI: Small-medium apps, clear dependencies, full control
- Functional Options: Many optional params, public APIs, libraries
- Config Structs: Complex configuration, environment-driven setup
- Wire: Large apps (20+ deps), team projects, compile-time safety
Key Takeaways
-
Go doesn't need DI containers. Manual wiring is explicit, debuggable, and fast.
-
Main is your composition root. All wiring happens there—one place to see the whole picture.
-
Define interfaces at the consumer. Small interfaces are easy to mock and reduce coupling.
-
Functional options for optional params. Great for public APIs, overkill for simple constructors.
-
Wire for large applications. Generates correct wiring code, catches errors at compile time.
-
Avoid globals and service locators. They hide dependencies and make testing hard.
-
Inject everything testable. Time, randomness, external services—if you might want to control it in tests, inject it.
The best DI in Go is no framework at all. Just constructors, interfaces, and explicit wiring in main. When that gets unwieldy, Wire generates the boilerplate while keeping everything explicit.