Dependency Injection Patterns Across Languages

Type: Software Reference Confidence: 0.92 Sources: 7 Verified: 2026-02-24 Freshness: semi-annual

TL;DR

Constraints

Quick Reference

DI TypeMechanismTestabilityComplexityBest ForLanguages/Frameworks
Constructor InjectionDependencies passed via constructor parametersExcellentLowRequired dependencies, immutable objectsAll (universal)
Property/Setter InjectionDependencies assigned via public setters after constructionGoodLowOptional dependencies, circular dep workaroundsJava/Spring, C#/.NET, Python
Method InjectionDependencies passed as method parameters per-callExcellentLowPer-request/transient dependenciesAll (universal)
Interface InjectionComponent implements injection interface the container callsGoodMediumPlugin architectures, legacy systemsJava (Avalon), less common today
Container-ManagedFramework auto-resolves via decorators, annotations, or configExcellentMedium-HighLarge apps with 50+ servicesSpring, NestJS, .NET, Angular, Fx
Manual/Pure DIWiring done explicitly in composition root, no containerExcellentLow-MediumSmall-medium apps, Go idiomGo, 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

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Building apps with multiple collaborating services that need testingWriting a simple script or CLI tool with < 5 functionsDirect instantiation
You need to swap implementations (prod database vs test mock)The project is a prototype/throwaway code with no testsSimplest possible approach
Working with a framework that provides DI (Spring, NestJS, .NET)Adding DI would require more code than the entire applicationPlain function composition
Multiple teams work on different modules needing clear boundariesAll dependencies are pure functions with no side effectsFunctional composition / higher-order functions
You need lifecycle management (startup/shutdown ordering)Performance-critical hot paths where allocation overhead mattersObject pools or static dispatch

Important Caveats

Related Units