background-shape
Ports and Adapters in Go 1.21 with Wire and uber-go/fx
October 12, 2023 · 8 min read · by Muhammad Amal programming

TL;DR — Go’s implicit interfaces make hexagonal architecture cheaper than in any other mainstream language / Wire is the right default for compile-time DI; fx is better for runtime-heavy services / Hand-rolled wiring works fine up to ~20 dependencies, then it stops scaling

The Go community has a strange relationship with dependency injection. There is a vocal contingent that insists “Go doesn’t need DI frameworks, just pass structs around.” There is another contingent shipping production codebases with uber-go/fx doing lifecycle management for 200+ components. Both groups are right within their domain, and the trick is knowing which side of the line you are on.

I have worked on a 30-package Go monolith where Wire was indispensable, and a 5-package CLI tool where it would have been laughable overhead. This post is about how to decide, and how to do it once you have decided. I assume you already know why ports and adapters is worth doing — this post is about the mechanics in Go specifically.

The Hand-Rolled Baseline

Before reaching for any framework, here is what production-quality wiring looks like in vanilla Go 1.21. This is from a real service I maintain:

// cmd/api/main.go
package main

import (
    "context"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "example.com/billing/internal/billing"
    "example.com/billing/internal/config"
    "example.com/billing/internal/postgres"
    "example.com/billing/internal/stripe"
    httpapi "example.com/billing/internal/transport/http"
)

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

func run() error {
    cfg, err := config.Load()
    if err != nil {
        return err
    }

    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))

    db, err := postgres.Open(cfg.DatabaseURL)
    if err != nil {
        return err
    }
    defer db.Close()

    invoiceRepo := postgres.NewInvoiceRepository(db)
    paymentGateway := stripe.NewGateway(cfg.StripeSecret, logger)
    clock := billing.SystemClock{}

    markPaid := &billing.MarkInvoicePaidService{
        Invoices: invoiceRepo,
        Payments: paymentGateway,
        Clock:    clock,
    }

    handler := &httpapi.InvoiceHandler{
        MarkPaid: markPaid,
        Logger:   logger,
    }

    srv := &http.Server{
        Addr:              cfg.HTTPAddr,
        Handler:           httpapi.Router(handler),
        ReadHeaderTimeout: 5 * time.Second,
    }

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

    go func() {
        <-ctx.Done()
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer cancel()
        _ = srv.Shutdown(shutdownCtx)
    }()

    logger.Info("starting", "addr", cfg.HTTPAddr)
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        return err
    }
    return nil
}

This is fine. For a service with one HTTP handler, one repo, and one external client, this is the right amount of structure. The dependencies are explicit, the order is visible, and you can read top-to-bottom what gets wired into what. Notice the use of log/slog, which landed in Go 1.21 — it is the new stdlib structured logging package and you should be using it everywhere going forward.

The trouble starts when this run function gets to 200 lines, when you have three handlers and seven adapters and a worker pool and a queue consumer and a metrics exporter and a cache layer. At that point you are doing what frameworks exist to do, but doing it badly.

When Wire Earns Its Keep

Google’s Wire is a code generator. You declare provider functions, list them in a “wire set”, and wire generates the wiring code at build time. There is no runtime reflection, no magic — the generated wire_gen.go is the same code you would have written by hand.

// internal/billing/wire_set.go
package billing

import "github.com/google/wire"

var Set = wire.NewSet(
    NewMarkInvoicePaidService,
    NewIssueRefundService,
    NewCancelSubscriptionService,
    SystemClock{},
    wire.Bind(new(Clock), new(SystemClock)),
)
// cmd/api/wire.go
//go:build wireinject

package main

import (
    "github.com/google/wire"

    "example.com/billing/internal/billing"
    "example.com/billing/internal/config"
    "example.com/billing/internal/postgres"
    "example.com/billing/internal/stripe"
    httpapi "example.com/billing/internal/transport/http"
)

func buildServer(cfg config.Config) (*Server, error) {
    wire.Build(
        billing.Set,
        postgres.Set,
        stripe.Set,
        httpapi.Set,
        NewServer,
    )
    return nil, nil
}

Run wire ./cmd/api and it generates the real buildServer. Your main.go becomes a two-line call.

What Wire buys you:

  1. Compile-time guarantees. If a dependency cannot be resolved, you find out at wire generate time, not at startup. No surprise nil interface at 2am.
  2. No reflection. The generated code is exactly what you would write. You can read it. You can step through it.
  3. No runtime cost. The graph is resolved once, at build.

What Wire does not give you: lifecycle management. If your dependencies need to be started in a particular order, stopped in reverse, or supervised — Wire is silent on all of that. You handle it yourself, or you reach for fx.

When fx Earns Its Keep

Uber’s fx is a runtime DI container with lifecycle support. It uses reflection. Some people hate that on principle; in practice for a long-running service starting in 30ms, it does not matter.

What fx buys you over Wire:

  • OnStart / OnStop hooks, so each component declares how it starts and stops. fx runs them in dependency order.
  • Optional dependencies, named dependencies, grouped dependencies — for plugin-style architectures.
  • Built-in logging of the dependency graph at startup, which is genuinely useful for debugging.

A small fx app:

package main

import (
    "context"
    "log/slog"
    "net/http"
    "os"

    "go.uber.org/fx"

    "example.com/billing/internal/billing"
    "example.com/billing/internal/config"
    "example.com/billing/internal/postgres"
    "example.com/billing/internal/stripe"
    httpapi "example.com/billing/internal/transport/http"
)

