Why Ordinary CRUD Kills Your Project, and How DDD Can Fix It
\n\nEvery 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?
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\nIn 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\nDDD Basics: Language, Contexts, and Model
\n\nUbiquitous Language
\nThe 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.
Bounded Context
\nA 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\nBuilding a Domain Model in TypeScript
\n\nValue Object
\nThis 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\nNotice: 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.
Entity
\nUnlike 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\nAggregate
\nAn 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\nExample: an order (Order)