April 10, 2026 10 min read

Go Dependency Injection Without Frameworks for Payment Services

Every Go DI framework I've tried added complexity without solving the actual problem. After building three payment platforms, I've settled on plain constructor injection — and it scales better than any framework ever did.

Why Payment Services Need Clean DI

Payment services have a unique dependency problem. A single charge request might touch a card network client, a fraud engine, a ledger, a notification service, and an audit logger. That's five dependencies before you even get to the database.

When I joined a payment startup in 2023, the codebase had global singletons everywhere. The fraud client was initialized in an init() function. The database pool lived in a package-level variable. Testing meant spinning up the entire world or not testing at all.

We needed DI. But we didn't need a framework.

Before: Global State
var db = postgres.Connect()
var fraud = fraud.NewClient()
var ledger = ledger.Global
// Untestable, coupled, init() races
After: Constructor Injection
func NewChargeService(
  db DB, fraud FraudChecker,
  ledger Ledger,
) *ChargeService { ... }
// Testable, explicit, no surprises

The Pattern: Interfaces at the Boundary

The core idea is dead simple. Define interfaces where your service meets the outside world. Accept those interfaces in constructors. Wire everything up in main().

Here's a real example from a charge processing service:

// domain/charge.go — define what you need, not what exists
type CardAuthorizer interface {
    Authorize(ctx context.Context, req AuthRequest) (AuthResponse, error)
}

type FraudChecker interface {
    Evaluate(ctx context.Context, txn Transaction) (RiskScore, error)
}

type Ledger interface {
    Record(ctx context.Context, entry LedgerEntry) error
}

type ChargeService struct {
    authorizer CardAuthorizer
    fraud      FraudChecker
    ledger     Ledger
    logger     *slog.Logger
}

func NewChargeService(
    auth CardAuthorizer,
    fraud FraudChecker,
    ledger Ledger,
    logger *slog.Logger,
) *ChargeService {
    return &ChargeService{
        authorizer: auth,
        fraud:      fraud,
        ledger:     ledger,
        logger:     logger,
    }
}

Key insight: Define interfaces in the package that uses them, not the package that implements them. This is the opposite of Java-style DI, and it's what makes Go's implicit interface satisfaction so powerful. Your ChargeService doesn't need to know about Stripe's SDK — it just needs something that can authorize a card.

Wiring It Up in main()

This is where people expect magic. There is none. You just... construct things in order.

// cmd/server/main.go
func main() {
    cfg := config.Load()

    // Infrastructure layer
    db, err := postgres.Open(cfg.DatabaseURL)
    if err != nil {
        log.Fatal("db connect failed", "error", err)
    }
    defer db.Close()

    redisClient := redis.NewClient(cfg.RedisURL)
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    // Adapters — implement domain interfaces
    stripeAuth := stripe.NewAuthorizer(cfg.StripeKey)
    fraudClient := maxmind.NewChecker(cfg.MaxMindKey)
    pgLedger := postgres.NewLedger(db)

    // Domain services — accept interfaces
    chargeSvc := domain.NewChargeService(stripeAuth, fraudClient, pgLedger, logger)
    refundSvc := domain.NewRefundService(stripeAuth, pgLedger, logger)

    // HTTP handlers — accept domain services
    handler := api.NewHandler(chargeSvc, refundSvc)

    srv := &http.Server{Addr: ":8080", Handler: handler.Routes()}
    log.Fatal(srv.ListenAndServe())
}

Yes, main() gets long. That's fine. It's the one place in your codebase where everything is concrete, and you can see every dependency at a glance. When a new engineer joins, they read main() and understand the entire system topology in five minutes.

Dependency Flow — Outer to Inner
main()
HTTP Handlers
Domain Services
Interfaces
Dependencies point inward. Domain never imports infrastructure.

Testing Becomes Trivial

This is where the payoff hits. When your service accepts interfaces, testing is just... passing in fakes.

// domain/charge_test.go
type mockAuthorizer struct {
    response AuthResponse
    err      error
}

func (m *mockAuthorizer) Authorize(ctx context.Context, req AuthRequest) (AuthResponse, error) {
    return m.response, m.err
}

func TestChargeService_DeclinedCard(t *testing.T) {
    auth := &mockAuthorizer{
        response: AuthResponse{Status: "declined", Code: "51"},
    }
    fraud := &mockFraudChecker{score: RiskScore{Value: 10}}
    ledger := &mockLedger{}

    svc := NewChargeService(auth, fraud, ledger, slog.Default())

    result, err := svc.ProcessCharge(context.Background(), ChargeRequest{
        Amount:   2000,
        Currency: "usd",
        CardToken: "tok_declined",
    })

    require.NoError(t, err)
    assert.Equal(t, "declined", result.Status)
    assert.False(t, ledger.recorded, "declined charges should not hit the ledger")
}

