Skip to main content
RapidDev - Software Development Agency
cursor-tutorial

How to generate dependency-injection-friendly code with Cursor

Cursor defaults to tightly coupled code with direct imports instead of dependency injection. By adding IoC rules to .cursorrules, referencing your DI container setup with @file, and prompting Cursor to generate constructor-injected classes, you get testable, loosely coupled code that works with any IoC container like InversifyJS or tsyringe.

What you'll learn

  • How to configure .cursorrules for dependency injection patterns
  • How to prompt Cursor to generate interface-based, injectable classes
  • How to set up a DI container configuration with Cursor
  • How to make Cursor produce testable code with mocked dependencies
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner7 min read10-15 minCursor Free+, TypeScript/Node.js with any DI containerMarch 2026RapidDev Engineering Team
TL;DR

Cursor defaults to tightly coupled code with direct imports instead of dependency injection. By adding IoC rules to .cursorrules, referencing your DI container setup with @file, and prompting Cursor to generate constructor-injected classes, you get testable, loosely coupled code that works with any IoC container like InversifyJS or tsyringe.

Generating dependency-injection-friendly code with Cursor

Cursor tends to generate classes with hardcoded dependencies (new UserRepository() inside a service). This makes testing difficult and couples your code tightly. This tutorial shows how to configure Cursor to generate classes that depend on interfaces, accept dependencies through constructors, and integrate with IoC containers like InversifyJS or tsyringe.

Prerequisites

  • Cursor installed with a TypeScript project
  • A DI container installed (InversifyJS, tsyringe, or similar)
  • Understanding of interfaces and constructor injection
  • Familiarity with Cmd+L and Cmd+K in Cursor

Step-by-step guide

1

Add dependency injection rules to .cursor/rules

Create rules that tell Cursor to always use constructor injection and interface-based dependencies. This changes the default pattern from tight coupling to dependency inversion.

.cursor/rules/dependency-injection.mdc
1---
2description: Dependency injection rules
3globs: "src/services/**/*.ts,src/repositories/**/*.ts"
4alwaysApply: true
5---
6
7## Dependency Injection Rules
8- ALWAYS depend on interfaces, NEVER on concrete classes
9- ALWAYS accept dependencies through the constructor
10- NEVER use 'new' to instantiate dependencies inside a class
11- Define interfaces in src/types/interfaces.ts
12- Use @injectable() decorator from tsyringe
13- Use @inject() for constructor parameters
14- Name interfaces with I prefix: IUserRepository, IOrderService
15- Export interfaces separately from implementations
16- Every class must be testable with mocked dependencies

Expected result: Cursor generates classes with constructor injection and interface-based dependencies.

2

Generate interfaces for your service layer

Ask Cursor to create the interfaces first, then the implementations. This establishes the contract before the code, making the dependency graph explicit.

src/types/interfaces.ts
1// Cursor Chat prompt (Cmd+L):
2// Create interfaces for a UserService and UserRepository
3// in src/types/interfaces.ts. UserRepository should have
4// findById, findAll, create, update, delete methods.
5// UserService should have getUser, listUsers, createUser,
6// updateUser. All methods return Promises with typed results.
7
8export interface IUserRepository {
9 findById(id: string): Promise<User | null>;
10 findAll(page: number, limit: number): Promise<User[]>;
11 create(data: CreateUserDto): Promise<User>;
12 update(id: string, data: UpdateUserDto): Promise<User>;
13 delete(id: string): Promise<void>;
14}
15
16export interface IUserService {
17 getUser(id: string): Promise<User>;
18 listUsers(page: number, limit: number): Promise<User[]>;
19 createUser(data: CreateUserDto): Promise<User>;
20 updateUser(id: string, data: UpdateUserDto): Promise<User>;
21}

Expected result: Clean interface definitions that serve as contracts for implementations.

3

Generate an injectable service class

Ask Cursor to generate a service that implements the interface and accepts its dependencies through the constructor. Reference the interfaces file so Cursor knows the exact method signatures.

