When I first heard about Domain-Driven Design (DDD) three years ago, I dismissed it as another buzzword in the ever-evolving landscape of software architecture. Fast forward to last month, and I found myself knee-deep in a complex SaaS project where the business logic was scattered across controllers, services, and utility functions like confetti after a New Year’s party. That’s when I decided to give DDD a serious try, but with a TypeScript twist that would change how I approach software design forever.
The Problem That Started It All
I was building a mortgage processing platform (yes, another fintech adventure), and the complexity was eating me alive. User entities were mixed with loan calculations, validation logic was duplicated everywhere, and adding new business rules felt like performing surgery with a butter knife. The codebase had grown organically, and like many developers, I had fallen into the trap of anemic domain models – classes that were nothing more than data containers with getters and setters.
The breaking point came when my product manager asked for a “simple” feature: automatic risk assessment based on multiple factors including credit score, employment history, and property value. What should have been straightforward turned into a week-long refactoring nightmare because the logic was spread across twelve different files.
That’s when I remembered reading about Domain-Driven Design and decided to explore how TypeScript’s decorator system and reflection capabilities could make DDD not just bearable, but actually enjoyable.
Understanding Domain-Driven Design
Before diving into the technical implementation, let me share what clicked for me about DDD. At its core, DDD is about modeling your software around the business domain rather than technical concerns. Instead of thinking in terms of databases, APIs, and frameworks, you think in terms of the actual business processes and rules.
The key concepts that transformed my approach were:
Entities - Objects with identity that persist over time. In my mortgage platform, a LoanApplication
is an entity because it has a unique ID and its state changes throughout the application process.
Value Objects - Immutable objects that are defined by their attributes rather than identity. A CreditScore
is a perfect value object – two credit scores with the same number are essentially the same thing.
Aggregates - Clusters of related entities and value objects that are treated as a single unit. A MortgageApplication
aggregate might contain the application entity, applicant information, property details, and financial documents.
Domain Services - Operations that don’t naturally fit within entities or value objects but are essential to the domain.
Enter TypeScript Decorators
TypeScript decorators became my secret weapon for implementing DDD patterns cleanly. Decorators allowed me to add metadata and behavior to classes without polluting the core domain logic. Here’s how I started using them:
Creating a Domain Entity Decorator
My first decorator was simple but powerful:
import 'reflect-metadata';
const ENTITY_METADATA_KEY = Symbol('entity');
export function Entity(name: string) {
return function <T extends { new(...args: any[]): {} }>(constructor: T) {
Reflect.defineMetadata(ENTITY_METADATA_KEY, { name, isEntity: true }, constructor);
return constructor;
};
}
export function isEntity(target: any): boolean {
return Reflect.getMetadata(ENTITY_METADATA_KEY, target)?.isEntity ?? false;
}
This decorator allowed me to mark classes as domain entities and retrieve that information at runtime. But the real magic happened when I combined it with validation and business rule decorators.
Business Rule Decorators
One of my proudest moments was creating a decorator system for business rules:
const BUSINESS_RULES_KEY = Symbol('businessRules');
export function BusinessRule(description: string, validator: (target: any) => boolean) {
return function (target: any, propertyKey: string) {
const existingRules = Reflect.getMetadata(BUSINESS_RULES_KEY, target.constructor) || [];
existingRules.push({ description, validator, property: propertyKey });
Reflect.defineMetadata(BUSINESS_RULES_KEY, existingRules, target.constructor);
};
}
export function validateBusinessRules(instance: any): ValidationResult {
const rules = Reflect.getMetadata(BUSINESS_RULES_KEY, instance.constructor) || [];
const violations: string[] = [];
for (const rule of rules) {
if (!rule.validator(instance)) {
violations.push(`${rule.property}: ${rule.description}`);
}
}
return {
isValid: violations.length === 0,
violations
};
}
Now I could write domain entities that were self-documenting and self-validating:
@Entity('LoanApplication')
export class LoanApplication {
private _id: string;
private _creditScore: number;
private _loanAmount: number;
private _annualIncome: number;
constructor(id: string, creditScore: number, loanAmount: number, annualIncome: number) {
this._id = id;
this._creditScore = creditScore;
this._loanAmount = loanAmount;
this._annualIncome = annualIncome;
}
@BusinessRule(
'Credit score must be at least 600 for loan approval',
(loan: LoanApplication) => loan._creditScore >= 600
)
get creditScore() { return this._creditScore; }
@BusinessRule(
'Debt-to-income ratio must not exceed 43%',
(loan: LoanApplication) => (loan._loanAmount / loan._annualIncome) <= 0.43
)
get debtToIncomeRatio() {
return this._loanAmount / this._annualIncome;
}
public validate(): ValidationResult {
return validateBusinessRules(this);
}
}
Reflection for Dynamic Domain Discovery
The real power came when I started using reflection to build a domain registry that could discover and manage all my domain objects automatically:
export class DomainRegistry {
private entities = new Map<string, any>();
private aggregateRoots = new Map<string, any>();
public registerEntity(constructor: any) {
const metadata = Reflect.getMetadata(ENTITY_METADATA_KEY, constructor);
if (metadata?.isEntity) {
this.entities.set(metadata.name, constructor);
}
}
public getAllEntities(): Map<string, any> {
return new Map(this.entities);
}
public createEntity(name: string, ...args: any[]) {
const EntityConstructor = this.entities.get(name);
if (!EntityConstructor) {
throw new Error(`Entity '${name}' not found in domain registry`);
}
return new EntityConstructor(...args);
}
public validateAllRules(instance: any): ValidationResult {
return validateBusinessRules(instance);
}
}
This registry became the heart of my domain layer, automatically discovering decorated classes and providing a clean API for creating and validating domain objects.
Advanced Patterns: Event Sourcing with Decorators
As I got more comfortable with the decorator approach, I implemented domain events using the same pattern:
const DOMAIN_EVENTS_KEY = Symbol('domainEvents');
export function DomainEvent(eventName: string) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const result = originalMethod.apply(this, args);
this.addDomainEvent(new (class {
constructor(public name: string, public data: any, public timestamp: Date) {}
})(eventName, { instance: this, args }, new Date()));
return result;
};
const existingEvents = Reflect.getMetadata(DOMAIN_EVENTS_KEY, target.constructor) || [];
existingEvents.push(eventName);
Reflect.defineMetadata(DOMAIN_EVENTS_KEY, existingEvents, target.constructor);
};
}
Now my entities could automatically emit domain events when important business operations occurred:
@Entity('LoanApplication')
export class LoanApplication {
private domainEvents: any[] = [];
@DomainEvent('LoanApplicationSubmitted')
public submit(): void {
if (this.status !== ApplicationStatus.Draft) {
throw new Error('Only draft applications can be submitted');
}
this.status = ApplicationStatus.Submitted;
this.submittedAt = new Date();
}
@DomainEvent('LoanApplicationApproved')
public approve(approver: string): void {
const validation = this.validate();
if (!validation.isValid) {
throw new Error(`Cannot approve invalid application: ${validation.violations.join(', ')}`);
}
this.status = ApplicationStatus.Approved;
this.approvedBy = approver;
this.approvedAt = new Date();
}
public getUncommittedEvents(): any[] {
return [...this.domainEvents];
}
private addDomainEvent(event: any): void {
this.domainEvents.push(event);
}
}
Building a Repository Pattern with Reflection
The repository pattern became much more interesting with decorators and reflection. I created a base repository that could automatically handle any decorated entity:
export abstract class BaseRepository<T> {
protected abstract storageAdapter: StorageAdapter;
async save(entity: T): Promise<void> {
const entityMetadata = this.getEntityMetadata(entity);
// Validate business rules before saving
const validation = validateBusinessRules(entity);
if (!validation.isValid) {
throw new Error(`Cannot save invalid entity: ${validation.violations.join(', ')}`);
}
// Emit domain events
const events = (entity as any).getUncommittedEvents?.() || [];
await this.storageAdapter.save(entityMetadata.name, entity);
// Publish events after successful save
for (const event of events) {
await this.publishDomainEvent(event);
}
}
private getEntityMetadata(entity: any) {
const metadata = Reflect.getMetadata(ENTITY_METADATA_KEY, entity.constructor);
if (!metadata?.isEntity) {
throw new Error('Object is not a domain entity');
}
return metadata;
}
protected abstract publishDomainEvent(event: any): Promise<void>;
}
The Transformation
After implementing this decorator-based DDD approach, my mortgage platform transformed from a chaotic mess into an elegant, self-documenting system. Business rules were clearly visible in the code, validation happened automatically, and adding new features became a matter of extending domain objects rather than hunting through service layers.
The most satisfying moment came when my product manager asked for that risk assessment feature again, but this time for a different type of loan. Instead of another week-long refactoring session, I simply created a new aggregate with decorated business rules and had it working in a few hours.
Performance Considerations
I was initially worried about the runtime overhead of reflection, but in practice, the impact was negligible. The metadata lookup happens once per class, and the benefits of having self-validating, event-emitting domain objects far outweighed the minimal performance cost.
For high-performance scenarios, I created a compile-time optimization that pre-computed the reflection metadata and stored it as static properties, eliminating runtime reflection calls entirely.
Lessons Learned
Start Small - I began with simple entity decorators and gradually added complexity. This incremental approach helped me understand the patterns deeply before building advanced features.
Metadata is Powerful - TypeScript’s reflection capabilities opened up possibilities I hadn’t considered. Being able to introspect and modify behavior at runtime made my domain objects incredibly flexible.
Documentation Becomes Code - With decorators describing business rules and domain events, my code became self-documenting. New team members could understand the business logic just by reading the decorated classes.
Testing Got Easier - Having business rules as decorators meant I could test them in isolation and ensure they were being applied consistently across all domain objects.
The Future
I’m now exploring how to extend this approach with GraphQL schema generation, automatic API documentation, and even UI form generation directly from decorated domain models. The combination of DDD principles with TypeScript’s powerful metadata system has opened up architectural possibilities I’m still discovering.
Looking back, what started as a frustrated attempt to organize messy business logic has evolved into a comprehensive approach to domain modeling that I now use in all my projects. The decorator-based DDD pattern has become my secret weapon for building maintainable, expressive, and robust business applications.
If you’re struggling with complex business logic scattered across your codebase, I encourage you to give this approach a try. Start with a simple entity decorator, add some business rules, and watch as your domain model transforms from a collection of data containers into a rich, expressive representation of your business domain.