Best Practices
Recommended patterns and approaches for using the DI framework effectively.
1. Mark All Services with @Container()
Make dependency management explicit by decorating all services:
// Good
@Container()
export class UserService {
// Implementation
}
// Avoid - Plain class without decoration
export class UserService {
// Implementation
}
Why: The decorator makes it clear that a class is part of your DI system and automatically handles registration.
2. Use Constructor Injection for Mandatory Dependencies
Constructor injection makes dependencies explicit and ensures they're available before the service is used:
// Good - Constructor injection
@Container()
export class UserService {
constructor(
@Component(DatabaseService) private db: DatabaseService,
@Component(LoggerService) private logger: LoggerService
) {}
}
// Acceptable - Property injection (for optional dependencies)
@Container()
export class UserService {
@Component(DatabaseService)
private db!: DatabaseService;
}
Why: Constructor injection makes dependencies obvious and prevents services from being in an incomplete state.
3. Keep Property Injection Minimal
Use property injection sparingly, primarily for optional dependencies:
// Good - Limited property injection
@Container()
export class UserService {
constructor(
@Component(DatabaseService) private db: DatabaseService
) {}
@Component(CacheService)
private cache?: CacheService; // Optional dependency
getUser(id: string) {
if (this.cache) {
const cached = this.cache.get(id);
if (cached) return cached;
}
return this.db.query(`SELECT * FROM users WHERE id = '${id}'`);
}
}
Why: Property injection can make dependencies less obvious and may lead to incomplete service initialization.
This framework uses a lightweight metadata store - you don't need reflect-metadata:
// Good - No reflect-metadata import needed
import { Container, Component } from 'di-framework/decorators';
@Container()
export class MyService {
constructor(@Component(DatabaseService) private db: DatabaseService) {}
}
// Avoid - Unnecessary import
import 'reflect-metadata';
Why: The framework is designed to work without reflect-metadata, keeping your bundle size small.
5. Separate Service Interfaces from Implementations
Define interfaces for easier testing and flexibility:
// Good - Interface-based design
export interface ILogger {
log(message: string): void;
error(error: Error): void;
}
@Container()
export class ConsoleLogger implements ILogger {
log(message: string) {
console.log(message);
}
error(error: Error) {
console.error(error);
}
}
@Container()
export class UserService {
constructor(@Component(ConsoleLogger) private logger: ILogger) {}
}
Why: Interfaces make it easy to swap implementations for testing or different environments.
6. Use Singletons for Stateless Services
Most services should be singletons (the default):
// Good - Singleton (default)
@Container()
export class DatabaseService {
// Stateless operations
query(sql: string) {
return this.connection.query(sql);
}
}
// Good - Transient for stateful services
@Container({ singleton: false })
export class RequestContext {
requestId = Math.random().toString();
userId?: string;
}
Why: Singletons improve performance and reduce memory usage. Use transient only when you need isolated state.
7. Use Transient for Stateful Services
Services with mutable state that shouldn't be shared should be transient:
// Good - Transient for request-scoped state
@Container({ singleton: false })
export class RequestContext {
private data = new Map<string, any>();
set(key: string, value: any) {
this.data.set(key, value);
}
get(key: string) {
return this.data.get(key);
}
}
Why: Prevents state leakage between requests or operations.
8. Initialize Services at Application Startup
Validate your DI configuration early:
// Good - Startup validation
@Container()
export class Application {
constructor(
@Component(DatabaseService) private db: DatabaseService,
@Component(AuthService) private auth: AuthService,
@Component(CacheService) private cache: CacheService
) {}
async initialize() {
// Validate all services are available
await this.db.connect();
await this.cache.connect();
this.auth.setup();
console.log('Application initialized successfully');
}
}
// In your entry point
const container = useContainer();
const app = container.resolve(Application);
await app.initialize();
Why: Catches configuration errors early instead of at runtime when a service is first needed.
9. Use Factory Functions for Configuration
Register configuration as factory services:
// Good - Configuration as factory
container.registerFactory('config', () => ({
database: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432')
},
api: {
key: process.env.API_KEY
}
}), { singleton: true });
@Container()
export class DatabaseService {
constructor(@Component('config') private config: any) {
console.log('Connecting to:', this.config.database.host);
}
}
Why: Centralizes configuration and makes it easy to inject environment-specific settings.
10. Organize Services by Feature
Structure your services by feature or domain:
src/
features/
user/
UserService.ts
UserRepository.ts
auth/
AuthService.ts
TokenService.ts
billing/
BillingService.ts
PaymentService.ts
Why: Improves code organization and makes it easier to understand dependencies.
11. Keep Services Focused (Single Responsibility)
Each service should have a single, well-defined purpose:
// Good - Focused services
@Container()
export class UserRepository {
// Only handles data access
findById(id: string) { }
save(user: User) { }
}
@Container()
export class UserValidator {
// Only handles validation
validate(user: User) { }
}
@Container()
export class UserService {
constructor(
@Component(UserRepository) private repo: UserRepository,
@Component(UserValidator) private validator: UserValidator
) {}
// Orchestrates user operations
async createUser(data: any) {
this.validator.validate(data);
return this.repo.save(data);
}
}
Why: Smaller, focused services are easier to test, maintain, and reuse.
12. Document Dependencies
Add comments to clarify why dependencies are needed:
@Container()
export class OrderService {
constructor(
@Component(DatabaseService) private db: DatabaseService, // Data persistence
@Component(PaymentService) private payment: PaymentService, // Payment processing
@Component(EmailService) private email: EmailService, // Order confirmations
@Component(LoggerService) private logger: LoggerService // Audit trail
) {}
}
Why: Makes code more maintainable and helps new developers understand dependencies.
13. Avoid Circular Dependencies
Design services to have clear dependency hierarchies:
// Bad - Circular dependency
@Container()
class ServiceA {
constructor(@Component(ServiceB) private b: ServiceB) {}
}
@Container()
class ServiceB {
constructor(@Component(ServiceA) private a: ServiceA) {}
}
// Good - Clear hierarchy
@Container()
class SharedService { }
@Container()
class ServiceA {
constructor(@Component(SharedService) private shared: SharedService) {}
}
@Container()
class ServiceB {
constructor(@Component(SharedService) private shared: SharedService) {}
}
Why: Circular dependencies indicate design problems and are harder to test and maintain.
14. Test with Isolated Containers
Use separate containers for testing:
// Good - Test isolation
import { Container as DIContainer } from 'di-framework/container';
describe('UserService', () => {
let testContainer: DIContainer;
beforeEach(() => {
testContainer = new DIContainer();
testContainer.register(MockDatabaseService);
testContainer.register(UserService);
});
it('should create user', () => {
const service = testContainer.resolve(UserService);
// Test implementation
});
});
Why: Isolated containers prevent test pollution and make tests independent.
9. Add Observability to DI
Hook into container events to log or measure dependency resolution:
const stop = container.on('resolved', ({ key, fromCache }) => {
const name = typeof key === 'string' ? key : key.name;
logger.debug(`Resolved ${name} (cached=${fromCache})`);
});
// Stop listening if needed
stop();
Why: Observability helps debug missing registrations, unexpected transient resolutions, and slow dependency graphs in production.
Summary Checklist
✅ Decorate all services with @Container()
✅ Use constructor injection for mandatory dependencies
✅ Keep property injection minimal (optional dependencies only)
✅ Don't import reflect-metadata
✅ Use interfaces for flexibility
✅ Default to singletons for stateless services
✅ Use transient for stateful services
✅ Validate services at startup
✅ Use factory functions for configuration
✅ Organize by feature
✅ Keep services focused
✅ Document dependencies
✅ Avoid circular dependencies
✅ Test with isolated containers
✅ Add observability via container events
Last modified: 23 November 2025