Dependency Injection in TypeScript Without a Framework
If you've used NestJS, you know how good dependency injection feels. You slap @Injectable() on a class, declare it in a module, and the framework hands you fully wired services like magic. It's great — until you realize you've adopted an entire opinionated framework just to avoid passing arguments manually.
On the other end, most TypeScript projects outside NestJS have zero DI. Services import other services directly. Route handlers new up their dependencies inline. Testing means reaching for jest.mock() and monkey-patching module imports. It works, but it's brittle, and it couples everything to everything. There's a middle ground that nobody talks about: doing DI yourself, with plain TypeScript, in about 40 lines of infrastructure code.
What DI Actually Is
Strip away the decorators, the reflect-metadata, the container registrations. Dependency injection is one idea: pass dependencies in instead of importing them directly.
Here's what most TypeScript code looks like without DI:
// user.service.ts — tightly coupled
import { PrismaClient } from "@prisma/client";
import { sendEmail } from "./email";
const prisma = new PrismaClient();
export async function createUser(email: string, name: string) {
const user = await prisma.user.create({ data: { email, name } });
await sendEmail(email, "Welcome!", `Hi ${name}, welcome aboard.`);
return user;
}This function is impossible to test without a real database and a real email provider (or brittle module mocks). It creates its own dependencies. You can't swap them.
Now the same thing with DI:
// user.service.ts — dependencies injected
interface UserRepository {
create(data: { email: string; name: string }): Promise<User>;
}
interface EmailClient {
send(to: string, subject: string, body: string): Promise<void>;
}
export function createUser(
repo: UserRepository,
email: EmailClient,
userEmail: string,
name: string
) {
const user = await repo.create({ data: { email: userEmail, name } });
await email.send(userEmail, "Welcome!", `Hi ${name}, welcome aboard.`);
return user;
}That's it. No framework. No decorators. The function declares what it needs, and the caller provides it. Testing is now trivial — pass in fakes.
Level 1: Constructor Injection
The most natural pattern in TypeScript. Define interfaces for your dependencies, accept them through the constructor, and store them as private fields.
interface UserRepository {
create(data: { email: string; name: string }): Promise<User>;
findByEmail(email: string): Promise<User | null>;
}
interface EmailClient {
send(to: string, subject: string, body: string): Promise<void>;
}
class UserService {
constructor(
private readonly repo: UserRepository,
private readonly email: EmailClient
) {}
async register(email: string, name: string): Promise<User> {
const existing = await this.repo.findByEmail(email);
if (existing) {
throw new Error("User already exists");
}
const user = await this.repo.create({ email, name });
await this.email.send(
email,
"Welcome!",
`Hi ${name}, your account is ready.`
);
return user;
}
}Wiring it up is explicit:
import { PrismaUserRepository } from "./infra/prisma-user-repo";
import { ResendEmailClient } from "./infra/resend-email-client";
const userService = new UserService(
new PrismaUserRepository(prisma),
new ResendEmailClient(process.env.RESEND_API_KEY!)
);This is the Factory Method pattern in its most practical form — you decide which concrete implementation to create and inject, while the service only knows about the interface.
Level 2: Factory Functions
Classes are fine, but sometimes they feel heavy for what's really just a bag of functions. Factory functions give you the same inversion of control with less ceremony. TypeScript's structural typing means you don't even need explicit implements clauses.
interface UserRepository {
create(data: { email: string; name: string }): Promise<User>;
findByEmail(email: string): Promise<User | null>;
}
interface EmailClient {
send(to: string, subject: string, body: string): Promise<void>;
}
interface UserService {
register(email: string, name: string): Promise<User>;
}
function createUserService(deps: {
repo: UserRepository;
email: EmailClient;
}): UserService {
return {
async register(email, name) {
const existing = await deps.repo.findByEmail(email);
if (existing) throw new Error("User already exists");
const user = await deps.repo.create({ email, name });
await deps.email.send(
email,
"Welcome!",
`Hi ${name}, your account is ready.`
);
return user;
},
};
}This is essentially the Strategy pattern — the factory closes over its dependencies, and you can swap strategies (implementations) at the call site. No class hierarchies, no extends, just functions and objects.
The wiring looks almost identical:
const userService = createUserService({
repo: new PrismaUserRepository(prisma),
email: new ResendEmailClient(process.env.RESEND_API_KEY!),
});I reach for this pattern more than constructor injection these days. It's lighter, composes better, and TypeScript infers the return type perfectly.
Level 3: A Simple Container
Once you have more than a handful of services, manually wiring everything gets tedious. You need a container — but not a framework. Here's one in about 40 lines:
type Factory<T> = (container: Container) => T;
interface Registration<T> {
factory: Factory<T>;
singleton: boolean;
instance?: T;
}
class Container {
private registrations = new Map<string, Registration<unknown>>();
register<T>(
token: string,
factory: Factory<T>,
options: { singleton?: boolean } = {}
): void {
this.registrations.set(token, {
factory,
singleton: options.singleton ?? true,
});
}
resolve<T>(token: string): T {
const registration = this.registrations.get(token);
if (!registration) {
throw new Error(`No registration found for "${token}"`);
}
if (registration.singleton && registration.instance) {
return registration.instance as T;
}
const instance = registration.factory(this) as T;
if (registration.singleton) {
registration.instance = instance;
}
return instance;
}
}Register your dependencies, and they get resolved lazily:
const container = new Container();
// Infrastructure
container.register("db", () => new PrismaClient(), { singleton: true });
container.register(
"emailClient",
() => new ResendEmailClient(process.env.RESEND_API_KEY!),
{ singleton: true }
);
// Repositories
container.register("userRepo", (c) => new PrismaUserRepository(c.resolve("db")));
// Services
container.register("userService", (c) =>
createUserService({
repo: c.resolve("userRepo"),
email: c.resolve("emailClient"),
})
);
// Usage
const userService = container.resolve<UserService>("userService");Each factory receives the container itself, so dependencies can resolve their own dependencies. Singletons are created once and cached. Transient registrations create a new instance every time.
Is this as type-safe as it could be? Not perfectly — the resolve<T> cast trusts you to use the right type for each token. You can improve this with a typed token map if you want:
interface TokenMap {
db: PrismaClient;
emailClient: EmailClient;
userRepo: UserRepository;
userService: UserService;
}
// Then resolve becomes:
resolve<K extends keyof TokenMap>(token: K): TokenMap[K];That eliminates the any gap entirely. Whether the extra boilerplate is worth it depends on your project size.
Testing Made Easy
Every level of DI makes testing straightforward. No jest.mock(), no module patching, no import order hacks. You just pass in fakes.
// Testing with constructor injection or factory functions
describe("UserService", () => {
it("should reject duplicate emails", async () => {
const fakeRepo: UserRepository = {
create: vi.fn(),
findByEmail: vi.fn().mockResolvedValue({ id: "1", email: "a@b.com", name: "Existing" }),
};
const fakeEmail: EmailClient = {
send: vi.fn(),
};
const service = createUserService({ repo: fakeRepo, email: fakeEmail });
await expect(service.register("a@b.com", "New User")).rejects.toThrow(
"User already exists"
);
expect(fakeRepo.create).not.toHaveBeenCalled();
expect(fakeEmail.send).not.toHaveBeenCalled();
});
it("should send welcome email on registration", async () => {
const fakeRepo: UserRepository = {
create: vi.fn().mockResolvedValue({ id: "2", email: "new@b.com", name: "New" }),
findByEmail: vi.fn().mockResolvedValue(null),
};
const fakeEmail: EmailClient = {
send: vi.fn(),
};
const service = createUserService({ repo: fakeRepo, email: fakeEmail });
await service.register("new@b.com", "New");
expect(fakeEmail.send).toHaveBeenCalledWith(
"new@b.com",
"Welcome!",
"Hi New, your account is ready."
);
});
});Notice what's happening: you test behavior, not implementation. You don't care whether the service uses Prisma or an in-memory array internally. You don't care if the email client uses Resend, SendGrid, or a carrier pigeon. The tests are fast, deterministic, and don't touch the network.
The Composition Root
Every DI setup needs a composition root — the single place where all dependencies are wired together. Everything else in your codebase works with interfaces. Only the composition root knows about concrete implementations.
For a Next.js API route (App Router):
// lib/container.ts
import { PrismaClient } from "@prisma/client";
import { PrismaUserRepository } from "@/infra/prisma-user-repo";
import { ResendEmailClient } from "@/infra/resend-email-client";
import { createUserService } from "@/services/user-service";
const prisma = new PrismaClient();
export const services = {
userService: createUserService({
repo: new PrismaUserRepository(prisma),
email: new ResendEmailClient(process.env.RESEND_API_KEY!),
}),
};
// app/api/users/route.ts
import { services } from "@/lib/container";
export async function POST(req: Request) {
const { email, name } = await req.json();
const user = await services.userService.register(email, name);
return Response.json(user, { status: 201 });
}For a standalone Express/Fastify app:
// src/composition-root.ts
import { PrismaClient } from "@prisma/client";
import { PrismaUserRepository } from "./infra/prisma-user-repo";
import { ResendEmailClient } from "./infra/resend-email-client";
import { createUserService } from "./services/user-service";
import { createOrderService } from "./services/order-service";
export function createAppServices() {
const prisma = new PrismaClient();
const userRepo = new PrismaUserRepository(prisma);
const emailClient = new ResendEmailClient(process.env.RESEND_API_KEY!);
return {
userService: createUserService({ repo: userRepo, email: emailClient }),
orderService: createOrderService({ repo: new PrismaOrderRepository(prisma) }),
// Add more services here
};
}
// src/main.ts
import Fastify from "fastify";
import { createAppServices } from "./composition-root";
import { userRoutes } from "./routes/user-routes";
const app = Fastify();
const services = createAppServices();
app.register(userRoutes, { services });
app.listen({ port: 3000 });The key principle: imports flow inward. Route handlers import from the composition root. The composition root imports concrete implementations. Services import nothing — they only depend on interfaces passed to them.
Real-World Project Structure
Here's how I structure a project using manual DI:
src/
├── domain/ # Interfaces and types only — no implementations
│ ├── user.ts # User type
│ ├── user-repository.ts # UserRepository interface
│ └── email-client.ts # EmailClient interface
├── infra/ # Concrete implementations
│ ├── prisma-user-repo.ts
│ └── resend-email-client.ts
├── services/ # Business logic — depends on domain interfaces
│ ├── user-service.ts
│ └── order-service.ts
├── routes/ # HTTP layer — receives services, calls them
│ └── user-routes.ts
├── composition-root.ts # Wires everything together
└── main.ts # Entry point
The rule is simple: domain/ has zero imports from other project directories. services/ imports only from domain/. infra/ implements interfaces from domain/. routes/ receives services as arguments. Only composition-root.ts imports from everywhere.
When to Use a Framework
Manual DI isn't always the right call. Here's how the options stack up:
| Approach | Best For | Trade-off |
|---|---|---|
| Manual DI (this article) | Small-to-medium projects, libraries, serverless functions | You write the wiring yourself |
| NestJS | Large enterprise apps, teams that want conventions | Full framework buy-in, decorators everywhere |
| tsyringe | Adding DI to existing projects | Requires reflect-metadata and decorators |
| inversify | Complex apps that need advanced DI features | Heavy API surface, learning curve |
My honest take: if you're already using NestJS, use its DI — it's excellent and well-integrated. If you're building a Next.js app, a CLI tool, a library, or a serverless function, manual DI with factory functions is almost always enough. You don't need a container until you have 20+ services, and even then, the 40-line container from this article covers most cases.
The moment I'd reach for a framework is when I need decorators for cross-cutting concerns (logging, caching, validation on every service method) or dynamic module loading (plugins that register their own services at runtime). For everything else, plain TypeScript does the job.
Takeaway
Dependency injection is not a framework feature. It's a design decision: do your functions and classes create their own dependencies, or do they receive them? The second option makes your code testable, flexible, and explicit about what it needs.
Start with factory functions and a composition root. That covers 90% of projects. If you outgrow it, the 40-line container gets you to 99%. And if you truly need the remaining 1% — auto-discovery, decorators, module systems — then a framework like NestJS has earned its place in your stack.
Comments
No comments yet. Be the first to comment!