No test containers. No mock frameworks. No HTTP interception. The test runs in milliseconds and tells you exactly what the service does when a card is declined.

4ms
Avg unit test time
92%
Coverage without mocking libs
0
DI framework dependencies

When Constructor Args Get Out of Hand

The most common pushback: "My service has 8 dependencies, the constructor is huge." Fair. Here's what I do.

First, ask if the service is doing too much. A ChargeService that also handles refunds, disputes, and reporting is a god object. Split it.

If the dependencies are genuinely needed, group related ones into a struct:

type PaymentDeps struct {
    Authorizer  CardAuthorizer
    Fraud       FraudChecker
    Ledger      Ledger
    Notifier    Notifier
    AuditLog    AuditLogger
}

func NewChargeService(deps PaymentDeps, logger *slog.Logger) *ChargeService {
    return &ChargeService{deps: deps, logger: logger}
}

Warning: Don't use this as an excuse to create a "god deps" struct that every service shares. Each service should define its own deps struct with only what it needs. The moment you have a ServiceDeps that's imported by 15 packages, you've recreated the global state problem with extra steps.

The Functional Options Trap

I see teams reach for functional options (WithLogger(), WithTimeout()) for DI. Don't. Functional options are great for configuring behavior — timeouts, retry policies, buffer sizes. They're terrible for injecting core dependencies.

The problem is that optional dependencies hide requirements. If your charge service can't function without a ledger, making the ledger optional via WithLedger() means you'll discover the missing dependency at runtime instead of compile time.

// Bad — hides a hard requirement
svc := NewChargeService(
    WithAuthorizer(stripe),  // what if I forget this?
    WithLedger(pgLedger),
)

// Good — compiler catches missing deps
svc := NewChargeService(stripe, pgLedger, logger)

Required dependencies go in the constructor. Optional configuration goes in functional options. Keep them separate.

Real-World Wiring: Multiple Payment Providers

Here's where this pattern really shines. We needed to support Stripe for domestic cards and Adyen for international cards. With interface-based DI, the routing logic lives in a thin adapter:

type RoutingAuthorizer struct {
    domestic      CardAuthorizer
    international CardAuthorizer
    resolver      CountryResolver
}

func (r *RoutingAuthorizer) Authorize(ctx context.Context, req AuthRequest) (AuthResponse, error) {
    country := r.resolver.Resolve(req.CardBIN)
    if country == "US" || country == "CA" {
        return r.domestic.Authorize(ctx, req)
    }
    return r.international.Authorize(ctx, req)
}

// In main():
router := &RoutingAuthorizer{
    domestic:      stripe.NewAuthorizer(cfg.StripeKey),
    international: adyen.NewAuthorizer(cfg.AdyenKey),
    resolver:      bin.NewResolver(binTable),
}
chargeSvc := domain.NewChargeService(router, fraudClient, pgLedger, logger)

The ChargeService didn't change at all. It still sees a CardAuthorizer. The routing decision is completely external to the domain logic. We added a third provider (Checkout.com for APAC) six months later and touched zero domain code.

What About Wire?

Google's Wire is the closest thing to a "good" DI framework in Go. It generates the wiring code at compile time, so there's no runtime reflection. I've used it on one project.

My take: Wire is worth it when your main() exceeds ~200 lines of wiring and you have multiple binaries sharing dependency graphs. For most payment services, you won't hit that threshold. And the cognitive overhead of Wire's provider sets and injector functions isn't free — every new engineer needs to learn Wire before they can understand your startup sequence.

Start with manual wiring. Move to Wire only when the pain is real, not theoretical.

The Checklist

After three payment platforms, here's what I always do:

  1. Define interfaces in the consumer package. Your domain package owns the interface definitions. Infrastructure packages implement them without importing domain.
  2. Required deps in constructors, config in options. If the service can't work without it, it's a constructor parameter.
  3. Wire in main(), nowhere else. No service should construct its own dependencies. No package-level init() functions that create clients.
  4. One interface per dependency. Don't create a PaymentGateway interface with 20 methods. Split into CardAuthorizer, Refunder, TokenVault.
  5. Test with plain structs. If you need a mocking library, your interfaces are probably too big.

The beauty of this approach is that there's nothing to learn. No annotations, no container lifecycle, no provider registration. It's just Go. And when you're debugging a production payment failure at 2 AM, "just Go" is exactly what you want.

References

Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Always verify with official documentation.