ss
Navigate back to the homepage

Spaceout

beyond excelsior

Software development, application and system architecture

about meContact
Link to $https://www.facebook.com/spaceout/Link to $https://twitter.com/spaceoutLink to $https://www.instagram.com/spaceout/Link to $https://blog.spaceout.pl/Link to $https://dribbble.com/spaceoutLink to $https://behance.com/spaceoutLink to $https://github.com/massivDash/

Domain-Driven Design in TypeScript

by
Luke Celitan
category: post, reading time: 5 min

The Definitive Guide to Domain-Driven Design in TypeScript: Patterns, Examples, and Best Practices

1. Introduction

Domain-Driven Design (DDD) is more than a set of patterns—it’s a philosophy and a collaborative approach to building complex software. In TypeScript, DDD can help you create robust, maintainable, and expressive systems that truly reflect your business’s core logic. In this guide, I’ll take you on a deep technical journey through DDD, showing not just the theory, but how to implement it in TypeScript, with real-world scenarios, advanced patterns, and practical advice from the trenches.

2. What is Domain-Driven Design?

At its heart, DDD is about focusing on the domain—the sphere of knowledge and activity around which your business revolves. DDD is a way of thinking, a set of priorities, and a collection of patterns and practices that help teams tackle complexity by modeling software after the real-world problems it’s meant to solve.

Key tenets of DDD include:

  • Knowledge Crunching: Iteratively refining the domain model in close collaboration with domain experts.
  • Ubiquitous Language: Building a shared language that permeates code, tests, and conversations.
  • Model-Driven Design: Ensuring the code and the domain model evolve together, avoiding the analysis/design divide.

DDD is not just for large enterprises. Even in smaller TypeScript projects, the principles of DDD can help you avoid anemic models, clarify intent, and build systems that are easier to change and reason about.

3. The Building Blocks of DDD

Let’s start with the core building blocks. Each of these is both a conceptual tool and a practical pattern you’ll use in your TypeScript code.

Entities

Entities are objects with a distinct identity that persists over time. Their identity matters more than their attributes.

1class Customer {
2 constructor(
3 public readonly id: string,
4 public name: string,
5 public email: string
6 ) {}
7}

Best Practices:

  • Use immutable IDs (UUIDs, database-generated, etc.).
  • Equality is based on identity, not attributes.
  • Entities can change state, but their identity remains constant.

Value Objects

Value Objects are defined only by their attributes. They are immutable and interchangeable if their values are equal.

1class Address {
2 constructor(
3 public readonly street: string,
4 public readonly city: string,
5 public readonly zip: string
6 ) {}
7
8 equals(other: Address): boolean {
9 return (
10 this.street === other.street &&
11 this.city === other.city &&
12 this.zip === other.zip
13 );
14 }
15}

Best Practices:

  • Make value objects immutable.
  • Implement equality checks.
  • Use value objects to encapsulate concepts like Money, DateRange, or EmailAddress.

Aggregates

Aggregates are clusters of associated objects (entities and value objects) treated as a unit for data changes. Each aggregate has a root entity (the Aggregate Root) that enforces invariants and controls access.

1class Order {
2 private items: OrderItem[] = [];
3
4 constructor(public readonly id: string, public customer: Customer) {}
5
6 addItem(item: OrderItem) {
7 this.items.push(item);
8 }
9
10 get total(): number {
11 return this.items.reduce((sum, item) => sum + item.price, 0);
12 }
13}

Best Practices:

  • Only allow external access through the aggregate root.
  • Enforce invariants at the aggregate level.
  • Keep aggregates small and focused.

Repositories

Repositories abstract away the details of data storage and retrieval for aggregates.

1interface OrderRepository {
2 findById(id: string): Promise<Order | null>;
3 save(order: Order): Promise<void>;
4}

Best Practices:

  • Repositories work with aggregates, not entities or value objects directly.
  • Keep repository interfaces simple and intention-revealing.
  • Use dependency injection for testability.

Services

Services encapsulate domain operations that don’t naturally fit on an entity or value object.

1class PaymentService {
2 processPayment(order: Order, paymentDetails: PaymentDetails): PaymentResult {
3 // ...
4 }
5}

Best Practices:

  • Use domain services for operations involving multiple aggregates or domain concepts.
  • Keep services stateless when possible.

Modules

Modules group related concepts to reduce cognitive load and clarify boundaries. In TypeScript, use folders, namespaces, or ES modules.


4. Modeling the Domain in TypeScript

TypeScript’s type system is a powerful ally for DDD. You can use interfaces, classes, and type guards to model the domain, enforce invariants, and prevent invalid states.

Example: Shipping Domain

Let’s model a simplified shipping domain: Cargo, Voyage, Booking, and Location.

