Leveraging dependency injection, custom decorators, and strict event typing to build robust, maintainable Node.js applications
Introduction
TypeScript has transformed the Node.js ecosystem, bringing static typing and enhanced developer tooling to JavaScript’s server-side runtime. While many teams have adopted TypeScript for its basic benefits, fewer have explored the advanced patterns that can dramatically improve the architecture of enterprise applications.
In this post, we’ll dive into three powerful TypeScript patterns that can elevate your Node.js backends: dependency injection, custom decorators, and strict event typing. These techniques are commonly found in enterprise-grade applications built with Java or C#, but they can be just as effective in the Node.js world when implemented properly.
Understanding Dependency Injection in TypeScript
Dependency Injection (DI) is an architectural pattern that helps decouple components by externalizing their dependencies. Instead of a component creating its dependencies directly, they are “injected” from the outside. This approach enhances testability, modularity, and maintenance—all critical concerns for enterprise applications.
The Problem DI Solves
Consider a typical service class in Node.js:
// Without dependency injection
class UserService {
private database = new Database();
private logger = new Logger();
private emailService = new EmailService();
async createUser(userData: UserData): Promise<User> {
this.logger.info('Creating new user');
const user = await this.database.users.create(userData);
await this.emailService.sendWelcomeEmail(user.email);
return user;
}
}
This implementation has several issues:
- Hard-coded dependencies make testing difficult
- Replacing implementations (e.g., switching email providers) requires code changes
- Understanding the full dependency tree becomes challenging as the application grows
Implementing DI with TypeScript and Inversify
InversifyJS is a powerful DI container that works seamlessly with TypeScript. Here’s how to refactor our example using Inversify:
First, let’s define our interfaces:
// interfaces.ts
export interface IDatabase {
users: {
create(userData: UserData): Promise<User>;
findById(id: string): Promise<User | null>;
// other methods...
}
// other collections...
}
export interface ILogger {
info(message: string, meta?: object): void;
error(message: string, error?: Error, meta?: object): void;
// other methods...
}
export interface IEmailService {
sendWelcomeEmail(email: string): Promise<void>;
// other methods...
}
Next, let’s set up our DI container:
// inversify.config.ts
import { Container } from 'inversify';
import "reflect-metadata";
import { IDatabase, ILogger, IEmailService } from './interfaces';
import { MongoDatabase } from './infrastructure/mongodb';
import { WinstonLogger } from './infrastructure/winston';
import { SendgridEmailService } from './infrastructure/sendgrid';
// Define symbols for our interfaces
export const TYPES = {
Database: Symbol.for('Database'),
Logger: Symbol.for('Logger'),
EmailService: Symbol.for('EmailService'),
UserService: Symbol.for('UserService')
};
// Configure the container
const container = new Container();
container.bind<IDatabase>(TYPES.Database).to(MongoDatabase).inSingletonScope();
container.bind<ILogger>(TYPES.Logger).to(WinstonLogger).inSingletonScope();
container.bind<IEmailService>(TYPES.EmailService).to(SendgridEmailService).inSingletonScope();
export { container };
Now, let’s refactor our UserService:
// user.service.ts
import { injectable, inject } from 'inversify';
import { IDatabase, ILogger, IEmailService } from './interfaces';
import { TYPES } from './inversify.config';
@injectable()
export class UserService {
constructor(
@inject(TYPES.Database) private database: IDatabase,
@inject(TYPES.Logger) private logger: ILogger,
@inject(TYPES.EmailService) private emailService: IEmailService
) {}
async createUser(userData: UserData): Promise<User> {
this.logger.info('Creating new user', { email: userData.email });
const user = await this.database.users.create(userData);
await this.emailService.sendWelcomeEmail(user.email);
return user;
}
}
// Register the service
container.bind<UserService>(TYPES.UserService).to(UserService);
Benefits of This Approach
- Improved Testing: We can easily mock dependencies during unit tests
- Flexibility: Swapping implementations (e.g., changing from MongoDB to PostgreSQL) becomes trivial
- Clarity: Dependencies are explicitly declared, making the code more readable
- Lifecycle Management: The DI container can manage object lifecycles (singleton, transient, etc.)
Testing with DI
DI truly shines when writing tests:
// user.service.test.ts
import { UserService } from './user.service';
import { Container } from 'inversify';
import { TYPES } from './inversify.config';
import { IDatabase, ILogger, IEmailService } from './interfaces';
describe('UserService', () => {
let container: Container;
let mockDatabase: jest.Mocked<IDatabase>;
let mockLogger: jest.Mocked<ILogger>;
let mockEmailService: jest.Mocked<IEmailService>;
let userService: UserService;
beforeEach(() => {
// Create mock implementations
mockDatabase = {
users: {
create: jest.fn(),
findById: jest.fn()
}
} as any;
mockLogger = {
info: jest.fn(),
error: jest.fn()
} as any;
mockEmailService = {
sendWelcomeEmail: jest.fn()
} as any;
// Configure test container
container = new Container();
container.bind<IDatabase>(TYPES.Database).toConstantValue(mockDatabase);
container.bind<ILogger>(TYPES.Logger).toConstantValue(mockLogger);
container.bind<IEmailService>(TYPES.EmailService).toConstantValue(mockEmailService);
container.bind<UserService>(TYPES.UserService).to(UserService);
// Get service instance with injected mocks
userService = container.get<UserService>(TYPES.UserService);
});
it('should create a user and send welcome email', async () => {
// Arrange
const userData = { email: 'test@example.com', name: 'Test User' };
const createdUser = { id: '123', ...userData };
mockDatabase.users.create.mockResolvedValue(createdUser);
mockEmailService.sendWelcomeEmail.mockResolvedValue();
// Act
const result = await userService.createUser(userData);
// Assert
expect(mockDatabase.users.create).toHaveBeenCalledWith(userData);
expect(mockLogger.info).toHaveBeenCalledWith('Creating new user', { email: userData.email });
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(userData.email);
expect(result).toEqual(createdUser);
});
});
Custom Decorators: Extending TypeScript’s Type System
Decorators are a powerful TypeScript feature that can help implement cross-cutting concerns such as validation, logging, and permission checks. While still technically an experimental feature, decorators are widely used in enterprise frameworks like NestJS and TypeORM.
Creating and Using Custom Method Decorators
Let’s explore how to create a custom decorator for tracking execution time:
// decorators/performance.decorator.ts
import { ILogger } from '../interfaces';
import { container, TYPES } from '../inversify.config';
export function TrackExecutionTime(methodName?: string) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const logger = container.get<ILogger>(TYPES.Logger);
const start = performance.now();
try {
const result = await originalMethod.apply(this, args);
const executionTime = performance.now() - start;
logger.info(
`${methodName || target.constructor.name + '.' + propertyKey} executed in ${executionTime.toFixed(2)}ms`
);
return result;
} catch (error) {
const executionTime = performance.now() - start;
logger.error(
`${methodName || target.constructor.name + '.' + propertyKey} failed after ${executionTime.toFixed(2)}ms`,
error as Error
);
throw error;
}
};
return descriptor;
};
}
Now we can use this decorator in our service:
import { injectable, inject } from 'inversify';
import { IDatabase, IEmailService } from './interfaces';
import { TYPES } from './inversify.config';
import { TrackExecutionTime } from './decorators/performance.decorator';
@injectable()
export class UserService {
constructor(
@inject(TYPES.Database) private database: IDatabase,
@inject(TYPES.EmailService) private emailService: IEmailService
) {}
@TrackExecutionTime()
async createUser(userData: UserData): Promise<User> {
const user = await this.database.users.create(userData);
await this.emailService.sendWelcomeEmail(user.email);
return user;
}
}
Advanced Parameter Decorators
Parameter decorators can validate inputs before execution:
// decorators/validation.decorator.ts
import { validate } from 'class-validator';
export function ValidateParam(paramIndex: number) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const paramToValidate = args[paramIndex];
const validationErrors = await validate(paramToValidate);
if (validationErrors.length > 0) {
throw new ValidationError(
'Validation failed',
validationErrors
);
}
return originalMethod.apply(this, args);
};
return descriptor;
};
}
Using it with a validated DTO:
import { IsEmail, IsNotEmpty, Length } from 'class-validator';
export class CreateUserDto {
@IsNotEmpty()
@IsEmail()
email: string;
@IsNotEmpty()
@Length(2, 100)
name: string;
@IsNotEmpty()
@Length(8, 100)
password: string;
}
@injectable()
export class UserService {
// ...
@ValidateParam(0)
@TrackExecutionTime()
async createUser(userData: CreateUserDto): Promise<User> {
// No need for validation logic here anymore
const user = await this.database.users.create(userData);
await this.emailService.sendWelcomeEmail(user.email);
return user;
}
}
Creating Property Decorators for Configuration
Property decorators can help with configuration management:
// decorators/config.decorator.ts
import { config } from '../config';
export function ConfigValue(configPath: string, defaultValue?: any) {
return function (target: any, propertyKey: string) {
// Find the value in config using the path
const getValue = () => {
const pathParts = configPath.split('.');
let current = config;
for (const part of pathParts) {
if (current === undefined) return defaultValue;
current = current[part];
}
return current !== undefined ? current : defaultValue;
};
Object.defineProperty(target, propertyKey, {
get: () => getValue(),
enumerable: true,
configurable: true
});
};
}
Using it in a service:
@injectable()
export class EmailService implements IEmailService {
@ConfigValue('email.welcomeSubject', 'Welcome to our platform!')
private welcomeSubject: string;
@ConfigValue('email.fromAddress')
private fromAddress: string;
// ...
async sendWelcomeEmail(email: string): Promise<void> {
// Access this.welcomeSubject and this.fromAddress
// which are dynamically loaded from config
}
}
Strict Event Typing: Making Event-Driven Architecture Safer
Node.js applications often use event-driven patterns for communication between components. However, without proper typing, this can lead to runtime errors and maintenance challenges. TypeScript can make event handling much safer.
Defining a Strictly Typed Event Emitter
Let’s create a typed event emitter:
// typed-events.ts
import { EventEmitter } from 'events';
// Define event map type
export type EventMap = {
[key: string]: any;
};
// Create a strictly typed event emitter
export class TypedEventEmitter<T extends EventMap> extends EventEmitter {
// Override emit with strong typing
emit<K extends keyof T>(event: K, ...args: T[K]): boolean {
return super.emit(event as string, ...args);
}
// Override on with strong typing
on<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
return super.on(event as string, listener as any);
}
// Override once with strong typing
once<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
return super.once(event as string, listener as any);
}
// Similarly for other methods like off, addListener, etc.
}
Using the Typed Event Emitter
Now we can define our application’s events and use them with proper typing:
// app-events.ts
import { TypedEventEmitter } from './typed-events';
import { User } from './models/user';
// Define all application events and their payload types
export interface AppEvents {
'user.created': [user: User];
'user.updated': [user: User, previousValues: Partial<User>];
'user.deleted': [userId: string];
'email.sent': [to: string, template: string, data: object];
'payment.received': [amount: number, userId: string, timestamp: Date];
}
// Create application event bus
export const eventBus = new TypedEventEmitter<AppEvents>();
Now when we use this in our services:
// user.service.ts
@injectable()
export class UserService {
constructor(
@inject(TYPES.Database) private database: IDatabase,
@inject(TYPES.EventBus) private eventBus: TypedEventEmitter<AppEvents>
) {
// TypeScript ensures we use correct event names and argument types
this.eventBus.on('user.created', (user) => {
// TypeScript knows 'user' is of type User
console.log(`User created: ${user.id}`);
});
}
async createUser(userData: CreateUserDto): Promise<User> {
const user = await this.database.users.create(userData);
// TypeScript ensures we pass the correct event payload
this.eventBus.emit('user.created', user);
return user;
}
async updateUser(id: string, updates: Partial<User>): Promise<User> {
const previousUser = await this.database.users.findById(id);
const updatedUser = await this.database.users.update(id, updates);
// TypeScript ensures we pass the correct arguments
this.eventBus.emit('user.updated', updatedUser, updates);
return updatedUser;
}
}
The benefits are immediate:
- TypeScript will catch event name typos at compile time
- You get intellisense for event names
- The payload types are checked at compile time
- Refactoring events becomes much safer
Advanced Event Pattern: Strongly Typed Event Handlers
We can take this a step further with decorator-based event handlers:
// decorators/event-handler.decorator.ts
import { AppEvents, eventBus } from '../app-events';
export function EventHandler<K extends keyof AppEvents>(eventName: K) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
// Register the method as an event handler when the class is instantiated
const originalOnInitialize = target.onInitialize;
target.onInitialize = function () {
if (originalOnInitialize) {
originalOnInitialize.call(this);
}
eventBus.on(eventName, (...args: AppEvents[K]) => {
originalMethod.apply(this, args);
});
};
return descriptor;
};
}
Now we can define event handlers in any service:
@injectable()
export class NotificationService {
constructor(
@inject(TYPES.EmailService) private emailService: IEmailService
) {}
onInitialize() {
// This will be called by our decorator
}
@EventHandler('user.created')
async sendWelcomeNotification(user: User) {
await this.emailService.sendWelcomeEmail(user.email);
}
@EventHandler('payment.received')
async sendPaymentConfirmation(amount: number, userId: string, timestamp: Date) {
// TypeScript knows the exact types of all parameters
const user = await this.userService.findById(userId);
await this.emailService.sendPaymentConfirmation(
user.email,
amount,
timestamp
);
}
}
Real-World Example: Bringing It All Together
Let’s see how these patterns work together in a complete example:
// application.ts
import { Container } from 'inversify';
import { TYPES } from './inversify.config';
import { ILogger } from './interfaces';
import { TypedEventEmitter } from './typed-events';
import { AppEvents, eventBus } from './app-events';
@injectable()
export class Application {
constructor(
@inject(TYPES.Logger) private logger: ILogger,
@inject(TYPES.UserService) private userService: UserService,
@inject(TYPES.NotificationService) private notificationService: NotificationService,
@inject(TYPES.HttpServer) private httpServer: HttpServer
) {}
@TrackExecutionTime('Application.start')
async start(): Promise<void> {
this.logger.info('Starting application');
// Initialize services that need to register event handlers
this.notificationService.onInitialize();
// Set up HTTP routes
this.httpServer.post('/users', async (req, res) => {
try {
const userData = req.body;
const user = await this.userService.createUser(userData);
res.status(201).json(user);
} catch (error) {
// Error handling...
res.status(400).json({ error: error.message });
}
});
// Start the HTTP server
await this.httpServer.listen(3000);
this.logger.info('Server started on port 3000');
// Additional startup tasks...
}
async stop(): Promise<void> {
this.logger.info('Stopping application');
await this.httpServer.close();
this.logger.info('Server stopped');
}
}
// bootstrap.ts
import { container } from './inversify.config';
import { Application } from './application';
async function bootstrap() {
const app = container.get<Application>(Application);
try {
await app.start();
} catch (error) {
console.error('Failed to start application:', error);
process.exit(1);
}
// Handle graceful shutdown
process.on('SIGTERM', async () => {
await app.stop();
process.exit(0);
});
}
bootstrap();
Performance Considerations
These advanced patterns do come with some overhead:
- Dependency Injection: Adds a small amount of startup time due to container configuration
- Decorators: Can increase function call overhead slightly
- Event Typing: Adds a small memory footprint compared to raw EventEmitter
However, the benefits in maintainability, testability, and type safety typically outweigh these costs for enterprise applications. For performance-critical code paths, you can always opt out of these patterns where necessary.
Conclusion
These advanced TypeScript patterns—dependency injection, custom decorators, and strict event typing—provide powerful tools for building enterprise-grade Node.js applications. They enable better:
- Testability: Mocking dependencies becomes trivial
- Maintainability: Clear contracts and boundaries between components
- Type Safety: Catching errors at compile time rather than runtime
- Separation of Concerns: Isolating cross-cutting concerns like logging and validation
While these patterns may seem complex at first, they pay dividends as applications grow in size and complexity. They’re particularly valuable for large teams working on long-lived applications.
Remember that not every application needs this level of sophistication. For smaller projects or prototypes, simpler approaches may be more appropriate. The key is matching your architectural patterns to your application’s scale and requirements.
How have you been using TypeScript in your Node.js applications? Have you adopted any of these patterns or developed your own approaches? Share your experiences in the comments below!
Leave a Reply