TypeScript Enterprise Patterns: Building Scalable Applications
Table of Contents
- Introduction to Enterprise TypeScript
- Advanced Type System Features
- Design Patterns for TypeScript
- Dependency Injection and IoC
- Domain-Driven Design with TypeScript
- Testing Strategies
- Performance and Optimization
- Error Handling and Logging
- Configuration Management
- Production Best Practices
Introduction to Enterprise TypeScript {#introduction}
Enterprise TypeScript development requires robust patterns, maintainable architectures, and scalable solutions. This guide explores advanced TypeScript patterns specifically designed for large-scale applications.
Why TypeScript for Enterprise?
- Type Safety: Catch errors at compile time
- Better IDE Support: Enhanced IntelliSense and refactoring
- Scalability: Maintain large codebases with confidence
- Team Productivity: Clear contracts and interfaces
- Gradual Adoption: Can be introduced incrementally
Project Structure for Enterprise
src/
├── application/ # Application layer
│ ├── commands/ # Command handlers
│ ├── queries/ # Query handlers
│ └── services/ # Application services
├── domain/ # Domain layer
│ ├── entities/ # Domain entities
│ ├── repositories/ # Repository interfaces
│ ├── services/ # Domain services
│ └── value-objects/ # Value objects
├── infrastructure/ # Infrastructure layer
│ ├── database/ # Database implementations
│ ├── external/ # External service clients
│ └── repositories/ # Repository implementations
├── presentation/ # Presentation layer
│ ├── controllers/ # API controllers
│ ├── middleware/ # Express middleware
│ └── validators/ # Input validation
├── shared/ # Shared utilities
│ ├── decorators/ # Custom decorators
│ ├── types/ # Shared type definitions
│ └── utils/ # Utility functions
└── config/ # Configuration files
Enterprise TypeScript Configuration
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"resolveJsonModule": true,
"baseUrl": "./src",
"paths": {
"@/*": ["*"],
"@/domain/*": ["domain/*"],
"@/application/*": ["application/*"],
"@/infrastructure/*": ["infrastructure/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Advanced Type System Features {#type-system}
Conditional Types and Advanced Generics
// Conditional types for API responses
type ApiResponse<T> = T extends string
? { message: T }
: T extends object
? { data: T; meta: ResponseMeta }
: never;
// Advanced utility types
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];
type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];
// Example usage
interface User {
id: string;
name: string;
email?: string;
preferences?: UserPreferences;
}
type RequiredUserKeys = RequiredKeys<User>; // "id" | "name"
type OptionalUserKeys = OptionalKeys<User>; // "email" | "preferences"
Template Literal Types
// Event system with template literals
type EventType = 'user' | 'order' | 'product';
type ActionType = 'created' | 'updated' | 'deleted';
type EventName = `${EventType}:${ActionType}`;
// Type-safe event emitter
interface EventMap {
'user:created': { userId: string; timestamp: Date };
'user:updated': { userId: string; changes: Partial<User> };
'user:deleted': { userId: string };
'order:created': { orderId: string; userId: string; amount: number };
'order:updated': { orderId: string; changes: Partial<Order> };
'order:deleted': { orderId: string };
}
class TypedEventEmitter {
private listeners: {
[K in keyof EventMap]?: Array<(data: EventMap[K]) => void>;
} = {};
on<K extends keyof EventMap>(
event: K,
listener: (data: EventMap[K]) => void
): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof EventMap>(event: K, data: EventMap[K]): void {
const eventListeners = this.listeners[event];
if (eventListeners) {
eventListeners.forEach(listener => listener(data));
}
}
}
// Usage
const emitter = new TypedEventEmitter();
emitter.on('user:created', (data) => {
// data is properly typed as { userId: string; timestamp: Date }
console.log(`User ${data.userId} created at ${data.timestamp}`);
});
Branded Types for Domain Modeling
// Branded types for type safety
type Brand<T, B> = T & { __brand: B };
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
type OrderId = Brand<string, 'OrderId'>;
type Money = Brand<number, 'Money'>;
// Factory functions for branded types
const createUserId = (id: string): UserId => {
if (!id || id.length < 1) {
throw new Error('Invalid user ID');
}
return id as UserId;
};
const createEmail = (email: string): Email => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error('Invalid email format');
}
return email as Email;
};
const createMoney = (amount: number): Money => {
if (amount < 0 || !Number.isFinite(amount)) {
throw new Error('Invalid money amount');
}
return Math.round(amount * 100) / 100 as Money;
};
// Domain entities with branded types
interface User {
readonly id: UserId;
readonly email: Email;
name: string;
createdAt: Date;
}
interface Order {
readonly id: OrderId;
readonly userId: UserId;
amount: Money;
status: OrderStatus;
}
Advanced Mapped Types
// Create immutable versions of types
type Immutable<T> = {
readonly [K in keyof T]: T[K] extends object ? Immutable<T[K]> : T[K];
};
// Create mutable versions of readonly types
type Mutable<T> = {
-readonly [K in keyof T]: T[K] extends object ? Mutable<T[K]> : T[K];
};
// Extract function property names
type FunctionPropertyNames<T> = {
[K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;
// Example: Service interface extraction
interface UserService {
findById(id: UserId): Promise<User | null>;
create(userData: CreateUserData): Promise<User>;
update(id: UserId, data: Partial<User>): Promise<User>;
delete(id: UserId): Promise<void>;
validateEmail(email: string): boolean;
}
type UserServiceMethods = FunctionProperties<UserService>;
// Result: all method signatures from UserService
Design Patterns for TypeScript {#design-patterns}
Repository Pattern with Generics
// Generic repository interface
interface Repository<T, ID> {
findById(id: ID): Promise<T | null>;
findAll(criteria?: Partial<T>): Promise<T[]>;
save(entity: T): Promise<T>;
update(id: ID, updates: Partial<T>): Promise<T>;
delete(id: ID): Promise<void>;
exists(id: ID): Promise<boolean>;
}
// Base repository implementation
abstract class BaseRepository<T, ID> implements Repository<T, ID> {
protected abstract tableName: string;
async findById(id: ID): Promise<T | null> {
// Generic implementation
const result = await this.executeQuery(
`SELECT * FROM ${this.tableName} WHERE id = ?`,
[id]
);
return result.length > 0 ? this.mapToEntity(result[0]) : null;
}
async findAll(criteria?: Partial<T>): Promise<T[]> {
const whereClause = this.buildWhereClause(criteria);
const query = `SELECT * FROM ${this.tableName}${whereClause}`;
const results = await this.executeQuery(query);
return results.map(row => this.mapToEntity(row));
}
async save(entity: T): Promise<T> {
const fields = this.getEntityFields(entity);
const placeholders = fields.map(() => '?').join(', ');
const query = `INSERT INTO ${this.tableName} (${fields.join(', ')}) VALUES (${placeholders})`;
const values = fields.map(field => (entity as any)[field]);
const result = await this.executeQuery(query, values);
return { ...entity, id: result.insertId } as T;
}
protected abstract executeQuery(query: string, params?: any[]): Promise<any>;
protected abstract mapToEntity(row: any): T;
protected abstract getEntityFields(entity: T): string[];
protected abstract buildWhereClause(criteria?: Partial<T>): string;
}
// Concrete repository implementation
class UserRepository extends BaseRepository<User, UserId> {
protected tableName = 'users';
protected async executeQuery(query: string, params?: any[]): Promise<any> {
// Database-specific implementation
return await this.database.execute(query, params);
}
protected mapToEntity(row: any): User {
return {
id: createUserId(row.id),
email: createEmail(row.email),
name: row.name,
createdAt: new Date(row.created_at)
};
}
protected getEntityFields(entity: User): string[] {
return ['email', 'name', 'created_at'];
}
protected buildWhereClause(criteria?: Partial<User>): string {
if (!criteria) return '';
const conditions = Object.entries(criteria)
.filter(([_, value]) => value !== undefined)
.map(([key, _]) => `${key} = ?`);
return conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
}
// Domain-specific methods
async findByEmail(email: Email): Promise<User | null> {
const result = await this.executeQuery(
`SELECT * FROM ${this.tableName} WHERE email = ?`,
[email]
);
return result.length > 0 ? this.mapToEntity(result[0]) : null;
}
}
Factory Pattern with Type Safety
// Abstract factory for creating domain objects
abstract class EntityFactory<T> {
abstract create(data: unknown): T;
abstract validate(data: unknown): data is T;
}
// User factory implementation
interface CreateUserData {
email: string;
name: string;
}
class UserFactory extends EntityFactory<User> {
create(data: CreateUserData): User {
if (!this.validate(data)) {
throw new Error('Invalid user data');
}
return {
id: createUserId(this.generateId()),
email: createEmail(data.email),
name: data.name.trim(),
createdAt: new Date()
};
}
validate(data: unknown): data is CreateUserData {
if (typeof data !== 'object' || data === null) {
return false;
}
const candidate = data as Record<string, unknown>;
return (
typeof candidate.email === 'string' &&
typeof candidate.name === 'string' &&
this.isValidEmail(candidate.email) &&
candidate.name.trim().length > 0
);
}
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
private generateId(): string {
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}
// Factory registry for different entity types
class FactoryRegistry {
private factories = new Map<string, EntityFactory<any>>();
register<T>(type: string, factory: EntityFactory<T>): void {
this.factories.set(type, factory);
}
create<T>(type: string, data: unknown): T {
const factory = this.factories.get(type);
if (!factory) {
throw new Error(`No factory registered for type: ${type}`);
}
return factory.create(data);
}
}
// Usage
const factoryRegistry = new FactoryRegistry();
factoryRegistry.register('user', new UserFactory());
const user = factoryRegistry.create<User>('user', {
email: 'john@example.com',
name: 'John Doe'
});
Command Pattern with CQRS
// Command interfaces
interface Command {
readonly type: string;
readonly timestamp: Date;
}
interface CommandHandler<T extends Command> {
handle(command: T): Promise<void>;
}
// Query interfaces
interface Query<TResult> {
readonly type: string;
}
interface QueryHandler<TQuery extends Query<TResult>, TResult> {
handle(query: TQuery): Promise<TResult>;
}
// Command implementations
class CreateUserCommand implements Command {
readonly type = 'CreateUser';
readonly timestamp = new Date();
constructor(
public readonly userData: CreateUserData,
public readonly requestId: string
) {}
}
class UpdateUserCommand implements Command {
readonly type = 'UpdateUser';
readonly timestamp = new Date();
constructor(
public readonly userId: UserId,
public readonly updates: Partial<User>,
public readonly requestId: string
) {}
}
// Command handlers
class CreateUserCommandHandler implements CommandHandler<CreateUserCommand> {
constructor(
private userRepository: UserRepository,
private userFactory: UserFactory,
private eventBus: EventBus
) {}
async handle(command: CreateUserCommand): Promise<void> {
// Check if user already exists
const existingUser = await this.userRepository.findByEmail(
createEmail(command.userData.email)
);
if (existingUser) {
throw new DomainError('User with this email already exists');
}
// Create new user
const user = this.userFactory.create(command.userData);
await this.userRepository.save(user);
// Publish domain event
await this.eventBus.publish(new UserCreatedEvent(user.id, command.timestamp));
}
}
// Query implementations
class GetUserByIdQuery implements Query<User | null> {
readonly type = 'GetUserById';
constructor(public readonly userId: UserId) {}
}
class GetUsersQuery implements Query<User[]> {
readonly type = 'GetUsers';
constructor(
public readonly filters?: UserFilters,
public readonly pagination?: PaginationOptions
) {}
}
// Query handlers
class GetUserByIdQueryHandler implements QueryHandler<GetUserByIdQuery, User | null> {
constructor(private userRepository: UserRepository) {}
async handle(query: GetUserByIdQuery): Promise<User | null> {
return await this.userRepository.findById(query.userId);
}
}
// Command/Query bus
class Bus {
private commandHandlers = new Map<string, CommandHandler<any>>();
private queryHandlers = new Map<string, QueryHandler<any, any>>();
registerCommandHandler<T extends Command>(
commandType: string,
handler: CommandHandler<T>
): void {
this.commandHandlers.set(commandType, handler);
}
registerQueryHandler<TQuery extends Query<TResult>, TResult>(
queryType: string,
handler: QueryHandler<TQuery, TResult>
): void {
this.queryHandlers.set(queryType, handler);
}
async executeCommand<T extends Command>(command: T): Promise<void> {
const handler = this.commandHandlers.get(command.type);
if (!handler) {
throw new Error(`No handler registered for command: ${command.type}`);
}
await handler.handle(command);
}
async executeQuery<TResult>(query: Query<TResult>): Promise<TResult> {
const handler = this.queryHandlers.get(query.type);
if (!handler) {
throw new Error(`No handler registered for query: ${query.type}`);
}
return await handler.handle(query);
}
}
Dependency Injection and IoC {#dependency-injection}
Custom Dependency Injection Container
// Service registration types
type ServiceFactory<T> = () => T;
type ServiceConstructor<T> = new (...args: any[]) => T;
type ServiceLifetime = 'singleton' | 'transient' | 'scoped';
interface ServiceRegistration<T> {
lifetime: ServiceLifetime;
factory?: ServiceFactory<T>;
constructor?: ServiceConstructor<T>;
dependencies?: string[];
}
// IoC Container implementation
class Container {
private services = new Map<string, ServiceRegistration<any>>();
private singletons = new Map<string, any>();
private scoped = new Map<string, any>();
register<T>(
name: string,
registration: ServiceRegistration<T>
): void {
this.services.set(name, registration);
}
registerSingleton<T>(
name: string,
factory: ServiceFactory<T>
): void {
this.register(name, { lifetime: 'singleton', factory });
}
registerTransient<T>(
name: string,
constructor: ServiceConstructor<T>,
dependencies: string[] = []
): void {
this.register(name, {
lifetime: 'transient',
constructor,
dependencies
});
}
resolve<T>(name: string): T {
const registration = this.services.get(name);
if (!registration) {
throw new Error(`Service not registered: ${name}`);
}
switch (registration.lifetime) {
case 'singleton':
return this.resolveSingleton<T>(name, registration);
case 'transient':
return this.resolveTransient<T>(registration);
case 'scoped':
return this.resolveScoped<T>(name, registration);
default:
throw new Error(`Unknown service lifetime: ${registration.lifetime}`);
}
}
private resolveSingleton<T>(
name: string,
registration: ServiceRegistration<T>
): T {
if (this.singletons.has(name)) {
return this.singletons.get(name);
}
const instance = this.createInstance<T>(registration);
this.singletons.set(name, instance);
return instance;
}
private resolveTransient<T>(registration: ServiceRegistration<T>): T {
return this.createInstance<T>(registration);
}
private resolveScoped<T>(
name: string,
registration: ServiceRegistration<T>
): T {
if (this.scoped.has(name)) {
return this.scoped.get(name);
}
const instance = this.createInstance<T>(registration);
this.scoped.set(name, instance);
return instance;
}
private createInstance<T>(registration: ServiceRegistration<T>): T {
if (registration.factory) {
return registration.factory();
}
if (registration.constructor) {
const dependencies = (registration.dependencies || []).map(dep =>
this.resolve(dep)
);
return new registration.constructor(...dependencies);
}
throw new Error('No factory or constructor provided for service');
}
clearScoped(): void {
this.scoped.clear();
}
}
// Service decorators
const Injectable = (name: string, dependencies: string[] = []) => {
return <T extends new (...args: any[]) => {}>(constructor: T) => {
// Store metadata for automatic registration
Reflect.defineMetadata('injectable', { name, dependencies }, constructor);
return constructor;
};
};
// Example service implementations
interface ILogger {
log(message: string): void;
error(message: string, error?: Error): void;
}
@Injectable('logger')
class ConsoleLogger implements ILogger {
log(message: string): void {
console.log(`[LOG] ${new Date().toISOString()}: ${message}`);
}
error(message: string, error?: Error): void {
console.error(`[ERROR] ${new Date().toISOString()}: ${message}`, error);
}
}
interface IUserRepository {
findById(id: UserId): Promise<User | null>;
save(user: User): Promise<void>;
}
@Injectable('userRepository', ['database', 'logger'])
class UserRepositoryImpl implements IUserRepository {
constructor(
private database: Database,
private logger: ILogger
) {}
async findById(id: UserId): Promise<User | null> {
this.logger.log(`Finding user by ID: ${id}`);
// Implementation details...
return null;
}
async save(user: User): Promise<void> {
this.logger.log(`Saving user: ${user.id}`);
// Implementation details...
}
}
@Injectable('userService', ['userRepository', 'logger'])
class UserService {
constructor(
private userRepository: IUserRepository,
private logger: ILogger
) {}
async getUser(id: UserId): Promise<User | null> {
this.logger.log(`Getting user: ${id}`);
return await this.userRepository.findById(id);
}
}
// Container setup
const container = new Container();
// Register services
container.registerSingleton('logger', () => new ConsoleLogger());
container.registerSingleton('database', () => new DatabaseImpl());
container.registerTransient('userRepository', UserRepositoryImpl, ['database', 'logger']);
container.registerTransient('userService', UserService, ['userRepository', 'logger']);
// Usage
const userService = container.resolve<UserService>('userService');
Domain-Driven Design with TypeScript {#ddd}
Value Objects and Entities
// Base value object
abstract class ValueObject {
protected abstract getAtomicValues(): any[];
equals(other: ValueObject): boolean {
if (this.constructor !== other.constructor) {
return false;
}
const thisValues = this.getAtomicValues();
const otherValues = other.getAtomicValues();
if (thisValues.length !== otherValues.length) {
return false;
}
return thisValues.every((value, index) =>
this.areEqual(value, otherValues[index])
);
}
private areEqual(a: any, b: any): boolean {
if (a === null || a === undefined || b === null || b === undefined) {
return a === b;
}
if (a instanceof ValueObject && b instanceof ValueObject) {
return a.equals(b);
}
return a === b;
}
}
// Example value objects
class Money extends ValueObject {
private constructor(
private readonly amount: number,
private readonly currency: string
) {
super();
if (amount < 0) {
throw new Error('Money amount cannot be negative');
}
if (!currency || currency.length !== 3) {
throw new Error('Currency must be a 3-letter code');
}
}
static create(amount: number, currency: string): Money {
return new Money(amount, currency);
}
getAmount(): number {
return this.amount;
}
getCurrency(): string {
return this.currency;
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('Cannot add money with different currencies');
}
return new Money(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return new Money(this.amount * factor, this.currency);
}
protected getAtomicValues(): any[] {
return [this.amount, this.currency];
}
}
class Address extends ValueObject {
private constructor(
private readonly street: string,
private readonly city: string,
private readonly postalCode: string,
private readonly country: string
) {
super();
this.validate();
}
static create(street: string, city: string, postalCode: string, country: string): Address {
return new Address(street, city, postalCode, country);
}
private validate(): void {
if (!this.street?.trim()) {
throw new Error('Street is required');
}
if (!this.city?.trim()) {
throw new Error('City is required');
}
if (!this.postalCode?.trim()) {
throw new Error('Postal code is required');
}
if (!this.country?.trim()) {
throw new Error('Country is required');
}
}
getFullAddress(): string {
return `${this.street}, ${this.city} ${this.postalCode}, ${this.country}`;
}
protected getAtomicValues(): any[] {
return [this.street, this.city, this.postalCode, this.country];
}
}
// Base entity
abstract class Entity<T> {
protected constructor(protected readonly id: T) {}
getId(): T {
return this.id;
}
equals(other: Entity<T>): boolean {
if (this.constructor !== other.constructor) {
return false;
}
return this.id === other.id;
}
}
// Domain entities
class Customer extends Entity<CustomerId> {
private constructor(
id: CustomerId,
private name: string,
private email: Email,
private address: Address,
private readonly createdAt: Date = new Date()
) {
super(id);
}
static create(name: string, email: Email, address: Address): Customer {
const id = CustomerId.generate();
return new Customer(id, name, email, address);
}
static reconstitute(
id: CustomerId,
name: string,
email: Email,
address: Address,
createdAt: Date
): Customer {
return new Customer(id, name, email, address, createdAt);
}
updateAddress(newAddress: Address): void {
this.address = newAddress;
// Publish domain event
DomainEvents.publish(new CustomerAddressChangedEvent(this.id, newAddress));
}
getName(): string {
return this.name;
}
getEmail(): Email {
return this.email;
}
getAddress(): Address {
return this.address;
}
getCreatedAt(): Date {
return this.createdAt;
}
}
// Aggregate root
class Order extends Entity<OrderId> {
private items: OrderItem[] = [];
private status: OrderStatus = OrderStatus.PENDING;
private constructor(
id: OrderId,
private readonly customerId: CustomerId,
private readonly createdAt: Date = new Date()
) {
super(id);
}
static create(customerId: CustomerId): Order {
const id = OrderId.generate();
const order = new Order(id, customerId);
// Publish domain event
DomainEvents.publish(new OrderCreatedEvent(id, customerId));
return order;
}
addItem(productId: ProductId, quantity: number, unitPrice: Money): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error('Cannot modify confirmed order');
}
const existingItem = this.items.find(item =>
item.getProductId().equals(productId)
);
if (existingItem) {
existingItem.updateQuantity(existingItem.getQuantity() + quantity);
} else {
this.items.push(OrderItem.create(productId, quantity, unitPrice));
}
}
removeItem(productId: ProductId): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error('Cannot modify confirmed order');
}
this.items = this.items.filter(item =>
!item.getProductId().equals(productId)
);
}
confirm(): void {
if (this.items.length === 0) {
throw new Error('Cannot confirm order without items');
}
this.status = OrderStatus.CONFIRMED;
DomainEvents.publish(new OrderConfirmedEvent(this.id, this.getTotalAmount()));
}
getTotalAmount(): Money {
return this.items.reduce(
(total, item) => total.add(item.getTotalPrice()),
Money.create(0, 'USD')
);
}
getItems(): readonly OrderItem[] {
return [...this.items];
}
getStatus(): OrderStatus {
return this.status;
}
}
Domain Events
// Domain event infrastructure
interface DomainEvent {
readonly eventId: string;
readonly occurredOn: Date;
readonly aggregateId: string;
readonly eventType: string;
}
abstract class BaseDomainEvent implements DomainEvent {
readonly eventId: string;
readonly occurredOn: Date;
constructor(public readonly aggregateId: string) {
this.eventId = this.generateEventId();
this.occurredOn = new Date();
}
abstract get eventType(): string;
private generateEventId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}
// Concrete domain events
class CustomerAddressChangedEvent extends BaseDomainEvent {
readonly eventType = 'CustomerAddressChanged';
constructor(
customerId: CustomerId,
public readonly newAddress: Address
) {
super(customerId.toString());
}
}
class OrderCreatedEvent extends BaseDomainEvent {
readonly eventType = 'OrderCreated';
constructor(
orderId: OrderId,
public readonly customerId: CustomerId
) {
super(orderId.toString());
}
}
class OrderConfirmedEvent extends BaseDomainEvent {
readonly eventType = 'OrderConfirmed';
constructor(
orderId: OrderId,
public readonly totalAmount: Money
) {
super(orderId.toString());
}
}
// Domain event dispatcher
type EventHandler<T extends DomainEvent> = (event: T) => Promise<void>;
class DomainEvents {
private static handlers = new Map<string, EventHandler<any>[]>();
private static pendingEvents: DomainEvent[] = [];
static subscribe<T extends DomainEvent>(
eventType: string,
handler: EventHandler<T>
): void {
if (!this.handlers.has(eventType)) {
this.handlers.set(eventType, []);
}
this.handlers.get(eventType)!.push(handler);
}
static publish(event: DomainEvent): void {
this.pendingEvents.push(event);
}
static async dispatchAll(): Promise<void> {
const events = [...this.pendingEvents];
this.pendingEvents.length = 0;
for (const event of events) {
await this.dispatch(event);
}
}
private static async dispatch(event: DomainEvent): Promise<void> {
const handlers = this.handlers.get(event.eventType) || [];
for (const handler of handlers) {
try {
await handler(event);
} catch (error) {
console.error(`Error handling event ${event.eventType}:`, error);
// In production, you'd want proper error handling/retry logic
}
}
}
static clear(): void {
this.pendingEvents.length = 0;
}
}
// Event handlers
class OrderCreatedEventHandler {
constructor(
private emailService: EmailService,
private inventoryService: InventoryService
) {}
async handle(event: OrderCreatedEvent): Promise<void> {
// Send confirmation email
await this.emailService.sendOrderCreatedEmail(event.customerId);
// Reserve inventory
await this.inventoryService.reserveForOrder(event.aggregateId);
}
}
// Setup event handlers
DomainEvents.subscribe('OrderCreated', (event: OrderCreatedEvent) =>
new OrderCreatedEventHandler(emailService, inventoryService).handle(event)
);
Testing Strategies {#testing}
Unit Testing with Jest and TypeScript
// Test setup and utilities
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
// Mock factory for creating test data
class TestDataBuilder {
static createValidUser(): User {
return {
id: createUserId('test-user-123'),
email: createEmail('test@example.com'),
name: 'Test User',
createdAt: new Date('2023-01-01T00:00:00Z')
};
}
static createValidOrder(): Order {
const customerId = CustomerId.generate();
return Order.create(customerId);
}
static createMoney(amount: number = 100, currency: string = 'USD'): Money {
return Money.create(amount, currency);
}
}
// Mock implementations for dependencies
class MockUserRepository implements IUserRepository {
private users = new Map<string, User>();
async findById(id: UserId): Promise<User | null> {
return this.users.get(id.toString()) || null;
}
async save(user: User): Promise<void> {
this.users.set(user.getId().toString(), user);
}
async findByEmail(email: Email): Promise<User | null> {
for (const user of this.users.values()) {
if (user.getEmail() === email) {
return user;
}
}
return null;
}
// Test helper methods
clear(): void {
this.users.clear();
}
getStoredUsers(): User[] {
return Array.from(this.users.values());
}
}
// Unit tests for value objects
describe('Money', () => {
describe('create', () => {
it('should create money with valid amount and currency', () => {
const money = Money.create(100, 'USD');
expect(money.getAmount()).toBe(100);
expect(money.getCurrency()).toBe('USD');
});
it('should throw error for negative amount', () => {
expect(() => Money.create(-10, 'USD')).toThrow('Money amount cannot be negative');
});
it('should throw error for invalid currency', () => {
expect(() => Money.create(100, 'US')).toThrow('Currency must be a 3-letter code');
});
});
describe('add', () => {
it('should add money with same currency', () => {
const money1 = Money.create(100, 'USD');
const money2 = Money.create(50, 'USD');
const result = money1.add(money2);
expect(result.getAmount()).toBe(150);
expect(result.getCurrency()).toBe('USD');
});
it('should throw error when adding different currencies', () => {
const usd = Money.create(100, 'USD');
const eur = Money.create(50, 'EUR');
expect(() => usd.add(eur)).toThrow('Cannot add money with different currencies');
});
});
describe('equals', () => {
it('should return true for equal money objects', () => {
const money1 = Money.create(100, 'USD');
const money2 = Money.create(100, 'USD');
expect(money1.equals(money2)).toBe(true);
});
it('should return false for different amounts', () => {
const money1 = Money.create(100, 'USD');
const money2 = Money.create(50, 'USD');
expect(money1.equals(money2)).toBe(false);
});
});
});
// Unit tests for entities
describe('Order', () => {
let customerId: CustomerId;
beforeEach(() => {
customerId = CustomerId.generate();
});
describe('create', () => {
it('should create order with pending status', () => {
const order = Order.create(customerId);
expect(order.getStatus()).toBe(OrderStatus.PENDING);
expect(order.getItems()).toHaveLength(0);
});
});
describe('addItem', () => {
it('should add new item to order', () => {
const order = Order.create(customerId);
const productId = ProductId.generate();
const unitPrice = Money.create(50, 'USD');
order.addItem(productId, 2, unitPrice);
expect(order.getItems()).toHaveLength(1);
expect(order.getTotalAmount().getAmount()).toBe(100);
});
it('should increase quantity for existing item', () => {
const order = Order.create(customerId);
const productId = ProductId.generate();
const unitPrice = Money.create(50, 'USD');
order.addItem(productId, 2, unitPrice);
order.addItem(productId, 1, unitPrice);
expect(order.getItems()).toHaveLength(1);
expect(order.getItems()[0]!.getQuantity()).toBe(3);
});
it('should throw error when adding to confirmed order', () => {
const order = Order.create(customerId);
const productId = ProductId.generate();
const unitPrice = Money.create(50, 'USD');
order.addItem(productId, 1, unitPrice);
order.confirm();
expect(() => order.addItem(productId, 1, unitPrice))
.toThrow('Cannot modify confirmed order');
});
});
describe('confirm', () => {
it('should confirm order with items', () => {
const order = Order.create(customerId);
const productId = ProductId.generate();
const unitPrice = Money.create(50, 'USD');
order.addItem(productId, 1, unitPrice);
order.confirm();
expect(order.getStatus()).toBe(OrderStatus.CONFIRMED);
});
it('should throw error when confirming empty order', () => {
const order = Order.create(customerId);
expect(() => order.confirm()).toThrow('Cannot confirm order without items');
});
});
});
// Integration tests for services
describe('UserService', () => {
let userService: UserService;
let mockRepository: MockUserRepository;
let mockLogger: jest.Mocked<ILogger>;
beforeEach(() => {
mockRepository = new MockUserRepository();
mockLogger = {
log: jest.fn(),
error: jest.fn()
};
userService = new UserService(mockRepository, mockLogger);
});
describe('getUser', () => {
it('should return user when found', async () => {
const user = TestDataBuilder.createValidUser();
await mockRepository.save(user);
const result = await userService.getUser(user.getId());
expect(result).toEqual(user);
expect(mockLogger.log).toHaveBeenCalledWith(`Getting user: ${user.getId()}`);
});
it('should return null when user not found', async () => {
const userId = createUserId('non-existent');
const result = await userService.getUser(userId);
expect(result).toBeNull();
});
});
});
// Test utilities for async operations
describe('AsyncTestUtils', () => {
it('should handle domain events in tests', async () => {
const events: DomainEvent[] = [];
// Subscribe to events for testing
DomainEvents.subscribe('OrderCreated', async (event) => {
events.push(event);
});
const customerId = CustomerId.generate();
Order.create(customerId);
await DomainEvents.dispatchAll();
expect(events).toHaveLength(1);
expect(events[0]!.eventType).toBe('OrderCreated');
});
});
This comprehensive guide covers the essential patterns and practices for building enterprise-grade TypeScript applications. The type system, design patterns, dependency injection, domain-driven design principles, and testing strategies shown here provide a solid foundation for scalable, maintainable applications.
Remember that enterprise development is about finding the right balance between flexibility and constraints, ensuring your codebase can evolve while maintaining reliability and performance. Use these patterns judiciously, applying them where they add value rather than complexity.