1// Value Object
2class Location {
3 constructor(
4 public readonly code: string, // e.g., 'NYC', 'LON'
5 public readonly name: string
6 ) {}
7}
8
9// Entity
10class Cargo {
11 private bookings: Booking[] = [];
12
13 constructor(
14 public readonly id: string,
15 public origin: Location,
16 public destination: Location
17 ) {}
18
19 book(voyage: Voyage) {
20 this.bookings.push(new Booking(this, voyage));
21 }
22}
23
24// Entity
25class Voyage {
26 constructor(
27 public readonly id: string,
28 public readonly schedule: VoyageSchedule
29 ) {}
30}
31
32// Value Object
33class VoyageSchedule {
34 constructor(
35 public readonly departure: Location,
36 public readonly arrival: Location,
37 public readonly departureDate: Date,
38 public readonly arrivalDate: Date
39 ) {}
40}
41
42// Aggregate Root
43class Booking {
44 constructor(
45 public readonly cargo: Cargo,
46 public readonly voyage: Voyage
47 ) {}
48}

TypeScript Patterns for DDD:

  • Use readonly for immutability.
  • Use interfaces for contracts (e.g., repositories, services).
  • Use type guards to enforce invariants at runtime.

5. Ubiquitous Language and Knowledge Crunching

A Ubiquitous Language is a shared vocabulary that connects code, tests, and conversations. It’s the backbone of DDD. In TypeScript, this means naming classes, methods, and variables after domain concepts, and refactoring relentlessly to keep the language consistent.

Example:

1// Instead of ambiguous names:
2const x = new Booking(y, z);
3
4// Use domain terms:
5const booking = new Booking(cargo, voyage);

Knowledge Crunching:

  • Work closely with domain experts.
  • Use code, diagrams, and conversations to refine the model.
  • Expect the model to evolve as you learn more.

6. Layered Architecture in TypeScript

A layered architecture separates concerns and keeps the domain model isolated from infrastructure and UI. A typical structure:

  • UI Layer: Handles user interaction.
  • Application Layer: Orchestrates use cases, coordinates aggregates.
  • Domain Layer: Contains entities, value objects, aggregates, services.
  • Infrastructure Layer: Handles persistence, messaging, external APIs.

Example Folder Structure:

1src/
2 domain/
3 entities/
4 value-objects/
5 services/
6 repositories/
7 application/
8 use-cases/
9 infrastructure/
10 orm/
11 api/
12 ui/
13 web/

Dependency Rule:

  • Domain layer depends on nothing.
  • Application depends on domain.
  • Infrastructure depends on domain and application.
  • UI depends on application.

7. Aggregates, Factories, and Repositories: Advanced Implementation

Aggregates

Aggregates enforce invariants and transactional consistency. In TypeScript, you can use private fields and methods to encapsulate rules.

1class BankAccount {
2 private balance: number;
3 private transactions: Transaction[] = [];
4
5 constructor(public readonly id: string, initialBalance: number) {
6 this.balance = initialBalance;
7 }
8
9 deposit(amount: number) {
10 if (amount <= 0) throw new Error('Amount must be positive');
11 this.balance += amount;
12 this.transactions.push(new Transaction('deposit', amount));
13 }
14
15 withdraw(amount: number) {
16 if (amount <= 0) throw new Error('Amount must be positive');
17 if (amount > this.balance) throw new Error('Insufficient funds');
18 this.balance -= amount;
19 this.transactions.push(new Transaction('withdraw', amount));
20 }
21}

Factories

Factories encapsulate complex creation logic, especially for aggregates.

1class BankAccountFactory {
2 static openAccount(id: string, initialBalance: number): BankAccount {
3 if (initialBalance < 0) throw new Error('Initial balance must be non-negative');
4 return new BankAccount(id, initialBalance);
5 }
6}

Repositories

Repositories abstract persistence. In TypeScript, use interfaces and dependency injection.

1interface BankAccountRepository {
2 findById(id: string): Promise<BankAccount | null>;
3 save(account: BankAccount): Promise<void>;
4}

8. Strategic Design

Strategic design is about managing complexity at scale. It’s where DDD really shines in large systems.

Bounded Contexts

A Bounded Context is an explicit boundary within which a particular domain model applies. Different teams or subsystems may have different models for the same concept.

Example:

  • In a shipping company, the term “Customer” might mean a paying client in the booking context, but a recipient in the delivery context.

Best Practices:

  • Make context boundaries explicit in code and documentation.
  • Integrate contexts via translation layers, APIs, or events.

Context Maps

A Context Map documents the relationships between bounded contexts: shared kernel, customer/supplier, conformist, anticorruption layer, etc.

Shared Kernel

A shared subset of the model/codebase used by multiple contexts. Requires close collaboration.

Anticorruption Layer

A translation layer that protects your model from external or legacy models.

TypeScript Example:

1// External API model
2type ExternalOrder = { order_id: string; total: number };
3
4// Internal model
5class Order {
6 constructor(public readonly id: string, public readonly total: number) {}
7}
8
9// Anticorruption Layer
10function mapExternalOrder(external: ExternalOrder): Order {
11 return new Order(external.order_id, external.total);
12}

Open Host Service & Published Language

Expose your model via a stable API or protocol, using a well-documented language (e.g., OpenAPI, GraphQL schema, or industry XML standard).

Distillation

