Dependency Injection Patterns Across Languages
What are the dependency injection patterns across languages?
TL;DR
- Bottom line: Dependency injection (DI) externalizes object creation so components receive their dependencies rather than creating them, enabling loose coupling, testability, and swappable implementations across all major languages.
- Key tool/command: Constructor injection via interfaces — the universal default pattern regardless of language or framework.
- Watch out for: Using the DI container as a service locator inside business logic, which hides dependencies and defeats the purpose of DI.
- Works with: Every object-oriented and most functional languages — TypeScript, Python, Java, Go, C#, Kotlin, Rust, Ruby, PHP.
Constraints
- Constructor injection should be the default — use setter/property injection only for optional dependencies
- Never inject concrete classes directly — always inject interfaces or abstract types to preserve testability
- Avoid circular dependencies — they indicate a design flaw and most DI containers will throw at startup
- Do not use the DI container as a service locator — resolving dependencies manually inside business logic defeats the purpose of DI [src3]
- In Go, prefer manual DI (constructor functions) over reflection-based containers unless the project has 50+ services [src5]
Quick Reference
| DI Type | Mechanism | Testability | Complexity | Best For | Languages/Frameworks |
|---|---|---|---|---|---|
| Constructor Injection | Dependencies passed via constructor parameters | Excellent | Low | Required dependencies, immutable objects | All (universal) |
| Property/Setter Injection | Dependencies assigned via public setters after construction | Good | Low | Optional dependencies, circular dep workarounds | Java/Spring, C#/.NET, Python |
| Method Injection | Dependencies passed as method parameters per-call | Excellent | Low | Per-request/transient dependencies | All (universal) |
| Interface Injection | Component implements injection interface the container calls | Good | Medium | Plugin architectures, legacy systems | Java (Avalon), less common today |
| Container-Managed | Framework auto-resolves via decorators, annotations, or config | Excellent | Medium-High | Large apps with 50+ services | Spring, NestJS, .NET, Angular, Fx |
| Manual/Pure DI | Wiring done explicitly in composition root, no container | Excellent | Low-Medium | Small-medium apps, Go idiom | Go, any language |
Decision Tree
START
├── Is the dependency required for the object to function?
│ ├── YES → Use Constructor Injection
│ └── NO → Is the dependency optional or has a sensible default?
│ ├── YES → Use Property/Setter Injection
│ └── NO ↓
├── Does the dependency vary per method call (e.g., per-request context)?
│ ├── YES → Use Method Injection
│ └── NO ↓
├── How many services need wiring (project size)?
│ ├── < 20 services → Manual/Pure DI in a composition root
│ ├── 20-50 services → Lightweight container (Wire for Go, simple factory for TS/Python)
│ └── > 50 services → Full DI framework (Spring, NestJS, .NET DI, Uber Fx)
│ └── NO ↓
└── DEFAULT → Constructor Injection with manual wiring; add container when boilerplate becomes unmanageable
Step-by-Step Guide
1. Define abstractions (interfaces)
Identify the boundaries between components. Each dependency should be represented by an interface, not a concrete class. This enables swapping implementations for testing and different environments. [src1]
// TypeScript — define an interface for the dependency
interface Logger {
log(message: string): void;
}
interface UserRepository {
findById(id: string): Promise<User | null>;
}
Verify: Every class depends only on interfaces, never on concrete implementations in its constructor signature.
2. Implement constructor injection
Pass all required dependencies through the constructor. Store them as private readonly fields. [src7]
class UserService {
constructor(
private readonly userRepo: UserRepository,
private readonly logger: Logger,
) {}
async getUser(id: string): Promise<User | null> {
this.logger.log(`Fetching user ${id}`);
return this.userRepo.findById(id);
}
}
Verify: The class has no new keywords for its dependencies — all are received from outside.
3. Create a composition root
Wire all dependencies in one place — the application entry point. This is where concrete implementations are chosen and connected. [src1]
// main.ts — composition root
const logger = new ConsoleLogger();
const userRepo = new PostgresUserRepository(dbPool);
const userService = new UserService(userRepo, logger);
// Start the application with fully wired dependencies
const app = createApp(userService);
app.listen(3000);
Verify: Only the composition root references concrete classes. All other modules depend on interfaces.
4. Write tests with mock dependencies
Replace real implementations with test doubles. Constructor injection makes this trivial. [src7]
// user-service.test.ts
const mockRepo: UserRepository = {
findById: jest.fn().mockResolvedValue({ id: '1', name: 'Alice' }),
};
const mockLogger: Logger = { log: jest.fn() };
const service = new UserService(mockRepo, mockLogger);
const user = await service.getUser('1');
expect(user?.name).toBe('Alice');
Verify: npm test passes with no real database or external service connections.
5. Migrate to a DI container (optional, for larger projects)
When manual wiring becomes too verbose, introduce a framework container. Register providers and let the container resolve the dependency graph. [src4]
// NestJS example — the framework handles wiring
@Injectable()
class UserService {
constructor(
@Inject('USER_REPO') private readonly userRepo: UserRepository,
private readonly logger: LoggerService,
) {}
}
Verify: Application starts without NullInjectorError or Cannot resolve dependency errors.
Code Examples
TypeScript/NestJS: Container-Managed DI
// Input: NestJS module with provider registration
// Output: Auto-wired UserService with injected dependencies
import { Module, Injectable, Inject } from '@nestjs/common';
interface UserRepository {
findById(id: string): Promise<User | null>;
}
@Injectable()
class PgUserRepository implements UserRepository {
async findById(id: string): Promise<User | null> {
return { id, name: 'Alice' };
}
}
@Injectable()
class UserService {
constructor(
@Inject('USER_REPO') private readonly repo: UserRepository,
) {}
}
@Module({
providers: [
{ provide: 'USER_REPO', useClass: PgUserRepository },
UserService,
],
exports: [UserService],
})
export class UserModule {}
Python: Manual Constructor Injection
# Input: Protocol-based interfaces, manual wiring
# Output: Testable service with swappable dependencies
from typing import Protocol
class UserRepository(Protocol):
def find_by_id(self, user_id: str) -> dict | None: ...
class PostgresUserRepository:
def __init__(self, dsn: str) -> None:
self.dsn = dsn
def find_by_id(self, user_id: str) -> dict | None:
return {"id": user_id, "name": "Alice"}
class UserService:
def __init__(self, repo: UserRepository) -> None:
self._repo = repo # injected, not created here
def get_user(self, user_id: str) -> dict | None:
return self._repo.find_by_id(user_id)
# Composition root
repo = PostgresUserRepository(dsn="postgresql://localhost/mydb")
service = UserService(repo=repo)
Java/Spring: Annotation-Based DI
// Input: Spring Boot application with @Service and @Repository
// Output: Auto-wired beans managed by Spring container
public interface UserRepository {
Optional<User> findById(String id);
}
@Repository
public class JpaUserRepository implements UserRepository {
@Override
public Optional<User> findById(String id) {
return Optional.of(new User(id, "Alice"));
}
}
@Service
public class UserService {
private final UserRepository repo;
// Spring auto-injects via constructor (no @Autowired needed
// when there is only one constructor, Spring 4.3+)
public UserService(UserRepository repo) {
this.repo = repo;
}
public Optional<User> getUser(String id) {
return repo.findById(id);
}
}
Go: Manual DI (Idiomatic)
// Input: Interface-based dependencies, constructor functions
// Output: Testable service with explicit wiring
package user
import "context"
// Interface (implicit in Go — no "implements" keyword)
type Repository interface {
FindByID(ctx context.Context, id string) (*User, error)
}
// Service receives its dependency via constructor function
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.FindByID(ctx, id)
}
Go: Container-Managed DI with Uber Fx
// Input: Uber Fx module with provider functions
// Output: Auto-wired application with lifecycle management
package main
import (
"go.uber.org/fx"
"myapp/user"
"myapp/postgres"
)
func main() {
fx.New(
fx.Provide(
postgres.NewRepository, // provides user.Repository
user.NewService, // consumes user.Repository
NewHTTPHandler, // consumes *user.Service
),
fx.Invoke(StartServer),
).Run()
}
Anti-Patterns
Wrong: Service Locator inside business logic
// BAD — hides dependencies, untestable, couples to container
class UserService {
getUser(id: string) {
const repo = Container.resolve<UserRepository>('UserRepo');
return repo.findById(id);
}
}
Correct: Constructor injection with explicit dependencies
// GOOD — dependencies are visible, testable, swappable
class UserService {
constructor(private readonly repo: UserRepository) {}
getUser(id: string) {
return this.repo.findById(id);
}
}
Wrong: God container with everything registered
// BAD — single container with 200+ registrations, no module boundaries
const container = new Container();
container.register('db', PostgresDB);
container.register('cache', RedisCache);
container.register('logger', WinstonLogger);
container.register('userRepo', UserRepo);
container.register('orderRepo', OrderRepo);
// ... 195 more registrations in one file
Correct: Modular registration with scoped containers
// GOOD — each module registers its own providers
@Module({
providers: [UserRepository, UserService],
exports: [UserService],
})
export class UserModule {}
@Module({
providers: [OrderRepository, OrderService],
imports: [UserModule], // explicit dependency between modules
})
export class OrderModule {}
Wrong: Injecting too many dependencies (constructor over-injection)
// BAD — 8+ constructor params = class does too much (SRP violation)
public class OrderService {
public OrderService(
UserRepo userRepo, OrderRepo orderRepo, PaymentGateway payment,
EmailService email, Logger logger, MetricsService metrics,
CacheService cache, AuditService audit, NotificationService notifs
) { /* ... */ }
}
Correct: Refactor into smaller, focused services
// GOOD — split into focused services with 2-4 dependencies each
public class OrderService {
public OrderService(OrderRepo orderRepo, PaymentGateway payment,
OrderNotifier notifier) { /* ... */ }
}
// OrderNotifier encapsulates email + push + audit concerns
public class OrderNotifier {
public OrderNotifier(EmailService email, NotificationService notifs,
AuditService audit) { /* ... */ }
}
Wrong: Circular dependencies
// BAD — A depends on B, B depends on A → container crashes
@Injectable()
class AuthService {
constructor(private userService: UserService) {}
}
@Injectable()
class UserService {
constructor(private authService: AuthService) {}
}
Correct: Break the cycle with an intermediate abstraction
// GOOD — extract shared logic into a third service
@Injectable()
class AuthService {
constructor(private tokenValidator: TokenValidator) {}
}
@Injectable()
class UserService {
constructor(private tokenValidator: TokenValidator) {}
}
// TokenValidator has no dependency on either service
@Injectable()
class TokenValidator {
validate(token: string): boolean { /* ... */ }
}
Common Pitfalls
- Creating dependencies with
newinside classes: Bypasses DI entirely, making the class untestable. Fix: movenewcalls to the composition root or container registration. [src1] - Using
@Autowiredon fields instead of constructors (Java/Spring): Fields can be null during construction and break immutability. Fix: use constructor injection — Spring auto-detects single constructors since 4.3. [src2] - Registering everything as singleton when it should be transient: Shared mutable state causes race conditions. Fix: match the lifecycle scope (singleton, scoped, transient) to the dependency's statefulness. [src7]
- Forgetting to register a provider: Results in cryptic runtime errors like
No provider for X. Fix: ensure every interface has a registered implementation in the module/container. [src4] - Over-engineering small projects with DI frameworks: Adds complexity without benefit for < 10 services. Fix: start with manual constructor injection; add a container when boilerplate becomes painful. [src5]
- Injecting the container itself: Makes every class depend on the entire application. Fix: inject only the specific interfaces each class needs. [src3]
- Ignoring Go's implicit interfaces: Trying to force Java-style DI containers in Go. Fix: use constructor functions returning interfaces — Go's structural typing provides DI naturally. [src6]
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Building apps with multiple collaborating services that need testing | Writing a simple script or CLI tool with < 5 functions | Direct instantiation |
| You need to swap implementations (prod database vs test mock) | The project is a prototype/throwaway code with no tests | Simplest possible approach |
| Working with a framework that provides DI (Spring, NestJS, .NET) | Adding DI would require more code than the entire application | Plain function composition |
| Multiple teams work on different modules needing clear boundaries | All dependencies are pure functions with no side effects | Functional composition / higher-order functions |
| You need lifecycle management (startup/shutdown ordering) | Performance-critical hot paths where allocation overhead matters | Object pools or static dispatch |
Important Caveats
- DI containers add a layer of indirection that can make debugging harder — stack traces go through container internals rather than direct calls. Use manual DI in performance-critical paths.
- Reflection-based containers (Spring, Uber Dig/Fx) have startup cost proportional to the number of registered types. Compile-time containers (Wire, Dagger 2) avoid this but require a build step.
- The "best" DI pattern depends on language idiom: Java/C# favor containers, Go favors manual injection, Python uses both. Do not transplant idioms across languages blindly.
- Constructor injection with 4+ parameters is a code smell indicating the class has too many responsibilities — refactor before adding more dependencies. [src1]
- In dynamically-typed languages (Python, Ruby), DI is simpler because duck typing provides implicit interfaces, but you lose compile-time verification of dependency contracts.