StackPractices
intermediate By Mathias Paulenko

Dependency Injection: Containers in 4 Languages

Implement dependency injection to write testable, decoupled code across languages and frameworks.

Overview

Dependency Injection (DI) is a design pattern where objects receive their dependencies from outside instead of building them internally. It decouples components, makes code testable without mocks, and lets you compose services flexibly.

This recipe is the implementation side: how to wire constructor injection by hand, when a container earns its place, and a ~60-line container you can adapt — plus the test setup that makes DI pay off. For the pattern’s theory, trade-offs, and UML, see the Dependency Injection Pattern page.

When to Use

  • Writing unit tests that substitute real services with test doubles
  • Building modular applications where components shouldn’t know about concrete implementations
  • Managing object graphs with transitive dependencies
  • Implementing plugin architectures or strategy patterns

When NOT to Use

  • Scripts and prototypes — wiring plumbing costs more than the coupling it removes
  • Single-dependency consumers — passing one argument through a container is ceremony for no benefit
  • Code that only ever has one implementation — abstract it when a second one arrives, not before

Solution

The first three examples cover the wiring styles you’ll meet most: constructor injection by hand (the 90% case), property injection for optional dependencies, and a framework container doing the same thing through annotations. The last three are the parts most articles skip — a container small enough to own, scoped lifetimes for per-request state, and the test container where the whole exercise pays off.

Constructor Injection (TypeScript)

interface EmailService {
  send(to: string, subject: string, body: string): Promise<void>;
}

class UserService {
  constructor(
    private emailService: EmailService,
    private userRepository: UserRepository
  ) {}

  async register(email: string, password: string) {
    const user = await this.userRepository.create({ email, password });
    await this.emailService.send(email, 'Welcome', 'Thanks for signing up!');
    return user;
  }
}

// Production wiring
const userService = new UserService(
  new SendGridEmailService(),
  new PostgresUserRepository()
);

// Test wiring
const userServiceTest = new UserService(
  new FakeEmailService(),
  new InMemoryUserRepository()
);

Property Injection (Python)

from typing import Protocol

class Logger(Protocol):
    def log(self, message: str) -> None: ...

class ConsoleLogger:
    def log(self, message: str) -> None:
        print(f"[LOG] {message}")

class OrderProcessor:
    logger: Logger = ConsoleLogger()  # Default

    def process(self, order: dict) -> None:
        self.logger.log(f"Processing order {order['id']}")
        # ...

DI Container (Java with Spring)

@Service
public class PaymentService {
    private final PaymentGateway gateway;
    private final FraudChecker fraudChecker;

    public PaymentService(PaymentGateway gateway, FraudChecker fraudChecker) {
        this.gateway = gateway;
        this.fraudChecker = fraudChecker;
    }
}

@Configuration
public class AppConfig {
    @Bean
    public PaymentGateway paymentGateway() {
        return new StripeGateway();
    }
}

Lightweight DI Container (TypeScript)

type Factory<T> = () => T;

class DIContainer {
  private factories: Map<string, Factory<any>> = new Map();
  private singletons: Map<string, any> = new Map();
  private scoped: Map<string, any> = new Map();

  registerTransient<T>(key: string, factory: Factory<T>): void {
    this.factories.set(key, factory);
  }

  registerSingleton<T>(key: string, factory: Factory<T>): void {
    this.factories.set(key, () => {
      if (!this.singletons.has(key)) {
        this.singletons.set(key, factory());
      }
      return this.singletons.get(key);
    });
  }

  registerScoped<T>(key: string, factory: Factory<T>): void {
    this.factories.set(key, () => {
      if (!this.scoped.has(key)) {
        this.scoped.set(key, factory());
      }
      return this.scoped.get(key);
    });
  }

  resolve<T>(key: string): T {
    const factory = this.factories.get(key);
    if (!factory) {
      throw new Error(`No service registered for key: ${key}`);
    }
    return factory();
  }

  beginScope(): void {
    this.scoped.clear();
  }

  endScope(): void {
    this.scoped.clear();
  }
}

// Registration at composition root
const container = new DIContainer();

container.registerSingleton('db', () => new PostgresPool({ connectionString: process.env.DB_URL }));
container.registerSingleton('emailService', () => new SendGridEmailService(process.env.SENDGRID_KEY));
container.registerScoped('userRepository', () => new PostgresUserRepository(container.resolve('db')));
container.registerScoped('userService', () =>
  new UserService(container.resolve('emailService'), container.resolve('userRepository'))
);

// Per-request usage
container.beginScope();
const userService = container.resolve<UserService>('userService');
await userService.register('user@example.com', 'password');
container.endScope();

