Dependency Injection Patterns in Go
Learn practical dependency injection in Go using interfaces, constructor injection, and functional options without heavy frameworks.
What you'll learn
- ✓Why dependency injection matters for testable Go code
- ✓Constructor injection with interfaces
- ✓The functional options pattern for flexible configuration
- ✓Structuring a service layer with injected dependencies
Prerequisites
None — this post is self-contained.
Go does not have a dependency injection framework built into the language, and it does not need one. Interfaces, constructor functions, and a few well-established patterns give you clean dependency injection without magic. The result is code where every dependency is explicit, every service is testable, and you can swap implementations without changing business logic.
The Problem Without Injection
Code that creates its own dependencies is hard to test and hard to change:
type OrderService struct{}
func (s *OrderService) PlaceOrder(userID string, items []string) error {
db, err := sql.Open("postgres", "host=localhost dbname=shop")
if err != nil {
return err
}
defer db.Close()
// insert order into database
_, err = db.Exec("INSERT INTO orders (user_id) VALUES ($1)", userID)
return err
}
This function opens a real database connection every time it runs. You cannot test it without a running PostgreSQL instance. You cannot switch to a different database without editing the service. The dependency is hidden inside the method.
Constructor Injection With Interfaces
Define an interface for the dependency and accept it in the constructor:
type OrderStore interface {
CreateOrder(ctx context.Context, userID string, items []string) error
GetOrder(ctx context.Context, orderID string) (*Order, error)
}
type OrderService struct {
store OrderStore
}
func NewOrderService(store OrderStore) *OrderService {
return &OrderService{store: store}
}
func (s *OrderService) PlaceOrder(ctx context.Context, userID string, items []string) error {
return s.store.CreateOrder(ctx, userID, items)
}
Now the service depends on the OrderStore interface, not a concrete database. In production, you pass a Postgres implementation. In tests, you pass a mock.
Implementing the Interface
The production implementation connects to a real database:
type PostgresOrderStore struct {
db *sql.DB
}
func NewPostgresOrderStore(db *sql.DB) *PostgresOrderStore {
return &PostgresOrderStore{db: db}
}
func (s *PostgresOrderStore) CreateOrder(ctx context.Context, userID string, items []string) error {
_, err := s.db.ExecContext(ctx,
"INSERT INTO orders (user_id, items) VALUES ($1, $2)",
userID, pq.Array(items),
)
return err
}
func (s *PostgresOrderStore) GetOrder(ctx context.Context, orderID string) (*Order, error) {
row := s.db.QueryRowContext(ctx,
"SELECT id, user_id, items FROM orders WHERE id = $1", orderID,
)
var o Order
err := row.Scan(&o.ID, &o.UserID, pq.Array(&o.Items))
return &o, err
}
Testing With a Mock
Tests inject a mock that records calls without touching a database:
type MockOrderStore struct {
CreateOrderFunc func(ctx context.Context, userID string, items []string) error
GetOrderFunc func(ctx context.Context, orderID string) (*Order, error)
}
func (m *MockOrderStore) CreateOrder(ctx context.Context, userID string, items []string) error {
return m.CreateOrderFunc(ctx, userID, items)
}
func (m *MockOrderStore) GetOrder(ctx context.Context, orderID string) (*Order, error) {
return m.GetOrderFunc(ctx, orderID)
}
func TestPlaceOrder(t *testing.T) {
var called bool
mock := &MockOrderStore{
CreateOrderFunc: func(ctx context.Context, userID string, items []string) error {
called = true
if userID != "user-1" {
t.Errorf("expected user-1, got %s", userID)
}
return nil
},
}
svc := NewOrderService(mock)
err := svc.PlaceOrder(context.Background(), "user-1", []string{"item-a"})
if err != nil {
t.Fatal(err)
}
if !called {
t.Error("expected CreateOrder to be called")
}
}
Multiple Dependencies
Real services have multiple dependencies. Accept all of them in the constructor:
type NotificationService interface {
SendEmail(ctx context.Context, to, subject, body string) error
}
type Logger interface {
Info(msg string, args ...any)
Error(msg string, args ...any)
}
type OrderService struct {
store OrderStore
notifier NotificationService
logger Logger
}
func NewOrderService(
store OrderStore,
notifier NotificationService,
logger Logger,
) *OrderService {
return &OrderService{
store: store,
notifier: notifier,
logger: logger,
}
}
func (s *OrderService) PlaceOrder(ctx context.Context, userID string, items []string) error {
s.logger.Info("placing order", "userID", userID, "items", len(items))
if err := s.store.CreateOrder(ctx, userID, items); err != nil {
s.logger.Error("failed to create order", "error", err)
return err
}
_ = s.notifier.SendEmail(ctx, userID, "Order Confirmed", "Your order is placed.")
return nil
}
Each dependency is an interface. Each can be replaced independently in tests.
The Functional Options Pattern
When a service has optional configuration alongside required dependencies, the functional options pattern keeps the constructor clean:
type ServerOption func(*Server)
type Server struct {
handler http.Handler
addr string
timeout time.Duration
logger Logger
}
func WithAddr(addr string) ServerOption {
return func(s *Server) {
s.addr = addr
}
}
func WithTimeout(d time.Duration) ServerOption {
return func(s *Server) {
s.timeout = d
}
}
func WithLogger(l Logger) ServerOption {
return func(s *Server) {
s.logger = l
}
}
func NewServer(handler http.Handler, opts ...ServerOption) *Server {
s := &Server{
handler: handler,
addr: ":8080",
timeout: 30 * time.Second,
logger: defaultLogger{},
}
for _, opt := range opts {
opt(s)
}
return s
}
Usage is expressive and only specifies what differs from defaults:
srv := NewServer(
mux,
WithAddr(":3000"),
WithTimeout(10*time.Second),
)
Wiring It All Together
At the application’s entry point, typically main(), you create concrete implementations and wire them into services:
func main() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
store := NewPostgresOrderStore(db)
notifier := NewSMTPNotifier(os.Getenv("SMTP_HOST"))
logger := slog.Default()
orderSvc := NewOrderService(store, notifier, logger)
userSvc := NewUserService(store, logger)
mux := http.NewServeMux()
mux.HandleFunc("POST /orders", orderSvc.HandlePlaceOrder)
mux.HandleFunc("GET /users/{id}", userSvc.HandleGetUser)
srv := NewServer(mux, WithAddr(":"+os.Getenv("PORT")))
log.Fatal(http.ListenAndServe(srv.addr, srv.handler))
}
All dependencies are visible in one place. There is no container, no reflection, and no annotations. The dependency graph is plain Go code.
Interface Segregation
Keep injected interfaces small. A service should not accept a 10-method interface when it only calls two methods:
// Too broad - forces mocks to implement everything
type UserRepository interface {
Create(ctx context.Context, user *User) error
GetByID(ctx context.Context, id string) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, limit, offset int) ([]*User, error)
}
// Better - only what this service needs
type UserGetter interface {
GetByID(ctx context.Context, id string) (*User, error)
}
type ProfileService struct {
users UserGetter
}
Small interfaces are easier to mock, easier to implement, and make dependencies self-documenting.
When to Use a DI Container
For most Go applications, manual wiring in main() is sufficient and preferred. If the dependency graph becomes very large, with dozens of services and complex initialization order, tools like Google’s Wire can generate the wiring code for you. Wire uses code generation rather than runtime reflection, staying true to Go’s philosophy of explicit code.
The threshold for needing a tool is high. If your main() function is under 100 lines of wiring, manual injection is clearer and easier to debug.
Wrap Up
Dependency injection in Go is straightforward: define small interfaces for dependencies, accept them in constructors, and wire concrete implementations in main(). This pattern makes every dependency visible, every service testable with mocks, and every implementation swappable. No framework required.
Related articles
- Go Go Generics: Practical Patterns and Constraints
Learn practical Go generics patterns including type constraints, generic data structures, utility functions, and when to use generics vs interfaces.
- Go Go Fuzz Testing Tutorial
Write fuzz tests in Go to discover edge cases and bugs automatically. Learn the fuzzing API, corpus management, and fixing found crashes.
- Go Struct Embedding and Composition Patterns in Go
Learn Go composition over inheritance with struct embedding, interface embedding, and practical patterns for clean architecture.
- Go Table-Driven Tests and Test Helpers in Go
Write clear, maintainable Go tests using table-driven patterns, test helpers, subtests, and cleanup functions.