func main() {
    app := fx.New(
        fx.Provide(
            config.Load,
            newLogger,
            postgres.NewPool,
            postgres.NewInvoiceRepository,
            fx.Annotate(
                postgres.NewInvoiceRepository,
                fx.As(new(billing.InvoiceRepository)),
            ),
            stripe.NewGateway,
            fx.Annotate(
                stripe.NewGateway,
                fx.As(new(billing.PaymentGateway)),
            ),
            billing.NewMarkInvoicePaidService,
            httpapi.NewRouter,
            newServer,
        ),
        fx.Invoke(registerServer),
    )

    app.Run()
}

func newLogger() *slog.Logger {
    return slog.New(slog.NewJSONHandler(os.Stdout, nil))
}

func newServer(router http.Handler, cfg config.Config) *http.Server {
    return &http.Server{Addr: cfg.HTTPAddr, Handler: router}
}

func registerServer(lc fx.Lifecycle, srv *http.Server, logger *slog.Logger) {
    lc.Append(fx.Hook{
        OnStart: func(ctx context.Context) error {
            logger.Info("starting", "addr", srv.Addr)
            go func() { _ = srv.ListenAndServe() }()
            return nil
        },
        OnStop: func(ctx context.Context) error {
            logger.Info("shutting down")
            return srv.Shutdown(ctx)
        },
    })
}

app.Run() blocks until SIGINT/SIGTERM and handles graceful shutdown by walking the lifecycle hooks in reverse order. That is genuinely valuable when you have a Kafka consumer that needs to drain before the database pool closes, which needs to close before the metrics flush, and so on. Hand-rolling that ordering is doable but error-prone.

How I Actually Decide

A rough heuristic from running both in production:

  • < 10 dependencies, no complex lifecycle: hand-rolled main.
  • 10-30 dependencies, simple lifecycle: Wire.
  • 30+ dependencies OR complex lifecycle (queues, consumers, schedulers, supervised goroutines): fx.
  • Many plugins / optional features / build-time variants: fx, because the annotation system is better for it.

The “complex lifecycle” axis matters more than the count. A 50-dependency service that is all repos and external clients is fine with Wire. A 15-dependency service with three background workers and a graceful-shutdown story is easier with fx.

The Adapter Layout

Independent of which DI tool, the package layout should look like this:

internal/
  billing/                  <-- domain + application
    invoice.go              <-- entity
    invoice_id.go           <-- value object
    mark_invoice_paid.go    <-- use case (application service)
    ports.go                <-- interfaces this package depends on
  postgres/                 <-- secondary adapter
    invoice_repository.go
    pool.go
  stripe/                   <-- secondary adapter
    gateway.go
  transport/
    http/                   <-- primary adapter
      invoice_handler.go
      router.go
    grpc/                   <-- primary adapter (if you have one)

The domain package (billing) imports nothing from the adapter packages. The adapter packages import the domain to satisfy its interfaces. A go list -deps ./internal/billing should not show postgres, stripe, or http in the output.

You can enforce this in CI:

# scripts/check-deps.sh
set -e
DOMAIN_DEPS=$(go list -deps ./internal/billing | sort -u)
if echo "$DOMAIN_DEPS" | grep -E "internal/(postgres|stripe|transport)"; then
  echo "Domain depends on adapter packages"
  exit 1
fi

Crude, but ships in 30 seconds and catches the regression that matters.

Common Pitfalls

Things that go wrong when teams adopt this in Go:

  • Interface in the wrong package. The interface should live in the package that consumes it, not the package that implements it. billing.InvoiceRepository lives in internal/billing, not internal/postgres. This is the opposite of what Java teaches you, and it is the right answer in Go because of how Go’s implicit interface satisfaction works.
  • Mock generation for every interface. Generating mocks for every two-method interface is overkill in Go. For most adapters, write a hand-rolled fake (MemoryInvoiceRepository) that lives in a *_test.go file. Mocks are for the genuinely complex cases.
  • Wire imports in non-wire files. The wire.Build call must be in a file with the //go:build wireinject tag, and that file must not be part of the regular build. Forgetting this leads to confusing duplicate-function errors.
  • fx.Invoke for things that should be fx.Provide. Invoke runs functions for side effects at startup. Provide registers constructors. People reach for Invoke when they want a server to start, but Invoke for that is correct — the misuse goes the other way, using Invoke to provide a value.
  • Singleton-by-default container thinking. In Go DI, everything is a singleton in the app graph. If you need a per-request value (a tracing span, a request-scoped logger), do not put it in the DI graph. Pass it through context.Context.
  • Cyclic interface dependencies. Two domain packages with interfaces referencing each other. The fix is almost always a third package that defines the shared contract, not “make one of them not an interface.”

The “interface in the consumer” rule is the single most violated and most important one. Get this right and every other architectural choice gets easier.

Wrapping Up

Hexagonal architecture in Go is mostly free because the language was designed for it. Implicit interfaces, no inheritance, packages that hide their internals — the language nudges you toward ports and adapters without you having to fight it. The tooling choice (Wire, fx, or none) is a productivity question, not an architecture question; the architecture is the same in all three.

Next post I will get into the domain layer specifically — what makes an entity an entity, what value objects buy you, and where I think most Laravel codebases lose the plot. The answer to “should this be a domain service or an entity method” is usually clearer than people think.