Dependency Injection in Go Without Frameworks

How Andromeda achieves clean dependency injection using constructor functions, interfaces, and manual wiring, avoiding the complexity of DI frameworks and reflection-based containers.

technical8 min readBy Klivvr Engineering
Share:

Dependency injection in the Java and .NET ecosystems typically involves a framework: Spring, Guice, or Microsoft's built-in DI container. These frameworks use reflection, annotations, or configuration files to wire dependencies automatically. Go developers often ask whether they need something similar. Our experience with Andromeda says no. Go's simplicity, combined with a few straightforward patterns, gives you all the benefits of dependency injection, testability, loose coupling, and clear dependency graphs, without the magic and indirection of a framework.

This article walks through the DI patterns we use in every Andromeda service. The approach is manual, explicit, and boring in the best possible way.

Constructor Injection

The fundamental pattern is constructor injection: every struct receives its dependencies as arguments to its constructor function. No struct ever creates its own dependencies.

// internal/payments/app/service.go
package app
 
import (
    "context"
    "fmt"
    "log/slog"
 
    "github.com/klivvr/andromeda/internal/payments/domain"
)
 
// PaymentService is the application service for payment processing.
type PaymentService struct {
    repo       PaymentRepository
    accounts   AccountClient
    publisher  EventPublisher
    idGen      IDGenerator
    logger     *slog.Logger
}
 
// NewPaymentService constructs a PaymentService with all its dependencies.
func NewPaymentService(
    repo PaymentRepository,
    accounts AccountClient,
    publisher EventPublisher,
    idGen IDGenerator,
    logger *slog.Logger,
) *PaymentService {
    return &PaymentService{
        repo:      repo,
        accounts:  accounts,
        publisher: publisher,
        idGen:     idGen,
        logger:    logger,
    }
}

Every dependency is an interface defined in the same package or a concrete type from a standard or well-known library (like *slog.Logger). The interfaces are small, often just one or two methods:

// internal/payments/app/ports.go
package app
 
import (
    "context"
 
    "github.com/klivvr/andromeda/internal/payments/domain"
)
 
type PaymentRepository interface {
    FindByID(ctx context.Context, id string) (*domain.Payment, error)
    Save(ctx context.Context, payment *domain.Payment) error
    ListByAccount(ctx context.Context, accountID string, limit, offset int) ([]*domain.Payment, error)
}
 
type AccountClient interface {
    Debit(ctx context.Context, accountID string, amount domain.Money) error
    Credit(ctx context.Context, accountID string, amount domain.Money) error
}
 
type EventPublisher interface {
    PaymentCompleted(ctx context.Context, paymentID, accountID string, amount int64) error
    PaymentFailed(ctx context.Context, paymentID, accountID string, reason string) error
}
 
type IDGenerator interface {
    New() string
}

Small interfaces are idiomatic Go. They are easy to implement for both production adapters and test doubles. They follow the interface segregation principle naturally: a consumer defines only the methods it needs.

The Wiring Function

If constructors are the leaves, the wiring function is the root. Every service has a single function, typically in cmd/<service>/main.go or a dedicated cmd/<service>/wire.go, that creates all dependencies and wires them together:

// cmd/payments/main.go
package main
 
import (
    "context"
    "database/sql"
    "log/slog"
    "net"
    "os"
    "os/signal"
    "syscall"
 
    "github.com/klivvr/andromeda/internal/payments/app"
    "github.com/klivvr/andromeda/internal/payments/infra"
    "github.com/klivvr/andromeda/internal/payments/ports"
    "github.com/klivvr/andromeda/pkg/idgen"
    "github.com/klivvr/andromeda/pkg/natsutil"
 
    "github.com/nats-io/nats.go"
    "google.golang.org/grpc"
)
 