Focus on the core domain, separate generic subdomains, and keep the model as simple as possible.


9. Supple Design: Making Models Easy to Use and Change

Supple design is about making your models expressive, intention-revealing, and easy to evolve.

Intention-Revealing Interfaces

Name methods and classes after domain concepts, not technical details.

Side-Effect-Free Functions

Prefer pure functions for calculations and queries.

Assertions and Invariants

Use TypeScript’s type system and runtime checks to enforce invariants.

Specification Pattern

Encapsulate business rules as reusable, composable objects.

1interface Specification<T> {
2 isSatisfiedBy(candidate: T): boolean;
3}
4
5class OverdraftAllowed implements Specification<BankAccount> {
6 isSatisfiedBy(account: BankAccount): boolean {
7 return account.balance >= 0;
8 }
9}

10. Refactoring Toward Deeper Insight

DDD is an iterative process. As you learn more, refactor your model to reflect deeper understanding.

Example: Evolving a Naive Model

Before:

1class Invoice {
2 constructor(public amount: number, public paid: boolean) {}
3}

After:

1class Invoice {
2 private payments: Payment[] = [];
3 constructor(public readonly id: string, public readonly total: number) {}
4
5 addPayment(payment: Payment) {
6 this.payments.push(payment);
7 }
8
9 get paid(): boolean {
10 return this.payments.reduce((sum, p) => sum + p.amount, 0) >= this.total;
11 }
12}

11. Testing and Evolving Your Domain Model

Testing is essential for evolving your domain model safely.

Example with Jest:

1test('BankAccount deposit increases balance', () => {
2 const account = new BankAccount('id', 100);
3 account.deposit(50);
4 expect(account.balance).toBe(150);
5});
  • Test aggregates, value objects, and business rules.
  • Use mocks for repositories and services.

12. Real-World Case Study: From Naive Model to Deep Model

Let’s walk through a shipping scenario, from a naive model to a deep, expressive domain model.

Step 1: Naive Model

1class Shipment {
2 constructor(
3 public id: string,
4 public origin: string,
5 public destination: string,
6 public status: string
7 ) {}
8}

Step 2: Introducing Value Objects and Entities

1class Location {
2 constructor(public readonly code: string, public readonly name: string) {}
3}
4
5class Shipment {
6 private status: ShipmentStatus;
7 constructor(
8 public readonly id: string,
9 public readonly origin: Location,
10 public readonly destination: Location
11 ) {
12 this.status = ShipmentStatus.Pending;
13 }
14
15 markInTransit() {
16 if (this.status !== ShipmentStatus.Pending) throw new Error('Invalid state');
17 this.status = ShipmentStatus.InTransit;
18 }
19}
20
21enum ShipmentStatus {
22 Pending,
23 InTransit,
24 Delivered,
25}

Step 3: Aggregates and Invariants

1class Shipment {
2 private events: ShipmentEvent[] = [];
3 // ...
4 addEvent(event: ShipmentEvent) {
5 // Enforce business rules here
6 this.events.push(event);
7 }
8}

Step 4: Bounded Contexts and Integration

  • Booking, Tracking, and Billing contexts each have their own models for Shipment.
  • Integrate via APIs or events, using anticorruption layers.

13. Best Practices and Common Pitfalls

Best Practices

  • Collaborate closely with domain experts.
  • Build and maintain a ubiquitous language.
  • Keep aggregates small and focused.
  • Use value objects liberally.
  • Isolate the domain model from infrastructure.
  • Test business rules thoroughly.
  • Refactor toward deeper insight.

Common Pitfalls

  • Anemic domain models (entities with only getters/setters).
  • Overly large aggregates.
  • Leaking infrastructure concerns into the domain.
  • Ignoring bounded contexts and integration challenges.
  • Failing to evolve the model as understanding grows.

14. Conclusion and Further Resources

Domain-Driven Design is a journey, not a destination. In TypeScript, DDD can help you build systems that are robust, expressive, and a joy to work with. By focusing on the domain, collaborating with experts, and relentlessly refining your model, you can tackle even the most complex business problems.

Further Reading:

  • Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
  • Vaughn Vernon, Implementing Domain-Driven Design
  • Patterns of Enterprise Application Architecture (Martin Fowler)
  • Domain-Driven Design Community: https://dddcommunity.org/
  • Awesome DDD: https://github.com/heynickc/awesome-ddd

This guide is intended as a living reference. As the TypeScript ecosystem and DDD practices evolve, so too should your models and your approach. Happy modeling!

ss

SSE vs WebSockets

A comprehensive deep-dive into Server-Sent Events and WebSockets, covering protocol details, architecture, performance, security, best practices, troubleshooting, advanced concepts, and real-world use cases with practical code examples.

3 min czytania

The Definitive Guide to Video Optimization and Delivery in Web Applications

A comprehensive deep-dive into optimizing, storing, and delivering video for web applications, including FFmpeg best practices, advanced streaming, storage strategies, CDN integration, troubleshooting, and real-world use cases.

5 min czytania

Loading search index...