Table of Contents
- Introduction
- What is Domain-Driven Design (DDD)?
- Core Concepts of DDD
- Entities
- Value Objects
- Aggregates
- Repositories
- Services
- Structuring a DDD Project in TypeScript
- Example: Building a Simple DDD Module in TypeScript
- Best Practices for DDD in TypeScript
- Conclusion
Introduction
As software systems grow in complexity, organizing code around technical concerns (controllers, services, repositories) becomes insufficient. Domain-Driven Design (DDD) offers a structured approach to tackling complex business problems by modeling software after real-world domains.
TypeScript, with its strong typing and object-oriented features, is a powerful language for applying DDD effectively.
This article explores DDD basics in TypeScript with practical examples.
What is Domain-Driven Design (DDD)?
Domain-Driven Design (DDD) is a methodology and set of principles for developing software systems that revolve around the core domain and domain logic.
Eric Evans introduced DDD to help teams model complex business domains accurately and maintain a shared understanding between domain experts and developers.
In short, DDD emphasizes:
- Focusing on business logic.
- Structuring code around the domain rather than technical layers.
- Using a common language (Ubiquitous Language) shared between developers and business stakeholders.
Core Concepts of DDD
Let’s understand the main building blocks of DDD with TypeScript examples.
1. Entities
An Entity is an object that has a distinct identity that runs through time and different states.
Example:
class User {
constructor(
public readonly id: string,
public name: string,
public email: string
) {}
changeEmail(newEmail: string) {
this.email = newEmail;
}
}
id
uniquely identifies theUser
.- The identity remains even if other fields change.
2. Value Objects
Value Objects are immutable and distinguishable only by their properties, not by identity.
Example:
class Email {
constructor(private readonly email: string) {
if (!this.validate(email)) {
throw new Error('Invalid email format.');
}
}
private validate(email: string): boolean {
return /\S+@\S+\.\S+/.test(email);
}
toString(): string {
return this.email;
}
}
- No
id
. - Two Email instances with the same email address are considered equal.
3. Aggregates
An Aggregate is a cluster of domain objects (Entities and Value Objects) treated as a single unit.
The Aggregate Root is the only entry point for accessing objects inside the aggregate.
Example:
class OrderItem {
constructor(public productId: string, public quantity: number) {}
}
class Order {
private items: OrderItem[] = [];
constructor(public readonly id: string) {}
addItem(productId: string, quantity: number) {
const item = new OrderItem(productId, quantity);
this.items.push(item);
}
getItems(): OrderItem[] {
return [...this.items]; // return a copy to prevent outside mutation
}
}
Order
is the Aggregate Root.OrderItem
objects are managed byOrder
.
4. Repositories
A Repository provides an abstraction to access and manage aggregates without exposing storage details (DB, API, etc.).
Example:
interface UserRepository {
save(user: User): Promise<void>;
findById(id: string): Promise<User | null>;
}
An in-memory implementation:
class InMemoryUserRepository implements UserRepository {
private users = new Map<string, User>();
async save(user: User): Promise<void> {
this.users.set(user.id, user);
}
async findById(id: string): Promise<User | null> {
return this.users.get(id) || null;
}
}
5. Services
A Service contains domain logic that doesn’t naturally fit inside an Entity or Value Object.
Example:
class PaymentService {
processPayment(orderId: string, amount: number) {
// Connect to a payment gateway
console.log(`Processing payment for order ${orderId} with amount ${amount}`);
}
}
Structuring a DDD Project in TypeScript
A basic structure for a DDD TypeScript project could look like:
/src
/domain
/user
- User.ts (Entity)
- Email.ts (Value Object)
- UserRepository.ts (Repository Interface)
/infrastructure
- InMemoryUserRepository.ts
/application
- UserService.ts (Application/Domain Service)
/presentation
- UserController.ts (Optional: API/HTTP Layer)
- Domain: Business rules, pure domain logic.
- Infrastructure: DBs, APIs, external dependencies.
- Application: Use cases and service orchestration.
- Presentation: HTTP controllers, GraphQL resolvers, etc.
Example: Building a Simple DDD Module in TypeScript
Let’s quickly wire up a simple module.
User Entity
class User {
constructor(
public readonly id: string,
public name: string,
public readonly email: Email
) {}
updateName(newName: string) {
this.name = newName;
}
}
Email Value Object
class Email {
constructor(private readonly value: string) {
if (!/\S+@\S+\.\S+/.test(value)) {
throw new Error('Invalid email format.');
}
}
getValue(): string {
return this.value;
}
}
User Repository Interface
interface IUserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
In-Memory Implementation
class InMemoryUserRepository implements IUserRepository {
private users = new Map<string, User>();
async findById(id: string): Promise<User | null> {
return this.users.get(id) || null;
}
async save(user: User): Promise<void> {
this.users.set(user.id, user);
}
}
Application Service
class UserService {
constructor(private userRepository: IUserRepository) {}
async registerUser(id: string, name: string, emailValue: string) {
const email = new Email(emailValue);
const user = new User(id, name, email);
await this.userRepository.save(user);
}
}
Best Practices for DDD in TypeScript
- Separate domain and infrastructure: Keep core business logic pure.
- Use Interfaces heavily: Especially for Repositories and external dependencies.
- Focus on the Ubiquitous Language: Name entities, methods, and services exactly how the business describes them.
- Prefer immutability: Especially for Value Objects.
- Guard Aggregate integrity: Aggregates should enforce all business rules internally.
- Write tests against domain logic: Your domain should be easily unit testable without databases.
Conclusion
Domain-Driven Design is a powerful approach to tackling complex business software projects.
In TypeScript, with strong typing and class-based OOP features, DDD becomes more structured and maintainable.
When applied correctly, DDD leads to:
- Cleaner architecture
- Better communication between teams
- High maintainability
- Easier scaling and evolution of systems
As you move forward, try applying DDD patterns gradually, starting small — maybe just in one bounded context — and then expanding as your system grows.