Skip to content

Dependency Injection

Learn how to use Veloce’s built-in dependency injection system effectively.

Dependency Injection (DI) is a design pattern that helps you write modular, testable, and maintainable code. Veloce includes a built-in DI container that manages the lifecycle of your dependencies.

  • Testability: Easy to mock dependencies in tests
  • Modularity: Clear separation of concerns
  • Maintainability: Changes to dependencies don’t affect consumers
  • Flexibility: Easy to swap implementations
class DatabaseService {
async query(sql: string) {
// Database logic
return [];
}
}
class UserService {
async getUsers() {
return [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
];
}
async getUserById(id: string) {
const users = await this.getUsers();
return users.find(u => u.id === parseInt(id));
}
}

Use the @Depends() decorator to inject dependencies into your handlers:

@Controller('/users')
class UserController {
@Get('/')
async listUsers(@Depends(UserService) userService: UserService) {
return await userService.getUsers();
}
@Get('/:id')
async getUser(
@Param('id') id: string,
@Depends(UserService) userService: UserService
) {
const user = await userService.getUserById(id);
if (!user) {
throw new HTTPException(404, 'User not found');
}
return user;
}
}

Register your services with the DI container:

const app = new Veloce();
const container = app.getContainer();
// Register services
container.register(DatabaseService, { scope: 'singleton' });
container.register(UserService, { scope: 'request' });
// Include controller
app.include(UserController);

Veloce supports three dependency scopes:

One instance for the entire application lifecycle.

container.register(DatabaseService, { scope: 'singleton' });

Use for:

  • Database connections
  • Configuration services
  • Stateless services
  • Caches

Example:

class ConfigService {
private config: Record<string, any>;
constructor() {
this.config = {
apiKey: process.env.API_KEY,
dbUrl: process.env.DATABASE_URL,
};
}
get(key: string) {
return this.config[key];
}
}
// Register as singleton
container.register(ConfigService, { scope: 'singleton' });

One instance per HTTP request (default).

container.register(UserService, { scope: 'request' });

Use for:

  • Request-specific services
  • Services that need request context
  • Services with request-scoped state

Example:

class RequestLogger {
private logs: string[] = [];
log(message: string) {
this.logs.push(`[${new Date().toISOString()}] ${message}`);
}
getLogs() {
return this.logs;
}
}
// Register as request-scoped
container.register(RequestLogger, { scope: 'request' });
@Controller('/api')
class ApiController {
@Get('/data')
async getData(@Depends(RequestLogger) logger: RequestLogger) {
logger.log('Fetching data');
// ... fetch data
logger.log('Data fetched');
return { logs: logger.getLogs() };
}
}

New instance every time it’s injected.

container.register(TemporaryService, { scope: 'transient' });

Use for:

  • Stateful objects
  • Objects that should not be shared
  • Lightweight objects

Example:

class UniqueIdGenerator {
private id: string;
constructor() {
this.id = crypto.randomUUID();
}
getId() {
return this.id;
}
}
// Register as transient
container.register(UniqueIdGenerator, { scope: 'transient' });
class MyService {
doSomething() {
return 'done';
}
}
container.register(MyService, { scope: 'singleton' });

Use a factory function for complex initialization:

class DatabaseService {
constructor(private connectionString: string) {}
async query(sql: string) {
// Use connectionString
return [];
}
}
container.register(DatabaseService, {
scope: 'singleton',
factory: () => {
const connectionString = process.env.DATABASE_URL || 'default';
return new DatabaseService(connectionString);
},
});
container.register(DatabaseService, {
scope: 'singleton',
factory: async () => {
const config = await loadConfig();
const db = new DatabaseService(config.dbUrl);
await db.connect();
return db;
},
});

Services can depend on other services:

class DatabaseService {
async query(sql: string) {
return [];
}
}
class UserRepository {
constructor(private db: DatabaseService) {}
async findAll() {
return this.db.query('SELECT * FROM users');
}
async findById(id: string) {
return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
}
}
class UserService {
constructor(private userRepo: UserRepository) {}
async getUsers() {
return this.userRepo.findAll();
}
async getUser(id: string) {
return this.userRepo.findById(id);
}
}
// Register all services
container.register(DatabaseService, { scope: 'singleton' });
container.register(UserRepository, { scope: 'singleton' });
container.register(UserService, { scope: 'request' });
// Use in controller
@Controller('/users')
class UserController {
@Get('/')
async list(@Depends(UserService) userService: UserService) {
// UserService automatically gets UserRepository
// UserRepository automatically gets DatabaseService
return await userService.getUsers();
}
}

Inject multiple dependencies in a single handler:

@Post('/users')
async createUser(
@Body(UserSchema) userData: User,
@Depends(UserService) userService: UserService,
@Depends(EmailService) emailService: EmailService,
@Depends(LoggerService) logger: LoggerService
) {
logger.info('Creating user', userData);
const user = await userService.createUser(userData);
await emailService.sendWelcomeEmail(user.email);
logger.info('User created', user);
return user;
}

Use factory functions for conditional logic:

