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.
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.
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.
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:
- Define interfaces in the consumer package. Your domain package owns the interface definitions. Infrastructure packages implement them without importing domain.
- Required deps in constructors, config in options. If the service can't work without it, it's a constructor parameter.
- Wire in main(), nowhere else. No service should construct its own dependencies. No package-level
init()functions that create clients. - One interface per dependency. Don't create a
PaymentGatewayinterface with 20 methods. Split intoCardAuthorizer,Refunder,TokenVault. - 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
- Effective Go — Interfaces
- Google Wire — Compile-time Dependency Injection for Go
- Go Blog — Structured Logging with slog
- Go Code Review Comments — Interfaces
- GopherCon — Advanced Testing with Go (Mitchell Hashimoto)
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.