Scoped Lifecycle with Context Managers (Python)

from contextlib import contextmanager
from typing import TypeVar, Callable, Dict

T = TypeVar('T')

class DIContainer:
    def __init__(self):
        self._factories: Dict[str, Callable] = {}
        self._singletons: Dict[str, object] = {}
        self._scoped: Dict[str, object] = {}

    def register_singleton(self, key: str, factory: Callable[[], T]) -> None:
        self._factories[key] = lambda: self._singletons.setdefault(key, factory())

    def register_scoped(self, key: str, factory: Callable[[], T]) -> None:
        self._factories[key] = lambda: self._scoped.setdefault(key, factory())

    def register_transient(self, key: str, factory: Callable[[], T]) -> None:
        self._factories[key] = factory

    def resolve(self, key: str) -> T:
        if key not in self._factories:
            raise KeyError(f'No service registered for: {key}')
        return self._factories[key]()

    @contextmanager
    def scope(self):
        """Context manager for scoped lifetime."""
        self._scoped.clear()
        try:
            yield self
        finally:
            self._scoped.clear()

# Usage — scoped per request
container = DIContainer()
container.register_singleton('db', lambda: create_engine('postgresql://localhost/app'))
container.register_scoped('session', lambda: Session(container.resolve('db')))
container.register_transient('user_service', lambda: UserService(container.resolve('session')))

with container.scope() as scoped:
    service = scoped.resolve('user_service')
    service.register('user@example.com', 'password')

DI Testing Patterns (TypeScript)

class TestContainer extends DIContainer {
  constructor() {
    super();
    // Override real services with fakes
    this.registerSingleton('db', () => new InMemoryDatabase());
    this.registerSingleton('emailService', () => new FakeEmailService());
    this.registerScoped('userRepository', () => new InMemoryUserRepository());
    this.registerScoped('userService', () =>
      new UserService(this.resolve('emailService'), this.resolve('userRepository'))
    );
  }
}

describe('UserService', () => {
  let container: TestContainer;
  let userService: UserService;
  let emailService: FakeEmailService;

  beforeEach(() => {
    container = new TestContainer();
    container.beginScope();
    userService = container.resolve('userService');
    emailService = container.resolve('emailService');
  });

  afterEach(() => {
    container.endScope();
  });

  it('sends welcome email on register', async () => {
    await userService.register('user@example.com', 'password');
    expect(emailService.sentEmails).toHaveLength(1);
    expect(emailService.sentEmails[0].to).toBe('user@example.com');
    expect(emailService.sentEmails[0].subject).toBe('Welcome');
  });

  it('persists user to repository', async () => {
    const user = await userService.register('user@example.com', 'password');
    const repo = container.resolve('userRepository') as InMemoryUserRepository;
    expect(repo.users).toHaveLength(1);
    expect(repo.users[0].email).toBe('user@example.com');
  });
});

How It Works

DI inverts control: components stop finding or building their dependencies — the container or the caller hands them over. That gets you:

  1. Testability: swap real services for fakes without touching the consumer
  2. Flexibility: change implementations in one place (the registration), not in every consumer
  3. Lifecycle management: containers own singletons, scoped instances, and disposal
  4. AOP support: decorators and interceptors wrap injected services transparently

A container is just two maps and a rule set. Registration stores a factory per key plus a lifecycle tag. Resolution looks the factory up and applies the lifecycle: transient calls it every time, singleton caches the first result forever, scoped caches it inside the current scope (a request, a test, a unit of work). The scope is just a second map you clear on beginScope/endScope.

The subtle part is ordering: factories often resolve other keys — userService resolves emailService and userRepository at construction — so registration order doesn’t matter but the resolution graph does. Validate the whole graph at startup (iterate every key and dry-resolve it) and missing registrations fail fast instead of exploding mid-request.

Why three lifecycles and not two? Because object graphs mix rates of change: a connection pool belongs to the app (singleton), a DB session belongs to the request (scoped), and a command object belongs to the call (transient). Getting the tag wrong is the classic DI bug — a request-scoped session registered as a singleton serves yesterday’s data to today’s users.

Two things this minimal container skips, worth knowing before adapting it: disposal — a singleton pool needs close() on shutdown, and real containers run a dispose hook for it — and async factories — config loading and connection setup are async, so production containers accept async registrations and await resolution.

