Building maintainable APIs without magic
Most developers learn Go and quickly discover that the language has no built-in dependency injection container. That's intentional. Go's philosophy pushes you toward explicit, readable code—and that clarity becomes your greatest asset in production. We've shipped dozens of APIs across e-commerce, trading, and SaaS platforms, and every time we resisted the urge to bolt on a framework, our code stayed cleaner, our onboarding faster, and our debugging sessions shorter. Go dependency injection: real-world lessons and examples shows you that you don't need frameworks. You need to understand constructors, interfaces, and how to wire dependencies in your entry point.
Why Go's "no magic" approach matters
Before we jump into code, let's talk about why Go's minimal approach to DI wins in production. When you open a service written by a colleague, you want to know exactly which dependencies flow where. In many languages, a DI container uses reflection and annotations to wire everything behind the scenes. That means you must learn the container's rules, debug its output, and sometimes spend an hour tracking down why a bean isn't being injected.
Go forces you to be explicit. Every dependency is passed to a constructor. Every constructor is a function call in main.go. This makes code review easier, tests simpler, and onboarding painless. You read main.go and see the entire dependency graph in one place. That's powerful.
Constructor functions: the foundation
Let's start with a real example. Imagine we're building an order service for an e-commerce platform. We have a database, a logger, a payment gateway, and a metrics client. Each of these is a dependency.
We wrap each logical component in a type and provide a constructor function that accepts its dependencies and returns a fully configured instance:
package orders
import (
"database/sql"
"log/slog"
)
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) GetOrder(ctx context.Context, id string) (*Order, error) {
row := s.db.QueryRowContext(ctx, "SELECT id, customer_id, total FROM orders WHERE id = $1", id)
var o Order
err := row.Scan(&o.ID, &o.CustomerID, &o.Total)
if err != nil {
return nil, err
}
return &o, nil
}
type Service struct {
store *Store
logger *slog.Logger
pay PaymentGateway
}
func NewService(store *Store, logger *slog.Logger, pay PaymentGateway) *Service {
return &Service{
store: store,
logger: logger,
pay: pay,
}
}
func (s *Service) PlaceOrder(ctx context.Context, order *Order) error {
s.logger.InfoContext(ctx, "placing order", "id", order.ID)
if err := s.pay.Charge(ctx, order.CustomerID, order.Total); err != nil {
s.logger.ErrorContext(ctx, "payment failed", "error", err)
return fmt.Errorf("charge failed: %w", err)
}
if err := s.store.SaveOrder(ctx, order); err != nil {
s.logger.ErrorContext(ctx, "save failed", "error", err)
return fmt.Errorf("save failed: %w", err)
}
return nil
}Notice the pattern. Each constructor is a simple function that takes dependencies as parameters and returns a pointer to the struct. No reflection, no tags, no magic. A junior engineer reading this code understands exactly what the Service needs to do its job.
Wiring the dependency graph in main
Now we move to the entry point. In main.go, we build the dependency graph from the bottom up, passing each constructed component to the next:
package main
import (
"database/sql"
"log/slog"
"os"
"myapp/orders"
"myapp/payment"
)
func main() {
// Configuration
cfg := loadConfig()
// Logger
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Database
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
logger.Error("failed to open database", "error", err)
os.Exit(1)
}
defer db.Close()
if err := db.Ping(); err != nil {
logger.Error("database ping failed", "error", err)
os.Exit(1)
}
// Payment gateway (configured with API keys from env)
paymentGW := payment.NewStripeGateway(cfg.StripeKey, logger)
// Orders
orderStore := orders.NewStore(db)
orderService := orders.NewService(orderStore, logger, paymentGW)
orderHandler := orders.NewHandler(orderService, logger)
// HTTP server
router := setupRouter(orderHandler)
logger.Info("server starting", "port", cfg.Port)
if err := router.ListenAndServe(":"+cfg.Port); err != nil {
logger.Error("server error", "error", err)
os.Exit(1)
}
}This is beautiful. Someone new to the codebase opens main.go and sees the entire dependency chain: database → store → service → handler. If they need to understand how orders flow through the system, they start here. There's no container hiding the relationships, no annotations scattered across three files. The dependency graph is explicit and linear.
As your application grows, you'll have more components. Some people organize this by creating helper functions that group related initialization:
func initOrderDependencies(db *sql.DB, logger *slog.Logger, paymentGW payment.Gateway) *orders.Handler {
store := orders.NewStore(db)
svc := orders.NewService(store, logger, paymentGW)
return orders.NewHandler(svc, logger)
}
func initPaymentDependencies(cfg Config, logger *slog.Logger) payment.Gateway {
return payment.NewStripeGateway(cfg.StripeKey, logger)
}
func main() {
cfg := loadConfig()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
db := setupDatabase(cfg, logger)
paymentGW := initPaymentDependencies(cfg, logger)
orderHandler := initOrderDependencies(db, logger, paymentGW)
router := setupRouter(orderHandler)
router.ListenAndServe(":" + cfg.Port)
}This keeps main.go readable even when you have dozens of services.
Interfaces at boundaries: swap implementations for testing
Now, here's where Go's interface model shines. We don't define the PaymentGateway by passing a concrete Stripe implementation everywhere. We define an interface at the boundary and pass that:
package orders
type PaymentGateway interface {
Charge(ctx context.Context, customerID string, amount float64) error
}
type Service struct {
store *Store
logger *slog.Logger
pay PaymentGateway // Interface, not concrete type
}
func NewService(store *Store, logger *slog.Logger, pay PaymentGateway) *Service {
return &Service{
store: store,
logger: logger,
pay: pay,
}
}In tests, we create a mock that satisfies this interface:
package orders
import (
"context"
"testing"
)
type mockPaymentGateway struct {
chargeFunc func(ctx context.Context, customerID string, amount float64) error
}
func (m *mockPaymentGateway) Charge(ctx context.Context, customerID string, amount float64) error {
if m.chargeFunc != nil {
return m.chargeFunc(ctx, customerID, amount)
}
return nil
}
func TestPlaceOrder_Success(t *testing.T) {
store := NewStore(inMemoryDB())
logger := slog.New(slog.NewDiscardHandler())
mock := &mockPaymentGateway{
chargeFunc: func(ctx context.Context, customerID string, amount float64) error {
return nil
},
}
svc := NewService(store, logger, mock)
order := &Order{ID: "123", CustomerID: "cust-1", Total: 99.99}
err := svc.PlaceOrder(context.Background(), order)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestPlaceOrder_PaymentFails(t *testing.T) {
store := NewStore(inMemoryDB())
logger := slog.New(slog.NewDiscardHandler())
mock := &mockPaymentGateway{
chargeFunc: func(ctx context.Context, customerID string, amount float64) error {
return errors.New("card declined")
},
}
svc := NewService(store, logger, mock)
order := &Order{ID: "123", CustomerID: "cust-1", Total: 99.99}
err := svc.PlaceOrder(context.Background(), order)
if err == nil {
t.Fatal("expected error, got nil")
}
}No mocking library needed. No code generation. You create a struct that implements the interface, pass it to NewService, and test both the happy path and failure modes. This is how Go developers test in production.
Tools if needed: Wire or Fx for very large graphs
For most APIs we've built—even large ones handling thousands of requests per second—explicit wiring in main.go scales beautifully. A well-organized main.go with helper functions remains readable even with 50+ components.
However, if you're building a very complex system with many interconnected services, two tools can help:
- Wire (github.com/google/wire): Code generation tool that reads constructor functions and generates the wiring code for you. It still outputs readable Go code; you just don't write it manually.
- Fx (go.uber.org/fx): A lightweight DI framework from Uber that uses constructors and provides hooks for lifecycle management (like graceful shutdown).
We've used Wire in projects with 80+ services. It's genuinely helpful because it catches missing dependencies at compile time and generates the boilerplate. But start without it. Most teams don't need it.
If you do reach for Wire, you're still following Go's philosophy: constructors are explicit, dependencies are declared clearly. The tool just generates the wiring code you'd write by hand.
Common mistakes
Mistake 1: Global singletons
Resist the urge to create global package-level variables for your database or logger:
// DON'T do this
var DB *sql.DB
var Logger *slog.Logger
func init() {
// Initialize globals
}Globals make testing impossible (every test modifies shared state) and hide dependencies. Constructors force them to be explicit.
Mistake 2: Struct embedding to avoid passing dependencies
Don't embed components in a parent struct just to avoid passing them through constructors:
// DON'T do this
type Service struct {
Store // embedded, so Service has all Store methods
Logger // embedded, so Service has all Logger methods
}
// Now you can call svc.GetOrder() without svc.store.GetOrder()
// But it's unclear where GetOrder comes from
Be explicit. Name your fields. Make the dependency graph obvious to the reader.
Mistake 3: Passing *http.Request into your service layer
Your service should accept context.Context, not the HTTP request. Extract values you need in your handler:
// In handler
func (h *Handler) PlaceOrder(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
// Pass only what the service needs
err := h.svc.PlaceOrder(r.Context(), userID, order)
// ...
}
// Service stays independent of HTTP
func (s *Service) PlaceOrder(ctx context.Context, userID string, order *Order) error {
// ...
}This keeps your business logic testable without mocking HTTP requests.
Key takeaways
- Go dependency injection doesn't require frameworks—just constructors, interfaces, and explicit wiring in main.go.
- Constructor functions (NewService, NewStore) are the foundation. Each accepts its dependencies and returns a configured struct.
- Wire the dependency graph in main.go. This single place shows every component and how they connect, making the codebase navigable.
- Use interfaces at boundaries (like PaymentGateway) to swap implementations in tests without mocking libraries or reflection.
- Organize main.go with helper functions (initOrderDependencies, initPaymentDependencies) as your graph grows.
- Avoid global singletons, struct embedding tricks, and passing HTTP requests into your service layer. Explicit beats implicit.
- Consider Wire or Fx only if you have 50+ tightly coupled services. Most teams never need them.
- This approach scales from a 2,000-line API to a 100,000-line monolith. The dependency graph remains readable because it's built in plain Go.






Comments
Loading comments…