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:
- Testability: swap real services for fakes without touching the consumer
- Flexibility: change implementations in one place (the registration), not in every consumer
- Lifecycle management: containers own singletons, scoped instances, and disposal
- 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.
| Moment | What runs | What can go wrong |
|---|---|---|
| Registration | Store the factory, not the instance | Side effects execute even if the service is never used |
| Startup validation | Dry-resolve every key once | Skipped validation = missing registrations surface mid-request |
| Resolution | Run factory + apply lifecycle | Resolving in a hot loop; singleton capturing a scoped dep |
| Scope end | Clear the scoped map | Scoped 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
| Approach | Use Case | Trade-off |
|---|---|---|
| Constructor | Mandatory dependencies | Most explicit; best for testing |
| Property/Setter | Optional dependencies | Can create partially initialized objects |
| Method | Per-call dependencies | Verbose; used for strategy injection |
| Service Locator | Legacy code | Hides 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
- Constructor explosion: more than 5 parameters signals a missing abstraction
- Leaking the container: passing the DI container into services defeats the purpose — it turns DI back into a service locator
- Tight coupling to a framework: use standard annotations (
@Inject) where possible so swapping containers doesn’t rewrite business code - Ignoring lifecycle: scoped services resolved as singletons leak request state — the classic “user A sees user B’s data” bug
- Circular dependencies: refactor into events or a mediator if A depends on B and B on A
- 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
- Dependency Injection Pattern — the theory side: trade-offs, alternatives, UML.
- Dependency Injection in TypeScript — decorators, tsyringe/InversifyJS, NestJS integration.
- Martin Fowler: Inversion of Control Containers and the Dependency Injection pattern — the 2004 article that named the pattern.
- Companion code — the TS container, Python container, and test container as runnable files.
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.
Related Resources
Dependency Injection Pattern
Supply dependencies from outside rather than creating them internally. An architectural pattern for decoupled, testable code.
PatternDependency Injection Container in TypeScript
Build a lightweight DI container that resolves class dependencies automatically, enabling testable, loosely-coupled applications without frameworks like Angular or InversifyJS
PatternRepository Pattern
Abstract data access logic behind a clean interface. An architectural design pattern for testable, maintainable data layers.
RecipeAPI Mocking for Testing
Build reliable tests by mocking external APIs with WireMock, MockServer, and MSW to eliminate flakiness and test edge cases.
DocArchitecture Decision Record (ADR) Template
A lightweight template for documenting major architectural decisions, their context, options considered, and the reasoning behind the chosen approach.