The nastiest lifecycle bug is the captive dependency: a singleton that captures a scoped instance and outlives it. Say reportCache registers as a singleton and its factory resolves the request-scoped session — the first request’s session gets pinned inside the cache and every later request silently queries with a stale (or disposed) session. The rule: dependencies only point “downward” — a scoped service can resolve a singleton, never the other way around. Production containers like ASP.NET Core detect this and throw CaptiveDependencyException; in a hand-rolled container, asserting direction in tests is your safety net.

MomentWhat runsWhat can go wrong
RegistrationStore the factory, not the instanceSide effects execute even if the service is never used
Startup validationDry-resolve every key onceSkipped validation = missing registrations surface mid-request
ResolutionRun factory + apply lifecycleResolving in a hot loop; singleton capturing a scoped dep
Scope endClear the scoped mapScoped instances leak across requests if endScope is skipped

Manual wiring or a container?

Manual wiring — new UserService(new FakeEmailService(), new InMemoryRepo()) — wins on transparency. There’s zero magic, the IDE shows every dependency, and for apps under ~15 services it stays readable. Reach for a container when the graph gets transitive (a service five levels deep can’t be wired inline), when you need lifecycle semantics (request-scoped DB sessions, per-test resets), or when teams keep re-inventing the same wiring. Frameworks like Spring, NestJS, or ASP.NET Core make the decision for you — they’re containers already.

Variants

ApproachUse CaseTrade-off
ConstructorMandatory dependenciesMost explicit; best for testing
Property/SetterOptional dependenciesCan create partially initialized objects
MethodPer-call dependenciesVerbose; used for strategy injection
Service LocatorLegacy codeHides dependencies; harder to test

Best Practices

  • Prefer constructor injection: makes dependencies explicit and immutable
  • Avoid service locators: they hide dependencies and make testing harder
  • Use interfaces/protocols: depend on abstractions, not concrete types. See Factory Pattern for object-creation abstractions.
  • Keep composition roots shallow: wire dependencies at the application entry point
  • Avoid primitive obsession: wrap config values in value objects (e.g., ApiKey, Timeout)
  • Resolve at the boundary: call container.resolve() in controllers/handlers, then pass plain instances down — never let domain code import the container

Common Mistakes

  1. Constructor explosion: more than 5 parameters signals a missing abstraction
  2. Leaking the container: passing the DI container into services defeats the purpose — it turns DI back into a service locator
  3. Tight coupling to a framework: use standard annotations (@Inject) where possible so swapping containers doesn’t rewrite business code
  4. Ignoring lifecycle: scoped services resolved as singletons leak request state — the classic “user A sees user B’s data” bug
  5. Circular dependencies: refactor into events or a mediator if A depends on B and B on A
  6. Registering side effects in factories: a factory that opens a connection at registration time runs it even when the service is never used — keep factories lazy

Further Reading

Frequently Asked Questions

Is DI only for object-oriented languages?

No. Functional languages get similar decoupling through higher-order functions and partial application — a function that takes its "dependencies" as parameters is constructor injection with less ceremony.

When should I use a DI container vs. manual wiring?

Manual wiring for small apps (under ~15 services) or when you value zero magic. Containers when the graph turns transitive, when lifecycle (singleton/scoped) matters, or when a framework already gives you one.

Does DI hurt performance?

Negligible at runtime — a singleton resolves to a Map lookup. Resolve once at the composition root or per request boundary, never per method call in a hot loop.

How do I handle circular dependencies in DI?

Circular dependencies (A needs B, B needs A) usually mean a design problem. Break the cycle by extracting shared logic into a third service C, using events or a mediator for one direction, or introducing lazy resolution where one side gets a factory instead of the instance:

// Breaking a cycle with a factory
container.registerSingleton('serviceA', () => new ServiceA(container.resolve('serviceB')));
container.registerSingleton('serviceB', () => new ServiceB(() => container.resolve('serviceA')));
Is this solution production-ready?

Yes. Constructor injection is how every modern framework works (Spring, NestJS, ASP.NET Core, Dagger). The lightweight container mirrors InversifyJS internals, the scoped lifecycle matches how FastAPI handles request-scoped sessions, and a dedicated test container is standard practice.

What are the performance characteristics of a container?

Singleton resolution is a Map lookup after first creation — O(1). Transient calls the factory every time, so the factory's cost is what matters. Startup validation adds a one-time pass over all registrations. Keep resolve() out of per-request hot paths: resolve once at the handler boundary and pass instances down.

How do I debug DI issues?

Log each resolution with its key and resulting type; for captive dependencies, log the lifecycle alongside. Most containers name the dependency chain in the circular-dependency error — read it. Run graph validation at startup to catch missing registrations before traffic hits. In tests, assert the test container registers the same keys as production.