container.register(CacheService, {
scope: 'singleton',
factory: () => {
if (process.env.REDIS_URL) {
return new RedisCacheService(process.env.REDIS_URL);
} else {
return new InMemoryCacheService();
}
},
});
interface IEmailService {
sendEmail(to: string, subject: string, body: string): Promise<void>;
}
class SendGridEmailService implements IEmailService {
async sendEmail(to: string, subject: string, body: string) {
// SendGrid implementation
}
}
class MockEmailService implements IEmailService {
async sendEmail(to: string, subject: string, body: string) {
console.log(`Mock email to ${to}: ${subject}`);
}
}
// Register based on environment
container.register(IEmailService as any, {
scope: 'singleton',
factory: () => {
if (process.env.NODE_ENV === 'production') {
return new SendGridEmailService();
} else {
return new MockEmailService();
}
},
});
import { describe, it, expect, beforeEach } from 'bun:test';
import { createTestApp, mockDependency } from 'veloce/testing';
describe('UserController', () => {
let app: Veloce;
let mockUserService: any;
beforeEach(() => {
app = createTestApp();
// Create mock
mockUserService = {
getUsers: async () => [
{ id: 1, name: 'Test User' },
],
getUserById: async (id: string) => {
return { id: parseInt(id), name: 'Test User' };
},
};
// Replace real service with mock
mockDependency(UserService, mockUserService);
app.include(UserController);
});
it('should get users', async () => {
const response = await app.request('/users');
const data = await response.json();
expect(data).toEqual([
{ id: 1, name: 'Test User' },
]);
});
it('should get user by id', async () => {
const response = await app.request('/users/1');
const data = await response.json();
expect(data).toEqual({
id: 1,
name: 'Test User',
});
});
});
describe('UserService', () => {
it('should create user', async () => {
const app = createTestApp();
const container = app.getContainer();
// Register test database
const testDb = new InMemoryDatabase();
container.register(DatabaseService, {
scope: 'singleton',
factory: () => testDb,
});
container.register(UserService, { scope: 'singleton' });
const userService = await container.resolve(UserService);
const user = await userService.createUser({
name: 'Test',
email: 'test@example.com',
});
expect(user.name).toBe('Test');
});
});
// ✓ Good
class UserService {
constructor(
private db: DatabaseService,
private cache: CacheService
) {}
async getUsers() {
const cached = await this.cache.get('users');
if (cached) return cached;
const users = await this.db.query('SELECT * FROM users');
await this.cache.set('users', users);
return users;
}
}
// ✗ Bad - Direct instantiation
class UserService {
private db = new DatabaseService();
private cache = new CacheService();
async getUsers() {
// ...
}
}
// ✓ Good - Stateless services as singleton
container.register(ConfigService, { scope: 'singleton' });
container.register(DatabaseService, { scope: 'singleton' });
// ✓ Good - Request-specific services as request-scoped
container.register(AuthService, { scope: 'request' });
container.register(RequestLogger, { scope: 'request' });
// ✓ Good - Stateful objects as transient
container.register(FormValidator, { scope: 'transient' });
// ✗ Bad - Circular dependency
class ServiceA {
constructor(private serviceB: ServiceB) {}
}
class ServiceB {
constructor(private serviceA: ServiceA) {}
}
// ✓ Good - Extract shared logic
class SharedService {
doSomething() {}
}
class ServiceA {
constructor(private shared: SharedService) {}
}
class ServiceB {
constructor(private shared: SharedService) {}
}
// ✓ Good - Single responsibility
class UserRepository {
async findAll() {}
async findById(id: string) {}
async create(user: User) {}
async update(id: string, user: User) {}
async delete(id: string) {}
}
class UserService {
constructor(private userRepo: UserRepository) {}
async getUsers() {
return this.userRepo.findAll();
}
async validateAndCreateUser(userData: any) {
// Validation logic
return this.userRepo.create(userData);
}
}
// ✗ Bad - Mixed concerns
class UserService {
async getUsers() {}
async createUser() {}
async sendEmail() {}
async logActivity() {}
async generateReport() {}
}
// ✓ Good
interface IStorageService {
save(key: string, value: any): Promise<void>;
get(key: string): Promise<any>;
}
class S3StorageService implements IStorageService {
async save(key: string, value: any) {
// S3 implementation
}
async get(key: string) {
// S3 implementation
}
}
class LocalStorageService implements IStorageService {
async save(key: string, value: any) {
// Local implementation
}
async get(key: string) {
// Local implementation
}
}
// Easy to swap implementations
container.register(IStorageService as any, {
factory: () => {
return process.env.USE_S3
? new S3StorageService()
: new LocalStorageService();
},
});
class UserRepository {
constructor(private db: DatabaseService) {}
async findAll(): Promise<User[]> {
return this.db.query('SELECT * FROM users');
}
async findById(id: string): Promise<User | null> {
const users = await this.db.query(`SELECT * FROM users WHERE id = ${id}`);
return users[0] || null;
}
async create(user: Omit<User, 'id'>): Promise<User> {
return this.db.query('INSERT INTO users ...', user);
}
}
container.register(UserRepository, { scope: 'singleton' });
class UserService {
constructor(
private userRepo: UserRepository,
private emailService: EmailService,
private logger: LoggerService
) {}
async createUser(userData: CreateUserDto): Promise<User> {
this.logger.info('Creating user', userData);
const user = await this.userRepo.create(userData);
await this.emailService.sendWelcomeEmail(user.email);
this.logger.info('User created', user);
return user;
}
}
container.register(UserService, { scope: 'request' });
class EmailServiceFactory {
create(type: 'sendgrid' | 'ses' | 'mock'): IEmailService {
switch (type) {
case 'sendgrid':
return new SendGridEmailService();
case 'ses':
return new SESEmailService();
case 'mock':
return new MockEmailService();
}
}
}
container.register(EmailServiceFactory, { scope: 'singleton' });