Dependency Injection has a reputation problem. Between bloated frameworks and decorator-heavy configs, it often feels like “too much magic.”
But at its core, DI is simple: It’s simply the act of injecting dependencies via a factory or constructor. It can be done manually, or with some library or framework support. At the end of the day, it’s an essential technique for creating clear boundaries between components.
For example, following code is using DI:
function createUserService(userApi: UserAPIClient) {
return {
async getUserById(id: string): Promise<User | undefined> {
try {
return userApi.fetch(`/users/${id}`);
} catch (err: unknown) {
if (err.name === 'NotFound') {
return;
}
throw err;
}
}
}
}
// The second example also uses DI via function parameters — but it doesn’t encapsulate behavior, making it slightly harder to extend.
// slightly more diffcult to extend
async function getUserById(userApi: UserAPIClient, id: string): Promise<User | undefined> {
try {
return userApi.fetch(`/users/${id}`);
} catch (err: unknown) {
if (err.name === 'NotFound') {
return;
}
throw err;
}
}
While DI is a simple technique for drawing clear boundaries, I’ve found it helpful to separate its use for shared singleton behavior vs context-specific behavior.
Used thoughtfully, DI helps you write code that’s:
- Easy to test
- Easy to understand
- Easy to refactor
The Role of DI: Composing Services, Not Context
Dependency Injection shines when handling long-lived, shared, and reusable logic:
- Logging
- Configuration
- API clients
- Business rules
While DI can technically be used for things like per-request data, frontend context, or React hooks — I’ve found a few constraints that work consistently well in practice:
- ⚙️ Scoped behaviors are better passed as function parameters to long-lived services.
- 🧱 Avoid stateful scoped behavior when possible — represent it as dry data.
- 🧩 If unavoidable, contain scoped state behind clear behavioral boundaries.
When using a DI framework, I avoid injecting scoped or contextual values into factory functions or constructors. Instead:
I deliberately keep DI focused on constructing long-lived behavioral components,
and pass context-specific values as arguments to their methods —
even if that means wiring things manually.
This separation of concerns keeps my boundaries clean, and avoids ambiguity about where state lives or how it's scoped.
Structured Side Effects
Apps have side effects. They need to:
- Log things
- Call APIs
- Persist data
These are all side effects — and they’re essential. Trying to remove them entirely is impractical. But what we can do is structure them intentionally.
That’s where DI fits in:
Dependency Injection doesn’t eliminate side effects — it isolates them.
It gives them clear entry points, makes them swappable, and keeps them from leaking into pure logic.
This isn't in conflict with functional principles — it's complementary.
Functional core, imperative shell — and DI lives in the shell.
By pushing side-effectful behavior to the edges of your system, and injecting it explicitly into behavioral units, you get:
- More predictable flows
- Cleaner separation of concerns
- Easier testability without mocks or global stubbing
- Even if you’re not writing "pure functional code," DI helps keep impurity structured, not scattered.
DI and Testing: You Get Control
Once you’ve drawn clear boundaries between behavior and side effects, testing becomes a lot easier.
- You can replace side-effectful parts with test doubles.
- You can isolate core behavior without mocks or globals.
- You can preload or stub behavior deterministically.
The key is knowing where the impurity lives — and keeping it out of the core logic.
It’s not about mocking everything — it’s about not needing to mock most things (if not all).