Why is DI this important?
Understanding the value of dependency injection
Dependency Injection isn't just a fancy pattern - it's the difference between maintainable, testable code and a tangled mess that breaks every time you make a change.
Built on Proven Principles
NovaDI is inspired by Autofac (the renowned .NET DI container) and the teachings of Mark Seemann, author of "Dependency Injection in .NET" and one of the world's leading experts on DI patterns and practices.
Mark Seemann advocates for several key principles that NovaDI follows religiously:
- The Composition Root Pattern: All DI configuration must be in ONE place at your application's entry point. NovaDI enforces this - you configure everything in your composition root, not scattered across your codebase.
- Constructor Injection: Dependencies should be explicitly declared in constructors, making them visible and testable. NovaDI uses constructor injection exclusively - no property injection, no hidden dependencies.
-
Don't Pollute Domain Code with Infrastructure: Your business logic should have zero
knowledge of the DI framework. NovaDI achieves this by requiring zero decorators in your domain code -
no
@Injectable(), no framework imports in your services. -
Convention over Configuration: Reduce boilerplate and potential errors by following
naming conventions. NovaDI's
.autoWire()automatically matches constructor parameters to registered interfaces by name. -
Explicit Lifetime Management: Be deliberate about object lifetimes (Singleton, Transient, Scoped).
NovaDI provides clear lifetime options:
.singleInstance(),.instancePerRequest(), and.instancePerLifetimeScope().
By following these battle-tested principles from the .NET ecosystem, NovaDI brings enterprise-grade dependency injection patterns to TypeScript.
The Problem: Manual Instantiation
Let's say you're building a real application. You have a UserService that needs to call a backend API,
handle user repo and send emails. Without DI, you're stuck creating everything manually.
❌ Without DI - The Nightmare
// Every single place that needs UserService has to know ALL its dependencies
const apiConfig = {
baseUrl: process.env.API_BASE_URL,
apiKey: process.env.API_KEY,
timeout: 5000
}
const httpClient = new FetchHttpClient(apiConfig)
const emailService = new EmailService('smtp.gmail.com', 587, 'user@example.com', 'password123')
const userApi = new UserApi(httpClient)
const userRepository = new UserRepository(userApi)
/***********
in this example we are injecting dependencies, sometimes code is
written in such a way that UserService instantiates userRepository and emailService
(which is an even bigger problem, because then it is impossible to test)
***********
*/
const userService = new UserService(userRepository, emailService)
// Now imagine doing having this pattern in 15 different files...
// Good luck testing this without mocking all HTTP calls.
- API keys and secrets scattered everywhere in your code
- Every file needs to know the entire dependency tree
- Impossible to test without hitting real API endpoints
- One constructor change breaks dozens of files
- No way to swap implementations (production vs test vs mock)
✅ With DI - Clean and Simple
Your Business Code (Clean!)
// Anywhere in your code - just ask for what you need
class UserService {
constructor(
private userRepo: IUserRepository,
private emailService: IEmailService
) {}
}
// That's it. No API keys. No configuration. No mess.
// Just declare what you need in the constructor.
Configuration (ONE Place, ONE Time)
// composition-root.ts - ALL secrets and wiring in ONE file
import { Container } from '@novadi/core'
const container = new Container()
const builder = container.builder()
// Configuration from environment
const apiConfig = {
baseUrl: process.env.API_BASE_URL,
apiKey: process.env.API_KEY,
timeout: 5000
}
// Register services
builder.registerInstance(apiConfig).as()
builder.registerType(FetchHttpClient).as().autoWire().singleInstance()
builder.registerType(EmailService)
.as()
.autoWire({
map: { //this is just an example of explicit mapping, you would probably do it the same way as apiConfig
smtp: () => emailConfig.smtp,
port: () => emailConfig.port,
user: () => emailConfig.user,
password: () => emailConfig.password
}
})
.singleInstance()
builder.registerType(UserApi).as().autoWire().singleInstance()
builder.registerType(UserRepository).as().autoWire()
builder.registerType(UserService).as().autoWire()
export const app = builder.build()
// Now use it
const userService = app.resolveType()
// Everything wired automatically! ✨
- Secrets in ONE place: All API keys and passwords in composition root (or .env)
- Clean business code: Controllers and services have zero configuration
- Easy testing: Just pass mock implementations to constructors
- Convention-based wiring:
.autoWire()handles dependencies automatically - Swap implementations: Change from production to test config in one line
Why Conventions Matter
Most DI frameworks require decorators in your domain code and manual injection tokens. NovaDI takes a different approach: Convention over Configuration.
❌ Token-Based DI (some random)
// tokens.ts - Separate file for all injection tokens
const TYPES = {
IUserRepository: Symbol.for('IUserRepository'),
IEmailService: Symbol.for('IEmailService'),
ILogger: Symbol.for('ILogger'),
IOrderRepository: Symbol.for('IOrderRepository'),
IUserService: Symbol.for('IUserService'),
IPaymentService: Symbol.for('IPaymentService')
}
// Domain code - imports + decorators + tokens everywhere
import { injectable, inject } from 'random-di'
import { TYPES } from './tokens'
@injectable()
class UserService {
constructor(
@inject(TYPES.IUserRepository) private userRepo: IUserRepository,
@inject(TYPES.IEmailService) private emailService: IEmailService,
@inject(TYPES.ILogger) private logger: ILogger
) {}
}
@injectable()
class OrderService {
constructor(
@inject(TYPES.IOrderRepository) private orderRepo: IOrderRepository,
@inject(TYPES.IUserService) private userService: IUserService,
@inject(TYPES.IPaymentService) private paymentService: IPaymentService,
@inject(TYPES.ILogger) private logger: ILogger
) {}
}
// Configuration - bind every token manually
container.bind(TYPES.IUserRepository).to(UserRepository)
container.bind(TYPES.IEmailService).to(EmailService)
container.bind(TYPES.ILogger).to(ConsoleLogger)
container.bind(TYPES.IUserService).to(UserService)
container.bind(TYPES.IOrderRepository).to(OrderRepository)
container.bind(TYPES.IPaymentService).to(PaymentService)
✅ Convention-Based (NovaDI)
// Clean domain code - NO decorators!
class UserService {
constructor(
private userRepo: IUserRepository,
private emailService: IEmailService,
private logger: ILogger
) {}
}
class OrderService {
constructor(
private orderRepo: IOrderRepository,
private userService: IUserService,
private paymentService: IPaymentService,
private logger: ILogger
) {}
}
// Configuration - conventions handle the wiring
builder.registerType(UserRepository).as()
builder.registerType(EmailService).as()
builder.registerType(ConsoleLogger).as()
builder.registerType(UserService).as().autoWire()
builder.registerType(OrderRepository).as()
builder.registerType(PaymentService).as()
builder.registerType(OrderService).as().autoWire()
// Parameters automatically match registered interfaces by name!
// userRepo → IUserRepository, emailService → IEmailService, etc.
💡 How Conventions Work
NovaDI matches constructor parameters to registered interfaces by naming convention.
A parameter named userRepo automatically resolves IUserRepository.
A parameter named logger resolves ILogger.
Result: Less configuration, fewer bugs, cleaner code.
Real-World Example: Complex Object Graph
Let's build a complete application with HTTP API calls, caching, email, and business logic. Watch how DI keeps everything clean and manageable.
Step 1: Define Your Domain (No DI Knowledge Required)
// Domain interfaces - clean, framework-agnostic
interface ILogger {
log(message: string): void
error(message: string): void
}
interface IHttpClient {
get(url: string, options?: RequestOptions): Promise
post(url: string, data: any, options?: RequestOptions): Promise
put(url: string, data: any, options?: RequestOptions): Promise
delete(url: string, options?: RequestOptions): Promise
}
interface ICache {
get(key: string): Promise
set(key: string, value: any, ttl?: number): Promise
}
interface IEmailService {
sendEmail(to: string, subject: string, body: string): Promise
}
interface IUserApi {
getUser(id: string): Promise
createUser(data: CreateUserDto): Promise
}
interface IUserRepository {
findById(id: string): Promise
save(user: User): Promise
}
interface IUserService {
getUser(id: string): Promise
createUser(email: string, name: string): Promise
}
Step 2: Implement Services (Still No DI!)
// Implementations - just declare what you need in the constructor
class ConsoleLogger implements ILogger {
log(message: string) { console.log(`[LOG] ${message}`) }
error(message: string) { console.error(`[ERROR] ${message}`) }
}
class FetchHttpClient implements IHttpClient {
constructor(
private config: HttpConfig,
private logger: ILogger
) {}
async get(url: string, options?: RequestOptions): Promise {
this.logger.log(`GET ${this.config.baseUrl}${url}`)
const response = await fetch(`${this.config.baseUrl}${url}`, {
headers: { 'Authorization': `Bearer ${this.config.apiKey}` }
})
return response.json()
}
async post(url: string, data: any): Promise { /* ... */ }
// ... put, delete
}
class MemoryCache implements ICache {
constructor(
private maxSize: number,
private httpClient: IHttpClient, // Fallback to API if cache miss
private logger: ILogger
) {}
private cache = new Map()
async get(key: string) {
const item = this.cache.get(key)
if (item && item.expires > Date.now()) {
this.logger.log(`Cache HIT: ${key}`)
return item.value
}
this.logger.log(`Cache MISS: ${key}`)
return null
}
async set(key: string, value: any, ttl: number = 3600) {
this.cache.set(key, { value, expires: Date.now() + ttl * 1000 })
}
}
class EmailService implements IEmailService {
constructor(
private config: EmailConfig,
private logger: ILogger
) {}
async sendEmail(to: string, subject: string, body: string) {
this.logger.log(`Sending email to ${to}`)
// ... SMTP logic
}
}
class UserApi implements IUserApi {
constructor(
private httpClient: IHttpClient,
private logger: ILogger
) {}
async getUser(id: string): Promise {
this.logger.log(`Fetching user ${id} from API`)
return this.httpClient.get(`/users/${id}`)
}
async createUser(data: CreateUserDto): Promise {
this.logger.log(`Creating user ${data.email}`)
return this.httpClient.post('/users', data)
}
}
class UserRepository implements IUserRepository {
constructor(
private userApi: IUserApi,
private cache: ICache,
private logger: ILogger
) {}
async findById(id: string): Promise {
// Try cache first
const cached = await this.cache.get(`user:${id}`)
if (cached) return cached
// Fallback to API
const user = await this.userApi.getUser(id)
await this.cache.set(`user:${id}`, user, 3600)
return user
}
async save(user: User): Promise {
await this.userApi.createUser(user)
await this.cache.set(`user:${user.id}`, user, 3600)
}
}
class UserService implements IUserService {
constructor(
private userRepo: IUserRepository,
private emailService: IEmailService,
private logger: ILogger
) {}
async getUser(id: string): Promise {
this.logger.log(`Fetching user ${id}`)
return this.userRepo.findById(id)
}
async createUser(email: string, name: string): Promise {
this.logger.log(`Creating user ${email}`)
const user = await this.userRepo.save({ email, name })
await this.emailService.sendEmail(email, 'Welcome!', `Hello ${name}`)
return user
}
}
Step 3: Configure DI (ONE Place, ONE Time)
// composition-root.ts - ALL configuration in ONE place
import { Container } from '@novadi/core'
const container = new Container()
const builder = container.builder()
// Configuration values (from environment variables)
const httpConfig = {
baseUrl: process.env.API_BASE_URL || 'https://api.example.com',
apiKey: process.env.API_KEY,
timeout: 5000,
retries: 3
}
const emailConfig = {
smtp: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT),
user: process.env.SMTP_USER,
password: process.env.SMTP_PASSWORD
}
const cacheMaxSize = 1000
// Register infrastructure (singletons - created once)
builder.registerInstance(httpConfig).as()
builder.registerInstance(emailConfig).as()
builder.registerInstance(cacheMaxSize).as('maxSize')
builder.registerType(ConsoleLogger)
.as()
.singleInstance()
builder.registerType(FetchHttpClient)
.as()
.autoWire() // ✨ Automatically wires: config, logger
.singleInstance()
builder.registerType(MemoryCache)
.as()
.autoWire() // ✨ Automatically wires: maxSize, httpClient, logger
.singleInstance()
builder.registerType(EmailService)
.as()
.autoWire() // ✨ Automatically wires: config, logger
.singleInstance()
// Register API layer
builder.registerType(UserApi)
.as()
.autoWire() // ✨ Automatically wires: httpClient, logger
.singleInstance()
// Register domain services
builder.registerType(UserRepository)
.as()
.autoWire() // ✨ Automatically wires: userApi, cache, logger
.instancePerRequest()
builder.registerType(UserService)
.as()
.autoWire() // ✨ Automatically wires: userRepo, emailService, logger
.instancePerRequest()
export const app = builder.build()
Step 4: Use It - Simple and Clean
// In your application code
import { app } from './composition-root'
// Resolve the entire object graph with ONE line
const userService = app.resolveType()
// Everything is wired automatically:
// ✅ UserService gets UserRepository, EmailService, Logger
// ✅ UserRepository gets UserApi, MemoryCache, Logger
// ✅ UserApi gets FetchHttpClient, Logger
// ✅ MemoryCache gets maxSize, FetchHttpClient, Logger
// ✅ FetchHttpClient gets HttpConfig, Logger
// ✅ EmailService gets EmailConfig, Logger
// ✅ Logger is shared (singleton) across everything
// Use it
const user = await userService.getUser('123')
console.log(user)
const newUser = await userService.createUser('jane@example.com', 'Jane Doe')
console.log(newUser)
🎯 Key Takeaways
- Configuration in ONE place: All API keys, secrets, and wiring in composition root
- Clean domain code: No framework knowledge in your business logic
- Convention over configuration:
.autoWire()handles wiring automatically - Easy testing: Pass mock implementations to constructors
- Flexible lifetimes: Singletons for infrastructure (HTTP client, cache), per-request for domain services
- Type-safe: TypeScript catches errors at compile time
Testing Made Trivial
With DI, testing is as simple as passing mock implementations. No need for complex mocking frameworks or to worry about hitting real API endpoints.
// test/user-service.test.ts
import { UserService } from '../src/services/user-service'
// Create simple mocks - no HTTP calls, no API endpoints
const mockLogger: ILogger = {
log: jest.fn(),
error: jest.fn()
}
const mockEmailService: IEmailService = {
sendEmail: jest.fn().mockResolvedValue(undefined)
}
const mockUserRepo: IUserRepository = {
findById: jest.fn().mockResolvedValue({ id: '123', name: 'John' }),
save: jest.fn().mockResolvedValue({ id: '456', name: 'Jane' })
}
// Test without ANY infrastructure (no HTTP, no cache, no real APIs)
test('getUser returns user from repository', async () => {
const service = new UserService(mockUserRepo, mockEmailService, mockLogger)
const user = await service.getUser('123')
expect(user).toEqual({ id: '123', name: 'John' })
expect(mockUserRepo.findById).toHaveBeenCalledWith('123')
expect(mockLogger.log).toHaveBeenCalledWith('Fetching user 123')
})
test('createUser sends welcome email', async () => {
const service = new UserService(mockUserRepo, mockEmailService, mockLogger)
await service.createUser('jane@example.com', 'Jane')
expect(mockEmailService.sendEmail).toHaveBeenCalledWith(
'jane@example.com',
'Welcome!',
'Hello Jane'
)
})
// You can also test the UserRepository layer if needed
test('UserRepository uses cache and falls back to API', async () => {
const mockHttpClient: IHttpClient = {
get: jest.fn().mockResolvedValue({ id: '123', name: 'John' }),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn()
}
const mockCache: ICache = {
get: jest.fn().mockResolvedValue(null), // Cache miss
set: jest.fn()
}
const mockUserApi: IUserApi = {
getUser: jest.fn().mockResolvedValue({ id: '123', name: 'John' }),
createUser: jest.fn()
}
const repo = new UserRepository(mockUserApi, mockCache, mockLogger)
const user = await repo.findById('123')
// Verify cache was checked first
expect(mockCache.get).toHaveBeenCalledWith('user:123')
// Verify API was called on cache miss
expect(mockUserApi.getUser).toHaveBeenCalledWith('123')
// Verify result was cached
expect(mockCache.set).toHaveBeenCalledWith('user:123', user, 3600)
})
Ready to Build Better Applications?
Stop fighting with manual instantiation and start using proper dependency injection