src/services/UserService.ts
1// Cursor Chat prompt (Cmd+L):
2// @src/types/interfaces.ts Generate a UserService class
3// that implements IUserService. It depends on IUserRepository
4// through constructor injection. Use @injectable() and
5// @inject() decorators from tsyringe. Add error handling.
6
7import { injectable, inject } from 'tsyringe';
8import type { IUserService, IUserRepository } from '@/types/interfaces';
9
10@injectable()
11export class UserService implements IUserService {
12 constructor(
13 @inject('IUserRepository')
14 private userRepository: IUserRepository
15 ) {}
16
17 async getUser(id: string): Promise<User> {
18 const user = await this.userRepository.findById(id);
19 if (!user) throw new Error(`User not found: ${id}`);
20 return user;
21 }
22
23 async listUsers(page: number, limit: number): Promise<User[]> {
24 return this.userRepository.findAll(page, limit);
25 }
26
27 async createUser(data: CreateUserDto): Promise<User> {
28 return this.userRepository.create(data);
29 }
30
31 async updateUser(id: string, data: UpdateUserDto): Promise<User> {
32 return this.userRepository.update(id, data);
33 }
34}

Pro tip: Reference the interface file with @file in every prompt. This ensures Cursor implements the exact method signatures defined in the contract.

Expected result: An injectable service class that depends on an interface, not a concrete repository.

4

Generate the DI container configuration

Ask Cursor to create the container setup that binds interfaces to implementations. This is where concrete classes are wired together.

src/container.ts
1// Cursor Chat prompt (Cmd+L):
2// @src/types/interfaces.ts @src/services/UserService.ts
3// @src/repositories/UserRepository.ts
4// Generate a tsyringe container configuration at
5// src/container.ts that registers:
6// IUserRepository -> UserRepository
7// IUserService -> UserService
8
9import 'reflect-metadata';
10import { container } from 'tsyringe';
11import { UserRepository } from './repositories/UserRepository';
12import { UserService } from './services/UserService';
13
14container.register('IUserRepository', { useClass: UserRepository });
15container.register('IUserService', { useClass: UserService });
16
17export { container };

Expected result: A DI container that maps interfaces to implementations, ready for use across the application.

5

Generate a test with mocked dependencies

Verify the DI pattern works by asking Cursor to generate a unit test that mocks the repository. This proves the service is properly decoupled from its dependencies.

src/services/UserService.test.ts
1// Cursor Chat prompt (Cmd+L):
2// @src/services/UserService.ts @src/types/interfaces.ts
3// Generate a unit test for UserService using Vitest.
4// Mock IUserRepository with vi.fn() implementations.
5// Test getUser with found and not-found scenarios.
6// Do NOT import the real UserRepository.
7
8import { describe, it, expect, vi } from 'vitest';
9import { UserService } from './UserService';
10import type { IUserRepository } from '@/types/interfaces';
11
12const mockRepo: IUserRepository = {
13 findById: vi.fn(),
14 findAll: vi.fn(),
15 create: vi.fn(),
16 update: vi.fn(),
17 delete: vi.fn(),
18};
19
20describe('UserService', () => {
21 const service = new UserService(mockRepo);
22
23 it('returns user when found', async () => {
24 vi.mocked(mockRepo.findById).mockResolvedValue({ id: '1', name: 'Test' });
25 const user = await service.getUser('1');
26 expect(user.name).toBe('Test');
27 });
28
29 it('throws when user not found', async () => {
30 vi.mocked(mockRepo.findById).mockResolvedValue(null);
31 await expect(service.getUser('999')).rejects.toThrow('User not found');
32 });
33});

Expected result: Tests pass with mocked dependencies, proving the service is properly decoupled.

Complete working example

