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.
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
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.
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.
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.