Dependency Injection in TypeScript Without a Framework
Dependency injection doesn't require NestJS or InversifyJS. A simple pattern improves testability and decoupling without the overhead.
The Problem With Direct Imports
// Hard to test: db and emailService are hardcoded
import { db } from '../db';
import { emailService } from '../email';
export class UserService {
async createUser(data: CreateUserDto) {
const user = await db.users.create({ data });
await emailService.sendWelcome(user);
return user;
}
}
// In tests, you can't easily swap db or emailService
Constructor Injection
// Interfaces for your dependencies
interface UserRepository {
create(data: CreateUserDto): Promise<User>;
findById(id: string): Promise<User | null>;
}
interface EmailService {
sendWelcome(user: User): Promise<void>;
}
// Service accepts dependencies via constructor
export class UserService {
constructor(
private readonly users: UserRepository,
private readonly email: EmailService,
) {}
async createUser(data: CreateUserDto) {
const user = await this.users.create(data);
await this.email.sendWelcome(user);
return user;
}
}
// Production: wire with real implementations
const userService = new UserService(
new PrismaUserRepository(db),
new ResendEmailService(resend),
);
// Tests: wire with mocks
const userService = new UserService(
new InMemoryUserRepository(),
new MockEmailService(),
);
Simple Container
// Manual DI container — no library needed
export function createContainer() {
const db = new PrismaClient();
const resend = new Resend(process.env.RESEND_API_KEY!);
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const userRepo = new PrismaUserRepository(db);
const emailService = new ResendEmailService(resend);
const paymentService = new StripePaymentService(stripe);
return {
userService: new UserService(userRepo, emailService),
orderService: new OrderService(userRepo, paymentService, emailService),
};
}
// In your Express app
const container = createContainer();
app.use('/api/users', createUserRouter(container.userService));
Testing With DI
describe('UserService', () => {
it('sends welcome email on creation', async () => {
const mockEmail = { sendWelcome: vi.fn() };
const mockRepo = { create: vi.fn().mockResolvedValue({ id: '1', email: 'test@test.com' }) };
const service = new UserService(mockRepo, mockEmail);
await service.createUser({ email: 'test@test.com', name: 'Test' });
expect(mockEmail.sendWelcome).toHaveBeenCalledWith(
expect.objectContaining({ email: 'test@test.com' })
);
});
});
Clean architecture with DI, testable services, and a manual container are part of the backend patterns in the AI SaaS Starter Kit.
Top comments (0)