Dependency injection makes code testable by externalizing dependencies — instead of services creating their collaborators, they receive them. This enables unit testing with mocks, feature flags via substitution, and architecture that grows without circular imports. Claude Code generates DI containers, mock substitutions for tests, and the module structure that avoids coupling.
Manual DI (Simple Cases)
Our UserService creates its own database connection and email client.
This makes it impossible to test without a real DB and email server.
Refactor to inject dependencies.
// Before — untestable
class UserService {
private db = new DatabaseClient(process.env.DATABASE_URL); // Coupled to real DB
private email = new SendGridClient(process.env.SENDGRID_KEY);
async createUser(data: CreateUserInput): Promise<User> {
const user = await this.db.users.create(data);
await this.email.sendWelcome(user.email);
return user;
}
}
// After — injectable
interface UserRepository {
create(data: CreateUserInput): Promise<User>;
findById(id: string): Promise<User | null>;
}
interface EmailService {
sendWelcome(email: string): Promise<void>;
}
class UserService {
constructor(
private readonly userRepo: UserRepository,
private readonly email: EmailService,
) {}
async createUser(data: CreateUserInput): Promise<User> {
const user = await this.userRepo.create(data);
await this.email.sendWelcome(user.email);
return user;
}
}
// Production wiring
const service = new UserService(
new PostgresUserRepository(pool),
new SendGridEmailService(sendgridClient),
);
// Test wiring — no real DB or email server needed
const service = new UserService(
new InMemoryUserRepository(),
new FakeEmailService(),
);
InversifyJS Container
Wire our application with InversifyJS for a large-scale TypeScript app.
Services: UserService, NotificationService, AuditService.
Each has multiple dependencies. Support different bindings for test vs production.
// container/symbols.ts
const TYPES = {
UserRepository: Symbol('UserRepository'),
EmailService: Symbol('EmailService'),
AuditService: Symbol('AuditService'),
UserService: Symbol('UserService'),
DatabasePool: Symbol('DatabasePool'),
RedisClient: Symbol('RedisClient'),
};
export default TYPES;
// services/UserService.ts
import { injectable, inject } from 'inversify';
import TYPES from '../container/symbols';
@injectable()
export class UserService {
constructor(
@inject(TYPES.UserRepository) private userRepo: UserRepository,
@inject(TYPES.EmailService) private email: EmailService,
@inject(TYPES.AuditService) private audit: AuditService,
) {}
async createUser(data: CreateUserInput, requestingUserId?: string): Promise<User> {
const user = await this.userRepo.create(data);
await Promise.all([
this.email.sendWelcome(user.email),
this.audit.log({
action: 'user.created',
resourceId: user.id,
userId: requestingUserId ?? 'system',
}),
]);
return user;
}
}
// container/production.ts
import { Container } from 'inversify';
import TYPES from './symbols';
export function createProductionContainer(config: AppConfig) {
const container = new Container();
// Infrastructure
container.bind(TYPES.DatabasePool).toConstantValue(
new Pool({ connectionString: config.databaseUrl })
);
container.bind(TYPES.RedisClient).toConstantValue(
new Redis(config.redisUrl)
);
// Repositories
container.bind<UserRepository>(TYPES.UserRepository)
.to(PostgresUserRepository)
.inSingletonScope(); // One instance shared across app
// Services
container.bind<EmailService>(TYPES.EmailService)
.to(SendGridEmailService)
.inSingletonScope();
container.bind<AuditService>(TYPES.AuditService)
.to(DatabaseAuditService)
.inSingletonScope();
container.bind<UserService>(TYPES.UserService)
.to(UserService)
.inSingletonScope();
return container;
}
// container/test.ts — swap implementations for tests
export function createTestContainer() {
const container = new Container();
container.bind<UserRepository>(TYPES.UserRepository)
.to(InMemoryUserRepository)
.inSingletonScope();
container.bind<EmailService>(TYPES.EmailService)
.toConstantValue(new FakeEmailService()); // Captures emails for assertions
container.bind<AuditService>(TYPES.AuditService)
.toConstantValue(new NoOpAuditService()); // Silences audit noise in tests
container.bind<UserService>(TYPES.UserService).to(UserService);
return container;
}
// Usage in tests
describe('UserService', () => {
let container: Container;
let userService: UserService;
let emailService: FakeEmailService;
beforeEach(() => {
container = createTestContainer();
userService = container.get(TYPES.UserService);
emailService = container.get<FakeEmailService>(TYPES.EmailService);
});
it('sends welcome email on registration', async () => {
await userService.createUser({ email: '[email protected]', name: 'Test User' });
expect(emailService.sentEmails).toHaveLength(1);
expect(emailService.sentEmails[0].template).toBe('welcome');
expect(emailService.sentEmails[0].to).toBe('[email protected]');
});
it('does not expose password in returned user', async () => {
const user = await userService.createUser({ email: '[email protected]', name: 'Test', password: 'secret' });
expect(user).not.toHaveProperty('password');
expect(user).not.toHaveProperty('passwordHash');
});
});
DI in React
Apply DI principles to React for testable components.
Services should be injectable via context, swappable in tests and Storybook.
// context/ServiceContext.tsx
import { createContext, useContext } from 'react';
interface Services {
userService: UserServiceInterface;
productService: ProductServiceInterface;
analyticsService: AnalyticsServiceInterface;
}
const ServiceContext = createContext<Services | null>(null);
export function ServiceProvider({ services, children }: { services: Services; children: React.ReactNode }) {
return <ServiceContext.Provider value={services}>{children}</ServiceContext.Provider>;
}
export function useServices(): Services {
const ctx = useContext(ServiceContext);
if (!ctx) throw new Error('useServices must be used within ServiceProvider');
return ctx;
}
// In component — no direct imports of service implementations
function UserProfile({ userId }: { userId: string }) {
const { userService } = useServices();
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
userService.getUser(userId).then(setUser);
}, [userId, userService]);
if (!user) return <Skeleton />;
return <div>{user.name}</div>;
}
// Production app
<ServiceProvider services={{
userService: new RealUserService(apiClient),
productService: new RealProductService(apiClient),
analyticsService: new SegmentAnalytics(process.env.SEGMENT_KEY),
}}>
<App />
</ServiceProvider>
// Tests and Storybook
<ServiceProvider services={{
userService: new FakeUserService([testUser]),
productService: new FakeProductService([testProduct]),
analyticsService: new NoOpAnalytics(),
}}>
<UserProfile userId="test-user" />
</ServiceProvider>
CLAUDE.md for DI Architecture
## Dependency Injection Architecture
- Container: InversifyJS (production), test container in tests/setup.ts
- All services: injected via constructor, never new'd inside other services
- Interface-first: define interface before implementation
- Test doubles: ./src/test-doubles/ — FakeXxx for in-memory impls, SpyXxx for tracking calls
- Singletons: repositories and services; transient: request-scoped objects
- No circular deps: run `npx madge --circular src/` in CI to catch them early
For testing patterns that take full advantage of DI mocking, see the testing strategies guide. For the modular architecture DI enables at scale, see the microservices guide. The Claude Skills 360 bundle includes architecture skill sets for dependency injection, IoC containers, and testable design. Start with the free tier to try DI refactoring patterns.