46.7 Domain-Driven Design Concepts in TypeScript
Right, let’s talk about Domain-Driven Design. You’ve probably heard the term thrown around like confetti at a software wedding. It sounds grand, a bit academic, and honestly, a little intimidating. But strip away the ceremony, and DDD is just a set of brutally practical ideas for stopping your code from becoming a tangled mess as your problem domain gets complex. It’s about making your code a reflection of the business reality it operates in, not the other way around. TypeScript, with its powerful type system, is an almost obscenely good fit for this. Let’s dig in.
The Big Idea: Ubiquitous Language
This is the cornerstone. The Ubiquitous Language is a common, rigorous language between the developers and the domain experts. It’s the same set of terms used in conversations, diagrams, and—this is the crucial part—the code itself. If the business calls someone who places an order a “Customer,” your User class isn’t just wrong, it’s a lie that creates constant mental translation. In TypeScript, we enforce this with types.
// Bad: Vague and disconnected from the domain.
interface User {
id: string;
name: string;
canBuyStuff: boolean;
}
// Good: Uses the domain's terms directly.
interface Customer {
customerId: CustomerId; // See? Even the ID is a domain concept!
fullName: string;
status: CustomerStatus; // 'NEW', 'VERIFIED', 'BANNED' - not a boolean!
}
The moment you see a Customer type instead of a User, you understand the context and the rules implicitly. This is how you stop the “oh, that kind of user” conversations.
The Building Blocks: Entities and Value Objects
Not all objects are created equal. DDD makes a critical distinction.
An Entity is defined by its identity. It’s a thread of continuity. A Customer, an Order, an Invoice—these are entities. Their attributes might change (a customer changes their address), but their identity remains. They have an ID. In TypeScript, we model them as classes or interfaces with a unique identifier.
A Value Object has no conceptual identity. It is defined solely by its attributes. It is immutable. Think of Money, Address, or Coordinates. Two value objects with the same attributes are functionally identical and interchangeable. This is where TypeScript’s readonly properties and literal types shine.
// A classic Value Object: Address
class Address {
constructor(
public readonly street: string,
public readonly city: string,
public readonly postalCode: string,
) {}
// Value Objects should be comparable by their values.
equals(other: Address): boolean {
return (
this.street === other.street &&
this.city === other.city &&
this.postalCode === other.postalCode
);
}
}
// Usage - immutability is key. You don't "change" an address, you create a new one.
const shippingAddress = new Address('123 Main St', 'Anytown', '12345');
const newShippingAddress = new Address('456 Oak Ave', 'Anytown', '12345');
// shippingAddress.street = '456 Oak Ave'; // Compiler error! Good.
Using Value Objects prevents primitive obsession—the disease of representing rich concepts as mere strings and numbers. A string for a postal code can be anything. An Address object with a postalCode: string property is far more constrained and meaningful.
The Heart of the Matter: Aggregates
This is where most people’s DDD plans go to die, but it’s also the most powerful concept for enforcing data integrity. An Aggregate is a cluster of associated objects (entities and value objects) that we treat as a single unit for data changes. It has a root—a single Entity called the Aggregate Root—which is the only entry point for outside objects.
The root is the boss. It enforces invariants (consistent rules) within the whole aggregate. Let’s model a simple Order aggregate.
class OrderLine {
constructor(
public readonly productId: ProductId,
public readonly quantity: number,
public readonly unitPrice: number,
) {}
}
class Order {
// The root entity has an ID
private _orderId: OrderId;
private _customerId: CustomerId;
private _status: OrderStatus = 'PENDING';
// It owns and controls its children
private _lines: OrderLine[] = [];
// The only way to add a line is through the aggregate root's method
addLine(productId: ProductId, quantity: number, unitPrice: number): void {
if (this._status !== 'PENDING') {
throw new Error('Cannot modify a shipped or cancelled order.');
}
// Maybe other invariants: is the product in stock? is the quantity positive?
this._lines.push(new OrderLine(productId, quantity, unitPrice));
}
// The root provides read-only access to its internals
get lines(): ReadonlyArray<OrderLine> {
return this._lines;
}
// And controls all state changes
markAsShipped(): void {
// Invariant: can't ship an empty order
if (this._lines.length === 0) {
throw new Error('Cannot ship an order with no lines.');
}
this._status = 'SHIPPED';
}
}
The beauty here? No other part of the codebase can randomly push items into order.lines or change an order’s status willy-nilly. Everything must go through the Order aggregate root. It protects its own data consistency like a dragon guarding gold. This massively simplifies reasoning about your code.
Where the Magic Happens: Domain Events
Things happen in a domain. A payment is received. An order is shipped. These are not just state changes; they are facts, and other parts of the system often need to react to them. A Domain Event is a first-class citizen in your model that represents such a fact.
In TypeScript, we model them as simple, immutable data structures.
class OrderShippedEvent {
// The event's name is part of the Ubiquitous Language!
public readonly type = 'OrderShippedEvent'; // Helpful for discrimination
constructor(
public readonly orderId: OrderId,
public readonly occurredOn: Date,
public readonly trackingNumber: string,
) {}
}
// Inside the Order aggregate's markAsShipped method:
markAsShipped(trackingNumber: string): OrderShippedEvent {
// ... (invariant checks first) ...
this._status = 'SHIPPED';
this._trackingNumber = trackingNumber;
// After the state change, we record the event.
return new OrderShippedEvent(this._orderId, new Date(), trackingNumber);
}
The aggregate returns the event, and the calling application service (or infrastructure layer) is responsible for publishing it—maybe to a message queue or a simple in-memory event bus. This keeps your domain layer pure and unaware of infrastructure concerns.
The Rough Edges and Pitfalls
Let’s be honest. DDD is not free. It introduces indirection and ceremony. You will write more code. The pitfall is applying it everywhere. This is not a architecture for a simple CRUD app to manage your movie collection. It’s a strategic weapon for complex domains. If your problem isn’t complex, you’ll feel like you’re building a suspension bridge to cross a puddle.
The other big mistake is letting your aggregates get fat. An aggregate should be small and enforce a consistency boundary. If loading one Order requires hydrating 90% of your database because it’s all one massive aggregate, you’ve built a monster. It’s a constant trade-off between modeling true invariants and practical performance.
Finally, don’t get dogmatic. The blue book is a guide, not a religious text. Use the patterns that give you leverage for your specific problem. TypeScript gives you the tools to model this explicitly and catch mistakes at compile time. Use them. Your future self, trying to untangle a bug at 2 AM, will thank you for it.