DDD in TypeScript: Clean Architecture for Complex Projects

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

Why Ordinary CRUD Kills Your Project, and How DDD Can Fix It

\n\n

Every developer has at least once encountered a situation where a seemingly simple project turns into a "swamp" of services, controllers, and business logic smeared across the entire codebase. You start writing UserService, then OrderService, and six months later OrderService contains logic for sending emails, calculating discounts, and checking user balance. Sound familiar?

\n\n

The problem is that we think about databases, frameworks, and REST APIs too early. We forget about the main thing — the domain. Domain-Driven Design (DDD) offers a radically different approach: first, model the business processes in the language of experts, and only then — the code.

\n\n

In this article, we will explore how to apply Domain-Driven Design in TypeScript using real examples. You will see how to build a clean architecture where business rules live separately from infrastructure, and the code is easy to test and modify.

\n\n

DDD Basics: Language, Contexts, and Model

\n\n

Ubiquitous Language

\n

The first principle of DDD is a ubiquitous language understood by both developers and business experts. If a manager says "The client activated the subscription," then the code should have a method activateSubscription(), not setStatus(1). TypeScript with its strict typing is ideal for this — class, method, and variable names become documentation.

\n\n

Bounded Context

\n

A large system is divided into independent contexts: "Payments," "User Management," "Delivery." Each context has its own model and its own language. TypeScript modules and strict types help to rigidly separate these contexts at the import level.

\n\n

Building a Domain Model in TypeScript

\n\n

Value Object

\n

This is an immutable entity defined by its attributes, not an identifier. Examples: Email, Money, Coordinates.

\n\n
// value-objects/Email.ts\nexport class Email {\n  private readonly value: string;\n\n  constructor(email: string) {\n    if (!this.isValid(email)) {\n      throw new Error('Invalid email address');\n    }\n    this.value = email;\n  }\n\n  private isValid(email: string): boolean {\n    return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email);\n  }\n\n  public getValue(): string {\n    return this.value;\n  }\n\n  public equals(other: Email): boolean {\n    return this.value === other.value;\n  }\n}\n
\n\n

Notice: Email validates itself. No service should know what a valid email looks like. This is pure Domain-Driven Design in TypeScript — the business rule lives inside the object.

\n\n

Entity

\n

Unlike a Value Object, an entity has an identifier (ID) and can change over time. Examples: User, Order.

\n\n
// entities/User.ts\nimport { Email } from '../value-objects/Email';\nimport { UserId } from '../value-objects/UserId';\n\nexport class User {\n  private readonly id: UserId;\n  private email: Email;\n  private isActive: boolean;\n\n  constructor(id: UserId, email: Email) {\n    this.id = id;\n    this.email = email;\n    this.isActive = true;\n  }\n\n  public getId(): UserId {\n    return this.id;\n  }\n\n  public changeEmail(newEmail: Email): void {\n    if (!this.isActive) {\n      throw new Error('Cannot change email for inactive user');\n    }\n    this.email = newEmail;\n  }\n\n  public deactivate(): void {\n    this.isActive = false;\n  }\n}\n
\n\n

Aggregate

\n

An aggregate is a group of entities and value objects that are treated as a single unit. An aggregate has a root (Aggregate Root) through which access to internal objects occurs.

\n\n

Example: an order (Order)

Blogs

Book Recommendations