DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Dependency Injection in TypeScript Without a Framework

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
Enter fullscreen mode Exit fullscreen mode

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(),
);
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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' })
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

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)