Dependency Injection #
Almost every engineer has experienced the same moment: asked to fix a bug in code that’s a year old, and before writing a single line, having to untangle a web of interconnected dependencies like a knotted thread. ServiceA creates its own RepositoryB inside its constructor, which directly creates ClientC, which needs configuration from os.Getenv scattered everywhere. To write even one unit test, you have to set up a real database connection. Dependency Injection is the design pattern that attacks the root of this problem directly: objects are not responsible for creating their own dependencies — dependencies come from outside. That’s how simple the concept is, and how big the impact is. This article covers DI from the concrete problems it solves, the three injection techniques with Go, Dart, and Java code examples, when to use manual DI vs a DI framework, lifecycle scopes, and the anti-patterns that often appear when DI is applied half-heartedly.
What Is Dependency Injection? #
Dependency Injection is a design pattern where an object doesn’t create the dependencies it needs itself; instead, those dependencies are provided from outside. The object only declares what it needs — usually through an interface — and an external party (whether main(), a framework, or a test) decides which implementation is provided.
// ANTI-PATTERN: UserService creates its own dependency — hidden coupling
type UserService struct{}
func (s *UserService) GetUser(id string) (*User, error) {
repo := NewMySQLUserRepository() // ← created inside — can't be tested
return repo.FindByID(id)
}
// CORRECT: the dependency is received from outside via the constructor
type UserService struct {
repo UserRepository // ← interface, not a concrete implementation
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(id string) (*User, error) {
return s.repo.FindByID(id) // doesn't know MySQL, PostgreSQL, or in-memory
}
The change from anti-pattern to correct looks small, but the impact is huge: UserService now doesn’t know or care whether its UserRepository stores data in MySQL, PostgreSQL, Redis, or even an in-memory array for testing purposes. Whoever calls NewUserService makes that decision.
flowchart LR
subgraph Before["❌ Before DI — Tight Coupling"]
US1[UserService] -->|"new MySQLRepo()"| R1[MySQLUserRepository]
R1 -->|"os.Getenv(DB_URL)"| DB1["(MySQL)"]
end
subgraph After["✅ After DI — Loose Coupling"]
Caller["main / test"] -->|"inject repo"| US2[UserService]
Caller -->|"create"| R2["UserRepository\ninterface"]
R2 -.->|"prod: implements"| R3[MySQLUserRepository]
R2 -.->|"test: implements"| R4[InMemoryUserRepository]
endDI’s Relationship with IoC and SOLID #
DI is the most concrete implementation of Inversion of Control — if IoC is the principle, DI is the technical mechanism. DI also directly implements the Dependency Inversion Principle (DIP), the last letter of SOLID:
High-level modules should not depend on low-level modules. Both should depend on abstractions.
flowchart TD
subgraph Without["❌ Without DI — High-Level Depends on Low-Level"]
OS1["OrderService\nhigh-level"] -->|depends on directly| R1["MySQLOrderRepository\nconcrete low-level"]
Note1["Switching MySQL → PostgreSQL\n= changing OrderService"]
end
subgraph With["✅ With DI — Both Depend on Abstraction"]
OS2["OrderService\nhigh-level"] -->|depends on| IFACE["OrderRepository\ninterface / abstraction"]
R2[MySQLOrderRepository] -->|implements| IFACE
R3[PostgresOrderRepository] -->|implements| IFACE
R4[InMemoryOrderRepository] -->|implements| IFACE
Note2["Switching MySQL → Postgres\n= only changing the wiring in main()"]
end| Concept | Relationship with DI |
|---|---|
| Inversion of Control | DI is one way to implement IoC |
| Dependency Inversion Principle | DI is the technical implementation of DIP |
| Clean Architecture | DI enables the domain to not know infrastructure implementations |
| Testability | DI enables swapping real implementations with fakes/mocks |
Three Injection Techniques #
There are three ways to “inject” a dependency into an object. Each has different characteristics and use cases.
1. Constructor Injection — The Primary Choice #
Dependencies are provided when the object is created via the constructor. This is the most recommended technique because it makes all dependencies visible, mandatory, and immutable from the moment the object is first created.
// Go — constructor injection
type OrderService struct {
repo OrderRepository // interface
emailer EmailSender // interface
logger Logger // interface
}
func NewOrderService(
repo OrderRepository,
emailer EmailSender,
logger Logger,
) *OrderService {
// All dependencies are clearly visible here — nothing hidden
return &OrderService{repo: repo, emailer: emailer, logger: logger}
}
// Go — constructor injection with a struct + constructor
type AuthBloc struct {
repository AuthRepository
tokenStorage TokenStorage
analytics AnalyticsService
}
func NewAuthBloc(
repository AuthRepository,
tokenStorage TokenStorage,
analytics AnalyticsService,
) *AuthBloc {
return &AuthBloc{
repository: repository,
tokenStorage: tokenStorage,
analytics: analytics,
}
}
func (b *AuthBloc) Login(ctx context.Context, email, password string) error {
token, err := b.repository.Login(ctx, email, password)
if err != nil {
return err
}
if err := b.tokenStorage.Save(ctx, token); err != nil {
return err
}
b.analytics.Track("user_logged_in", map[string]string{"email": email})
return nil
}
// Testing: inject fake implementations
func main() {
bloc := NewAuthBloc(
FakeAuthRepository(), // ← fake, not network
InMemoryTokenStorage(), // ← fake, not Hive/SharedPrefs
NoOpAnalytics(), // ← fake, doesn't send real events
)
}
// Go — constructor injection (no framework magic needed)
type PaymentService struct {
repo PaymentRepository
notifier NotificationService
}
// The constructor is the single wiring point — no annotations required
func NewPaymentService(repo PaymentRepository, notifier NotificationService) *PaymentService {
return &PaymentService{repo: repo, notifier: notifier}
}
The advantages of constructor injection:
| Advantage | Explanation |
|---|---|
| Dependencies clearly visible | Looking at the constructor is enough to know everything needed |
| Immutable after creation | Can’t be swapped after construction — safer |
| Compiler enforcement | Compiler error if a dependency isn’t provided |
| Easy to test | Tests just call the constructor with fake implementations |
2. Setter Injection — For Optional Dependencies #
Dependencies are provided after the object is created via setter methods. Use this only for truly optional dependencies — ones with sensible defaults if not set.
// CORRECT: setter injection for an optional dependency with a default
type EmailService struct {
sender EmailSender
logger Logger
rateLimit int
}
func NewEmailService(sender EmailSender, logger Logger) *EmailService {
return &EmailService{
sender: sender,
logger: logger,
rateLimit: 100, // ← a sensible default
}
}
// Setter only to override the default — optional
func (s *EmailService) SetRateLimit(limit int) {
s.rateLimit = limit
}
// ANTI-PATTERN: setter injection for a required dependency
type UserService struct {
repo UserRepository // required, but not enforced via the constructor
}
func NewUserService() *UserService {
return &UserService{} // repo is still nil — a ticking time bomb!
}
func (s *UserService) SetRepo(repo UserRepository) {
s.repo = repo
}
// If the caller forgets to call SetRepo → s.repo is nil → panic when used
3. Parameter Injection — For Per-Call Variation #
Dependencies are provided as function parameters, not stored in the struct. Good for dependencies that differ every time the function is called, or for one-off operations that don’t need to be stored.
// Parameter injection — context and transactions injected per-call
func ProcessRefund(
ctx context.Context, // ← different context per request
tx *sql.Tx, // ← different transaction per operation
refundRepo RefundRepository,
orderRepo OrderRepository,
refundID string,
) error {
refund, err := refundRepo.FindByID(ctx, refundID)
if err != nil {
return err
}
order, err := orderRepo.FindByID(ctx, refund.OrderID)
if err != nil {
return err
}
return refundRepo.MarkCompleted(ctx, tx, refund, order)
}
context.Context in Go is the classic example of parameter injection — it carries deadline, cancellation signals, and trace IDs that differ per request, so it can’t be stored in a struct as a permanent field.
Summary comparison of the three techniques:
| Technique | When to Use | Strengths | Weaknesses |
|---|---|---|---|
| Constructor | Required dependencies | Explicit, immutable, compiler-safe | — |
| Setter | Optional dependencies with defaults | Flexible for configuration | Can be used in an invalid state |
| Parameter | Per-call dependencies (context, tx) | Right for limited scopes | Verbose with many parameters |
Manual DI vs DI Frameworks #
There are two approaches in practice: assembling dependencies manually in main(), or using a framework/container that does the wiring automatically.
Manual DI — Simple and Transparent #
// main.go — all wiring explicit, no magic
func main() {
// Infrastructure layer
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil { log.Fatal(err) }
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
redisClient := redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_URL"),
})
// Repository layer
userRepo := repository.NewUserRepository(db)
orderRepo := repository.NewOrderRepository(db)
cache := cache.NewRedisCache(redisClient)
// Service layer
userService := service.NewUserService(userRepo, cache)
orderService := service.NewOrderService(orderRepo, userService)
// Handler layer
userHandler := handler.NewUserHandler(userService)
orderHandler := handler.NewOrderHandler(orderService)
// Server
router := setupRouter(userHandler, orderHandler)
log.Fatal(http.ListenAndServe(":8080", router))
}
DI with a Framework/Container #
For large systems with dozens or hundreds of components:
// Go with Uber FX — a functional-options-based DI framework
func main() {
fx.New(
fx.Provide(
database.NewPostgresDB, // provides *sql.DB
cache.NewRedisClient, // provides *redis.Client
repository.NewUserRepo, // needs *sql.DB, provides UserRepository
repository.NewOrderRepo, // needs *sql.DB, provides OrderRepository
cache.NewRedisCache, // needs *redis.Client, provides CacheStore
service.NewUserService, // needs UserRepository + CacheStore
service.NewOrderService, // needs OrderRepository + UserService
handler.NewUserHandler, // needs UserService
handler.NewOrderHandler, // needs OrderService
),
fx.Invoke(startHTTPServer),
).Run()
// FX automatically resolves all dependencies based on type signatures
// No need to write the manual order
}
// Go — manual registry, a simple service locator
func setupDependencies() {
// Infrastructure
container.Register("dio", NewDio(BaseOptions{BaseURL: Env.APIURL}))
container.Register("hive", NewHiveStorage())
// Repositories
container.Register("authRepo", NewAuthRepositoryImpl(container.Get("dio")))
container.Register("userRepo", NewUserRepositoryImpl(
container.Get("dio"),
container.Get("hive"),
))
// BLoC / Service
container.RegisterFactory("authBloc", func() interface{} {
return NewAuthBloc(
container.Get("authRepo"),
container.Get("hive"),
)
})
}
flowchart TD
subgraph Manual["Manual DI"]
M1[main.go] --> M2["Initialize infra\nDB, Redis, HTTP"]
M2 --> M3["Initialize repos\nusing infra"]
M3 --> M4["Initialize services\nusing repos"]
M4 --> M5["Initialize handlers\nusing services"]
M5 --> M6[Start server]
end
subgraph Framework["DI Framework / Container"]
F1["Register all\nproviders"] --> F2["Container resolves\ndependencies automatically"]
F2 --> F3["Lifecycle hooks\nstartup / shutdown"]
F3 --> F4[Run]
end| Aspect | Manual DI | DI Framework |
|---|---|---|
| Transparency | Very explicit, easy to trace | Some “magic”, needs framework knowledge |
| Boilerplate | Grows with system size | Minimal, auto-resolve |
| Lifecycle | Manual (defer close) | Built-in startup/shutdown hooks |
| Best for | Small-medium systems | Large systems (50+ components) |
| Error detection | Compile time | Runtime (some frameworks) |
DI frameworks that use reflection (like some Java/PHP frameworks) detect wiring errors at runtime, not compile time. This means incorrect configuration is only discovered when the application runs. For Go, it’s better to use code generation (Wire) or functional type matching (Uber FX), where errors are detected earlier.
DI and Testability — The Most Tangible Impact #
The most directly felt benefit of DI in daily life is the ability to write fast, isolated, deterministic unit tests — without needing a real database, SMTP server, or third-party API.
// The same interface implemented by production and fake
type UserRepository interface {
Save(ctx context.Context, user *User) error
FindByEmail(ctx context.Context, email string) (*User, error)
}
// Production: uses PostgreSQL
type PostgresUserRepository struct{ db *sql.DB }
func (r *PostgresUserRepository) Save(ctx context.Context, user *User) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO users (id, email, name) VALUES ($1, $2, $3)",
user.ID, user.Email, user.Name)
return err
}
// Test: in-memory, needs no infrastructure at all
type InMemoryUserRepository struct {
users map[string]*User
mu sync.RWMutex
}
func NewInMemoryUserRepository() *InMemoryUserRepository {
return &InMemoryUserRepository{users: make(map[string]*User)}
}
func (r *InMemoryUserRepository) Save(_ context.Context, user *User) error {
r.mu.Lock()
defer r.mu.Unlock()
r.users[user.Email] = user
return nil
}
func (r *InMemoryUserRepository) FindByEmail(_ context.Context, email string) (*User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
if user, ok := r.users[email]; ok {
return user, nil
}
return nil, ErrUserNotFound
}
// Tests run in milliseconds, no Docker/database needed
func TestRegisterUser_EmailAlreadyExists(t *testing.T) {
repo := NewInMemoryUserRepository()
emailer := &FakeEmailSender{}
svc := NewUserService(repo, emailer)
// Setup: user already exists
repo.Save(context.Background(), &User{
ID: "existing-1", Email: "[email protected]",
})
// Test: registering with the same email must fail
_, err := svc.Register(context.Background(), RegisterRequest{
Email: "[email protected]",
Name: "Someone Else",
})
assert.ErrorIs(t, err, ErrEmailAlreadyExists)
assert.Empty(t, emailer.SentEmails) // no email was sent
}
func TestRegisterUser_Success(t *testing.T) {
repo := NewInMemoryUserRepository()
emailer := &FakeEmailSender{}
svc := NewUserService(repo, emailer)
user, err := svc.Register(context.Background(), RegisterRequest{
Email: "[email protected]",
Name: "Unis Badri",
})
assert.NoError(t, err)
assert.NotEmpty(t, user.ID)
assert.Len(t, emailer.SentEmails, 1) // welcome email sent
}
flowchart LR
subgraph WithDI["With DI — Pure Unit Tests"]
T1[Test] -->|inject| FAKE["InMemoryRepo\nFakeEmailer"]
T1 -->|test| SVC[UserService]
SVC --> FAKE
T1 --> RESULT["✓ Fast < 1ms\n✓ Deterministic\n✓ No infra"]
end
subgraph WithoutDI["Without DI — Forced Integration Tests"]
T2[Test] --> SVC2[UserService]
SVC2 -->|needs| DB[("(MySQL\nrunning)")]
SVC2 -->|needs| SMTP["SMTP Server\nrunning"]
T2 --> RESULT2["✗ Slow\n✗ Flaky\n✗ Needs infra"]
endLifecycle Scopes in DI #
One thing often forgotten when designing DI is scope — how long a dependency lives and when new instances are created.
| Scope | Description | Example | Created |
|---|---|---|---|
| Singleton | One instance for the app’s lifetime | DB pool, logger, config | At startup |
| Transient | A new instance every time it’s needed | Command handlers, DTO builders | On every injection |
| Scoped | One instance per unit of work | HTTP request context, DB transaction | Per request/job |
// ANTI-PATTERN: creating a DB connection per request — very expensive and slow
func handleGetUser(w http.ResponseWriter, r *http.Request) {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL")) // ← wasteful!
defer db.Close()
repo := repository.NewUserRepository(db)
// ...
}
// CORRECT: singleton — the connection pool is created once, shared by all handlers
func main() {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
db.SetMaxOpenConns(25) // max 25 concurrent connections
db.SetMaxIdleConns(10) // min 10 ready connections
// Singleton: created once, used for the entire application lifetime
repo := repository.NewUserRepository(db) // singleton
service := service.NewUserService(repo) // singleton
handler := handler.NewUserHandler(service) // singleton
http.HandleFunc("/users", handler.GetUser)
http.ListenAndServe(":8080", nil)
}
// Go — scopes with a simple container
func setupDI() {
c := container.New()
// Singleton: one instance for the app's lifetime
c.RegisterSingleton("apiClient", NewApiClient(Env.APIURL))
// LazySingleton: a singleton, but created on first use
c.RegisterLazySingleton("authRepo", func() interface{} {
return NewAuthRepositoryImpl(c.Get("apiClient"))
})
// Factory: a new instance every time c.Get("authBloc") is called
// Good for BLoCs that are disposed and recreated per screen
c.RegisterFactory("authBloc", func() interface{} {
return NewAuthBloc(c.Get("authRepo"))
})
}
Anti-Patterns to Avoid #
// ✗ Creating dependencies inside a method — IoC violated again
func (s *OrderService) CreateOrder(req Request) (*Order, error) {
repo := &MySQLOrderRepository{db: globalDB} // ← tight coupling again!
return repo.Save(buildOrder(req))
}
// ✓ Use s.repo, which was injected via the constructor
// ✗ Global variables as dependencies — can't be mocked, race conditions
var globalDB *sql.DB
func init() {
globalDB, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))
}
// ✓ Inject *sql.DB via the constructor, not as a global
// ✗ God constructor — a sign the service is doing too much
func NewOrderService(
repo OrderRepository,
userRepo UserRepository,
productRepo ProductRepository,
inventoryRepo InventoryRepository,
paymentGW PaymentGateway,
emailer EmailSender,
sms SMSSender,
push PushNotifier,
logger Logger,
cache CacheStore,
config *Config,
bus EventBus,
) *OrderService { ... }
// 12 dependencies = OrderService is doing too much → split into smaller services
// ✓ If > 4-5 dependencies, evaluate whether SRP is satisfied
// ✗ Injecting concrete types instead of interfaces
func NewOrderService(repo *MySQLOrderRepository) *OrderService { ... }
// ✓ Inject an interface
func NewOrderService(repo OrderRepository) *OrderService { ... }
// ✗ Setter injection for required dependencies — can be used in an invalid state
type UserService struct{ repo UserRepository }
func NewUserService() *UserService { return &UserService{} } // repo = nil!
func (s *UserService) SetRepo(r UserRepository) { s.repo = r }
// ✓ Constructor injection for required dependencies
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo} // compiler error if no repo
}
// ✗ Injecting the IoC container into a service — the service knows about the container
type OrderService struct { container *Container }
func (s *OrderService) CreateOrder(req Request) error {
repo := s.container.Get("orderRepo").(OrderRepository) // hidden dependency!
...
}
// ✓ Inject the dependency directly, not the container
DI Implementation Checklist #
INTERFACE DESIGN:
□ Every dependency declared as an interface on the consumer side
□ Minimal interfaces — only the methods the consumer actually uses
□ Interfaces don't leak implementation details (no *sql.DB in interfaces)
CONSTRUCTOR INJECTION:
□ All required dependencies injected via the constructor
□ Constructors don't create their own dependencies (no `new`, `Open`, etc.)
□ Optional dependencies have sensible defaults
WIRING:
□ All wiring centralized in one place (main.go, wire.go, module.go)
□ No global variables used as dependencies
□ Lifecycle scope of every dependency considered
TESTING:
□ Every interface has a fake/mock implementation for testing
□ Unit tests don't need DB connections, SMTP, or network
□ Tests can run in parallel without race conditions
SCOPE:
□ Database connection pool as a singleton
□ BLoC/Command handlers as factories (a new instance per use)
□ Request-scoped data passed via context, not stored in structs
Summary #
- Dependency Injection is a pattern where objects receive dependencies from outside — moving construction responsibility to the caller, not the object itself.
- Three injection techniques: constructor injection (required, explicit, immutable dependencies — the primary choice), setter injection (optional dependencies with sensible defaults), parameter injection (per-call dependencies like
context.Contextand*sql.Tx).- Constructor injection is the most recommended — dependencies are clearly visible from the signature, immutable, and the compiler immediately errors if something isn’t provided.
- DI is the implementation of SOLID’s DIP — high-level modules depend on abstractions (interfaces), not concrete implementations; swapping implementations only touches the wiring, not the consumers.
- The most tangible impact is testability — with DI, every dependency can be replaced with a fake/in-memory implementation that runs in milliseconds without a database or network.
- Manual DI (wiring in
main()) is sufficient and more transparent for small-medium systems; DI frameworks (Uber FX, Wire, Spring, get_it) suit large systems with many components.- Watch lifecycle scopes: database connection pools must be singletons, per-screen BLoCs should be factories, request-scoped objects go through context.
- God constructors (8+ dependencies) are a strong code smell — a sign the component is doing too much and needs to be split.
- Inject interfaces, not concrete types — the only way to allow swapping implementations without changing all consumers.
← Previous: Inversion of Control Next: Aspect Oriented Programming →