func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))
 
    // Infrastructure
    db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
    if err != nil {
        logger.Error("opening database", "error", err)
        os.Exit(1)
    }
    defer db.Close()
 
    nc, err := nats.Connect(os.Getenv("NATS_URL"))
    if err != nil {
        logger.Error("connecting to NATS", "error", err)
        os.Exit(1)
    }
    defer nc.Close()
 
    js, err := nc.JetStream()
    if err != nil {
        logger.Error("creating JetStream context", "error", err)
        os.Exit(1)
    }
 
    // Adapters
    paymentRepo := infra.NewPostgresPaymentRepository(db)
    accountClient := infra.NewGRPCAccountClient(os.Getenv("ACCOUNTS_GRPC_ADDR"))
    eventPublisher := infra.NewNATSEventPublisher(js)
    idGenerator := idgen.NewULIDGenerator()
 
    // Application service
    paymentService := app.NewPaymentService(
        paymentRepo,
        accountClient,
        eventPublisher,
        idGenerator,
        logger,
    )
 
    // gRPC server
    grpcServer := grpc.NewServer()
    ports.RegisterPaymentsServer(grpcServer, ports.NewGRPCServer(paymentService))
 
    // Start server
    lis, err := net.Listen("tcp", ":8080")
    if err != nil {
        logger.Error("listening", "error", err)
        os.Exit(1)
    }
 
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()
 
    go func() {
        logger.Info("starting gRPC server", "addr", lis.Addr().String())
        if err := grpcServer.Serve(lis); err != nil {
            logger.Error("serving", "error", err)
        }
    }()
 
    <-ctx.Done()
    logger.Info("shutting down")
    grpcServer.GracefulStop()
}

This function is long, and that is fine. It is the one place where the entire dependency graph is visible. When you read it top to bottom, you see exactly how the service is assembled. There is no indirection, no container, no configuration file to cross-reference. If a dependency is missing, the compiler tells you. If a dependency is unused, the compiler tells you that too.

Option Patterns for Configuration

Some dependencies have optional configuration. Rather than adding more constructor parameters, we use the functional options pattern:

// pkg/grpcutil/client.go
package grpcutil
 
import (
    "time"
 
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
)
 
type ClientOption func(*clientConfig)
 
type clientConfig struct {
    timeout       time.Duration
    maxRetries    int
    insecureCreds bool
}
 
func WithTimeout(d time.Duration) ClientOption {
    return func(c *clientConfig) {
        c.timeout = d
    }
}
 
func WithMaxRetries(n int) ClientOption {
    return func(c *clientConfig) {
        c.maxRetries = n
    }
}
 
func WithInsecure() ClientOption {
    return func(c *clientConfig) {
        c.insecureCreds = true
    }
}
 
func Dial(addr string, opts ...ClientOption) (*grpc.ClientConn, error) {
    cfg := clientConfig{
        timeout:    5 * time.Second,
        maxRetries: 3,
    }
    for _, opt := range opts {
        opt(&cfg)
    }
 
    dialOpts := []grpc.DialOption{}
    if cfg.insecureCreds {
        dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
    }
 
    return grpc.NewClient(addr, dialOpts...)
}

This pattern keeps the common case simple (grpcutil.Dial("addr")) while allowing callers to customize behavior when needed (grpcutil.Dial("addr", grpcutil.WithTimeout(10*time.Second))).

Testing with Manual Doubles

The payoff of constructor injection is effortless testing. Because every dependency is an interface, you can substitute test doubles without any mocking framework:

// internal/payments/app/service_test.go
package app_test
 
import (
    "context"
    "log/slog"
    "testing"
 
    "github.com/klivvr/andromeda/internal/payments/app"
    "github.com/klivvr/andromeda/internal/payments/domain"
)
 
// fakePaymentRepository is a test double that stores payments in memory.
type fakePaymentRepository struct {
    payments map[string]*domain.Payment
}
 
func newFakePaymentRepository() *fakePaymentRepository {
    return &fakePaymentRepository{payments: make(map[string]*domain.Payment)}
}
 
func (f *fakePaymentRepository) FindByID(_ context.Context, id string) (*domain.Payment, error) {
    p, ok := f.payments[id]
    if !ok {
        return nil, domain.ErrNotFound
    }
    return p, nil
}
 
func (f *fakePaymentRepository) Save(_ context.Context, payment *domain.Payment) error {
    f.payments[payment.ID()] = payment
    return nil
}
 
func (f *fakePaymentRepository) ListByAccount(_ context.Context, accountID string, limit, offset int) ([]*domain.Payment, error) {
    var result []*domain.Payment
    for _, p := range f.payments {
        if p.AccountID() == accountID {
            result = append(result, p)
        }
    }
    return result, nil
}
 
// fakeAccountClient tracks debit and credit calls.
type fakeAccountClient struct {
    debits  []debitCall
    credits []creditCall
}
 
type debitCall struct {
    AccountID string
    Amount    domain.Money
}
 
type creditCall struct {
    AccountID string
    Amount    domain.Money
}
 
