background-shape
Dependency Injection in Go Without a Framework
June 8, 2022 · 4 min read · by Muhammad Amal programming

TL;DR — Go DI is just constructor functions returning structs with their dependencies. Pass interfaces, return concrete types. No reflection, no annotations, no container. For >50 components, google/wire codegens the wiring. Below that, manual main.go is fine.

After the Go layout, the question of how to wire it up. Go developers from Java/Spring or PHP/Laravel reach for a “DI container.” Don’t. Go doesn’t need one. This post is what to do instead.

Constructor injection — the whole pattern

A “constructor” in Go is just a function returning a struct. Dependencies are arguments:

type StartSubscription struct {
    repo SubscriptionRepository
    pub  EventPublisher
    now  func() time.Time
}

func NewStartSubscription(repo SubscriptionRepository, pub EventPublisher, now func() time.Time) *StartSubscription {
    return &StartSubscription{repo: repo, pub: pub, now: now}
}

The struct is unexported fields; the constructor is the only way to make a valid one. Callers can’t bypass it.

The arguments are interfaces (SubscriptionRepository) or function types (func() time.Time). Concrete implementations are passed in by main.go. The struct itself is concrete (*StartSubscription), not an interface — return concrete, accept interface.

Why interfaces only at consumers

Three reasons:

  1. Smaller interfaces. Each consumer defines what it actually uses. The Postgres struct might have 30 methods; the use case interface declares the 2 it needs.
  2. No tight coupling. Adapter packages don’t need to know about every consumer.
  3. Easier to test. Mocking a 2-method interface is trivial.

This is “interface segregation” in Go-native form. Consumers define ports; providers fulfill them by happenstance of structural typing.

Manual wiring in main.go

For 10-20 components, just write it:

func main() {
    cfg := mustLoadConfig()
    pool := mustOpenPostgres(cfg.DBURL)
    redis := mustOpenRedis(cfg.RedisURL)

    // adapters
    subRepo := postgres.NewSubscriptionRepository(pool)
    custRepo := postgres.NewCustomerRepository(pool)
    pub := kafka.NewPublisher(cfg.KafkaBrokers)
    stripe := stripe.NewClient(cfg.StripeKey)
    cache := redisCache.New(redis)

    // use cases
    now := time.Now
    startSub := usecase.NewStartSubscription(subRepo, custRepo, pub, now)
    cancelSub := usecase.NewCancelSubscription(subRepo, pub, now)
    refund := usecase.NewRefund(subRepo, stripe, pub, now)
    listInvoices := usecase.NewListInvoices(subRepo, cache)

    // HTTP
    router := httpadapter.NewRouter(startSub, cancelSub, refund, listInvoices)

    // run
    srv := &http.Server{Addr: cfg.Addr, Handler: router}
    runWithGracefulShutdown(srv)
}

100 lines max. The whole dependency graph is visible at a glance. New developer reads main.go top to bottom; understands the service in 5 minutes.

When manual wire breaks down

At ~50+ components, manual wiring becomes tedious and error-prone. You’ll forget to pass a dep; you’ll wire it in the wrong order. That’s when codegen helps.

google/wire generates the wiring at build time:

//go:build wireinject

func InitializeApp(cfg Config) (*App, error) {
    wire.Build(
        postgres.NewSubscriptionRepository,
        postgres.NewCustomerRepository,
        kafka.NewPublisher,
        usecase.NewStartSubscription,
        usecase.NewCancelSubscription,
        httpadapter.NewRouter,
        wire.Struct(new(App), "*"),
    )
    return nil, nil
}

Run wire ./.... Wire reads the constructors, figures out the dependency graph, generates a wire_gen.go with the manual wiring code. You commit both.

Benefits:

  • Compile-time errors if a dep is missing
  • No runtime reflection
  • Generated code is readable; debugger steps through it

When to introduce wire:

  • 50+ constructors
  • You’ve already had a “forgot to pass now” bug at runtime
  • New team members keep getting wiring wrong

Most services I write never hit this threshold.

What NOT to use

Reflection-based DI containers (uber/fx, dig). Add runtime overhead, hide the dependency graph, debugging is opaque. Avoid unless you have a specific reason.

Service locator patterns. Singleton registry that things look themselves up in. Defeats explicit dependencies; hides what’s needed.

Global variables. “Just put the DB pool in a package-level var” — works until you need a second DB pool, until you need to test with a fake, until you need to lazy-init. Pass explicitly.

Testing with constructor injection

The payoff:

func TestStartSubscription_HappyPath(t *testing.T) {
    fakeRepo := &fakeRepo{}
    fakePub := &fakePub{}
    fixedNow := func() time.Time { return time.Date(2022, 6, 1, 0, 0, 0, 0, time.UTC) }

    uc := usecase.NewStartSubscription(fakeRepo, fakePub, fixedNow)

    sub, err := uc.Execute(context.Background(), usecase.StartSubscriptionInput{
        CustomerID: uuid.New(),
        PlanID:     "pro_monthly",
    })

    require.NoError(t, err)
    require.Equal(t, domain.StatusActive, sub.Status)
    require.Equal(t, 1, fakeRepo.saveCount)
    require.Equal(t, 1, fakePub.publishCount)
}

fakeRepo and fakePub are 30-line structs in _test.go. No mock framework needed. Fast, debuggable, clear.

For when you need behaviour-rich mocks, stretchr/testify/mock is fine. But hand-rolled fakes are usually clearer.

Common Pitfalls

Passing concrete types instead of interfaces. Use case takes *postgres.SubscriptionRepository; you can’t swap for tests. Always accept interface.

Returning interface from constructors. func New() Storage — caller can’t extend the type. Return concrete: func New() *PostgresStorage. Callers can hold as Storage if they want.

Initialization order bugs. “X needs Y but Y isn’t constructed yet.” Sequence main.go to build deps before consumers. Compiler can’t help here, but the linear top-to-bottom wiring makes it obvious.

Hiding wiring in init() functions. init() runs at import time, magic. Wiring should be explicit in main.

Mocking everything. Some real types are cheap to use directly: time.Now as a function, in-memory implementations of small interfaces. Don’t mock for the sake of mocking.

Wrapping Up

DI in Go is constructor functions + interfaces + main.go wiring. No framework. For very large services, codegen via wire. For everything else, manual is clearer. Friday: domain entities in Go — what goes inside the structs being wired up.