src/services/UserService.ts
1import { injectable, inject } from 'tsyringe';
2import type {
3 IUserService,
4 IUserRepository,
5} from '@/types/interfaces';
6import type { User, CreateUserDto, UpdateUserDto } from '@/types/user';
7
8@injectable()
9export class UserService implements IUserService {
10 constructor(
11 @inject('IUserRepository')
12 private readonly userRepository: IUserRepository
13 ) {}
14
15 async getUser(id: string): Promise<User> {
16 if (!id) throw new Error('User ID is required');
17 const user = await this.userRepository.findById(id);
18 if (!user) {
19 throw new Error(`User not found: ${id}`);
20 }
21 return user;
22 }
23
24 async listUsers(page = 1, limit = 20): Promise<User[]> {
25 if (page < 1) throw new Error('Page must be >= 1');
26 if (limit < 1 || limit > 100) throw new Error('Limit must be 1-100');
27 return this.userRepository.findAll(page, limit);
28 }
29
30 async createUser(data: CreateUserDto): Promise<User> {
31 if (!data.email) throw new Error('Email is required');
32 return this.userRepository.create(data);
33 }
34
35 async updateUser(id: string, data: UpdateUserDto): Promise<User> {
36 const existing = await this.userRepository.findById(id);
37 if (!existing) throw new Error(`User not found: ${id}`);
38 return this.userRepository.update(id, data);
39 }
40
41 async deleteUser(id: string): Promise<void> {
42 const existing = await this.userRepository.findById(id);
43 if (!existing) throw new Error(`User not found: ${id}`);
44 return this.userRepository.delete(id);
45 }
46}

Common mistakes when generating dependency-injection-friendly code with Cursor

Why it's a problem: Cursor using 'new Repository()' inside service classes

How to avoid: Add 'NEVER use new to instantiate dependencies inside a class' to .cursorrules. Require constructor injection for all dependencies.

Why it's a problem: Forgetting to define interfaces before implementations

How to avoid: Always generate interfaces first, then reference them with @file when generating implementations.

Why it's a problem: Importing concrete classes in test files

How to avoid: In your test generation prompt, explicitly say 'Do NOT import the real repository. Use mocked implementations of the interface.'

Best practices

  • Define interfaces before implementations and reference them in all Cursor prompts
  • Add DI rules to .cursorrules requiring constructor injection and interface-based dependencies
  • Place all interfaces in src/types/interfaces.ts for easy @file referencing
  • Generate tests alongside services to verify decoupling immediately
  • Use the @inject() decorator with string tokens for container resolution
  • Keep the container configuration in a single file for easy auditing
  • Mark constructor parameters as private readonly for immutability

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

Create a dependency-injection-friendly UserService in TypeScript using tsyringe. The service depends on IUserRepository (interface) through constructor injection. Include @injectable and @inject decorators. Implement getUser, listUsers, createUser, updateUser methods with error handling. Then generate the container configuration and a unit test with mocked repository.

Cursor Prompt

In Cursor Chat (Cmd+L): @src/types/interfaces.ts @.cursor/rules/dependency-injection.mdc Generate a UserService that implements IUserService. Accept IUserRepository through constructor injection with @inject('IUserRepository'). Add input validation and error handling. Follow our DI rules.

Frequently asked questions

Which DI container should I use with Cursor?

tsyringe is the simplest and works well with Cursor. InversifyJS has more features but more boilerplate. NestJS has built-in DI. Specify your choice in .cursorrules so Cursor uses the correct decorators.

Does Cursor understand the @injectable decorator?

Yes. Cursor's training data includes tsyringe, InversifyJS, and NestJS DI patterns. Specify which container you use in your rules and Cursor will apply the correct decorators.

Can I use DI without a container?

Yes. Manual constructor injection (passing dependencies in the constructor call) works without any container. Ask Cursor to generate a factory function instead of container configuration.

How do I handle DI in React components?

Use React Context to provide services to components. Ask Cursor to generate a ServiceProvider component that resolves services from the DI container and passes them through Context.

Will DI patterns increase my bundle size?

The runtime overhead is minimal. tsyringe adds about 3KB gzipped. The reflect-metadata polyfill adds about 5KB. For frontend code where bundle size matters, consider manual injection without a container.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.