func (f *fakeAccountClient) Debit(_ context.Context, accountID string, amount domain.Money) error {
    f.debits = append(f.debits, debitCall{AccountID: accountID, Amount: amount})
    return nil
}
 
func (f *fakeAccountClient) Credit(_ context.Context, accountID string, amount domain.Money) error {
    f.credits = append(f.credits, creditCall{AccountID: accountID, Amount: amount})
    return nil
}
 
// fakeEventPublisher records published events.
type fakeEventPublisher struct {
    completed []string
    failed    []string
}
 
func (f *fakeEventPublisher) PaymentCompleted(_ context.Context, paymentID, _ string, _ int64) error {
    f.completed = append(f.completed, paymentID)
    return nil
}
 
func (f *fakeEventPublisher) PaymentFailed(_ context.Context, paymentID, _ string, _ string) error {
    f.failed = append(f.failed, paymentID)
    return nil
}
 
// staticIDGenerator always returns the same ID.
type staticIDGenerator struct {
    id string
}
 
func (g *staticIDGenerator) New() string { return g.id }
 
func TestProcessPayment_Success(t *testing.T) {
    repo := newFakePaymentRepository()
    accounts := &fakeAccountClient{}
    publisher := &fakeEventPublisher{}
    idGen := &staticIDGenerator{id: "pay_test_123"}
    logger := slog.Default()
 
    svc := app.NewPaymentService(repo, accounts, publisher, idGen, logger)
 
    err := svc.ProcessPayment(context.Background(), app.ProcessPaymentRequest{
        AccountID: "acc_456",
        Amount:    1000,
        Currency:  "EGP",
    })
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
 
    // Verify the payment was saved
    payment, err := repo.FindByID(context.Background(), "pay_test_123")
    if err != nil {
        t.Fatalf("payment not found: %v", err)
    }
    if payment.AccountID() != "acc_456" {
        t.Errorf("account ID = %q, want %q", payment.AccountID(), "acc_456")
    }
 
    // Verify the account was debited
    if len(accounts.debits) != 1 {
        t.Fatalf("expected 1 debit, got %d", len(accounts.debits))
    }
 
    // Verify the event was published
    if len(publisher.completed) != 1 {
        t.Fatalf("expected 1 completed event, got %d", len(publisher.completed))
    }
}

No mocking library. No reflection. No generated code. Just Go structs that implement interfaces. The test reads clearly, the assertions are straightforward, and the setup is explicit. When a test fails, the error message points directly to the problem.

When to Consider a DI Framework

We do not use a DI framework in Andromeda, but we do not dismiss them categorically. Google's Wire and Uber's fx are well-designed tools that solve real problems. Wire generates the wiring code at compile time, eliminating the boilerplate of manual wiring. fx provides runtime dependency injection with lifecycle management.

Consider Wire when your wiring function exceeds a few hundred lines and the boilerplate becomes genuinely burdensome. Consider fx when you need sophisticated lifecycle management (ordered startup, graceful shutdown, health checks) that would be tedious to implement manually.

We have not reached that threshold. Our largest service has about 25 dependencies, and the wiring function is around 80 lines. The explicitness is still a net benefit. If that changes, Wire is our likely next step because it generates code rather than using reflection, keeping the debugging experience simple.

Conclusion

Dependency injection in Go does not require a framework. Constructor injection, small interfaces, a single wiring function, and hand-written test doubles give you the core benefits, testability, loose coupling, and a visible dependency graph, with none of the indirection and complexity of a container. The wiring function is the only place that knows how everything fits together, and its explicitness is a feature that pays dividends during debugging, onboarding, and code review.

The key takeaway is to resist the urge to automate the wiring before the pain justifies it. In most Go services, the wiring function is a small fraction of the total code. Writing it manually takes minutes. Reading it takes seconds. And when something goes wrong, there is no framework to debug, just straightforward Go code that creates structs and passes them to constructors.

Related Articles

technical

Testing Strategies for Go Backend Services

A comprehensive guide to testing Go backend services, covering unit tests, integration tests, end-to-end tests, table-driven patterns, test fixtures, and strategies for testing gRPC and NATS-based systems.

11 min read
business

How Monorepos Boost Team Productivity

An exploration of how monorepo architecture improves developer velocity, code quality, and cross-team collaboration, based on real-world experience with Andromeda.

9 min read
technical

Observability in Go: Tracing, Metrics, and Logging

A practical guide to implementing observability in Go backend services using OpenTelemetry for tracing, Prometheus for metrics, and structured logging with log/slog.

7 min read