DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Dependency Injection with Claude Code: Testable TypeScript Without DI Containers

Dependency injection decouples modules and makes unit testing possible without spinning up a database. Claude Code designs complete DI patterns — interfaces, repositories, and mock implementations — when you define the rules.


CLAUDE.md for Dependency Injection

## Dependency Injection Rules

### Core approach
- Constructor injection as the default
- DI containers: optional (small projects don't need them)
- Services never instantiate dependencies internally

### Interface design
- All external dependencies abstracted behind interfaces
  Examples: IEmailService, IUserRepository
- Tests swap in mock implementations
- Interfaces live in src/interfaces/

### Naming conventions
- Interfaces: I{Name} (IUserRepository)
- Implementations: {Name} (UserRepository)
- Test mocks: Mock{Name} (MockUserRepository)

### Testing
- Unit tests don't use real DBs (mock the Repository)
- DI-based code tests well with vitest + vi.fn()

### Prohibited
- new keyword to instantiate dependencies inside service classes
- Direct Prisma calls inside service classes (use Repository layer)
Enter fullscreen mode Exit fullscreen mode

Generating the DI Design

Generate a DI design for UserService (no DI container needed).

Requirements:
- Define IUserRepository interface
- UserRepository implementation (Prisma)
- MockUserRepository for tests
- UserService depends on IUserRepository (not concrete class)
- IEmailService for email sending

Generate:
- src/interfaces/IUserRepository.ts
- src/interfaces/IEmailService.ts
- src/repositories/UserRepository.ts
- src/services/UserService.ts
Enter fullscreen mode Exit fullscreen mode

Generated Interfaces

// src/interfaces/IUserRepository.ts
export interface IUserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  create(data: CreateUserData): Promise<User>;
  update(id: string, data: Partial<UpdateUserData>): Promise<User>;
  delete(id: string): Promise<void>;
}

// src/interfaces/IEmailService.ts
export interface IEmailService {
  sendWelcomeEmail(user: User): Promise<void>;
  sendPasswordResetEmail(user: User, token: string): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

Generated Repository

// src/repositories/UserRepository.ts
import { IUserRepository } from '../interfaces/IUserRepository';
import { prisma } from '../lib/prisma';

export class UserRepository implements IUserRepository {
  async findById(id: string): Promise<User | null> {
    return prisma.user.findUnique({ where: { id } });
  }

  async findByEmail(email: string): Promise<User | null> {
    return prisma.user.findUnique({ where: { email } });
  }

  async create(data: CreateUserData): Promise<User> {
    return prisma.user.create({ data });
  }

  async update(id: string, data: Partial<UpdateUserData>): Promise<User> {
    return prisma.user.update({ where: { id }, data });
  }

  async delete(id: string): Promise<void> {
    await prisma.user.delete({ where: { id } });
  }
}
Enter fullscreen mode Exit fullscreen mode

Generated Service with DI

// src/services/UserService.ts — depends on interfaces, not concrete classes
import { IUserRepository } from '../interfaces/IUserRepository';
import { IEmailService } from '../interfaces/IEmailService';

export class UserService {
  constructor(
    private readonly userRepository: IUserRepository, // injected
    private readonly emailService: IEmailService       // injected
  ) {}

  async createUser(data: CreateUserData): Promise<User> {
    const existing = await this.userRepository.findByEmail(data.email);
    if (existing) throw new ConflictError('Email already in use');

    const user = await this.userRepository.create(data);
    await this.emailService.sendWelcomeEmail(user);
    return user;
  }

  async getUserById(id: string): Promise<User> {
    const user = await this.userRepository.findById(id);
    if (!user) throw new NotFoundError(`User not found: ${id}`);
    return user;
  }
}
Enter fullscreen mode Exit fullscreen mode

Unit Tests Using DI

Generate unit tests for UserService.

Requirements:
- Mock IUserRepository (no DB)
- Mock IEmailService (no actual emails)
- Use vitest vi.fn() for mock functions
- Test cases: successful creation, duplicate email error

Save to: src/services/__tests__/UserService.test.ts
Enter fullscreen mode Exit fullscreen mode

Generated:

// src/services/__tests__/UserService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService } from '../UserService';
import { IUserRepository } from '../../interfaces/IUserRepository';
import { IEmailService } from '../../interfaces/IEmailService';

describe('UserService', () => {
  let userService: UserService;
  let mockUserRepository: IUserRepository;
  let mockEmailService: IEmailService;

  beforeEach(() => {
    mockUserRepository = {
      findById: vi.fn(),
      findByEmail: vi.fn(),
      create: vi.fn(),
      update: vi.fn(),
      delete: vi.fn(),
    };

    mockEmailService = {
      sendWelcomeEmail: vi.fn(),
      sendPasswordResetEmail: vi.fn(),
    };

    // Inject mock dependencies — no DB, no real email
    userService = new UserService(mockUserRepository, mockEmailService);
  });

  it('creates user and sends welcome email', async () => {
    const data = { email: 'new@example.com', name: 'Test User' };
    const created = { id: '123', ...data, createdAt: new Date() };

    vi.mocked(mockUserRepository.findByEmail).mockResolvedValue(null);
    vi.mocked(mockUserRepository.create).mockResolvedValue(created);
    vi.mocked(mockEmailService.sendWelcomeEmail).mockResolvedValue(undefined);

    const result = await userService.createUser(data);

    expect(result).toEqual(created);
    expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(created);
    expect(mockUserRepository.create).toHaveBeenCalledWith(data);
  });

  it('throws ConflictError when email already exists', async () => {
    vi.mocked(mockUserRepository.findByEmail).mockResolvedValue({
      id: 'existing', email: 'existing@example.com', name: 'Existing',
    });

    await expect(
      userService.createUser({ email: 'existing@example.com', name: 'New' })
    ).rejects.toThrow('Email already in use');

    // No create or email should happen
    expect(mockUserRepository.create).not.toHaveBeenCalled();
    expect(mockEmailService.sendWelcomeEmail).not.toHaveBeenCalled();
  });
});
Enter fullscreen mode Exit fullscreen mode

Summary

Design dependency injection with Claude Code:

  1. CLAUDE.md — Interface-first, constructor injection, no internal new
  2. Interfaces first — Define the contract before implementing
  3. Services depend on interfaces — Not concrete Prisma/Redis classes
  4. Unit tests use mocks — Fast, no DB, no external services

Code Review Pack (¥980) includes /code-review for DI pattern review — tight coupling, direct DB access in services, untestable designs.

👉 prompt-works.jp

Myouga (@myougatheaxo) — Claude Code engineer focused on clean architecture.

Top comments (0)