Domain-Driven Design (DDD) Practical Guide - Learning Tactical Design with TypeScript

2025.12.02

Domain-Driven Design (DDD) is a methodology for effectively designing and implementing software with complex business logic. This article explains how to implement DDD tactical design patterns in TypeScript.

DDD Fundamentals

Strategic Design and Tactical Design

Design LevelComponents
Strategic DesignUbiquitous Language (defining shared language)
Bounded Context (system partitioning)
Context Map (relationships between contexts)
Subdomains (Core/Supporting/Generic)
Tactical DesignEntity (object with identity)
Value Object (immutable value)
Aggregate (consistency boundary)
Repository (persistence abstraction)
Domain Service (logic not belonging to entities)
Domain Event (state change notification)
Factory (complex object creation)

Layered Architecture

flowchart TB
    P["Presentation Layer<br/>(Controllers, API, UI)"]
    A["Application Layer<br/>(Use Cases, Application Services, DTOs)"]
    D["Domain Layer<br/>(Entities, Value Objects, Aggregates, Domain Services)"]
    I["Infrastructure Layer<br/>(Repositories, External Services, DB)"]

    P --> A --> D --> I

Value Objects

Basic Value Object

// domain/value-objects/email.ts

export class Email {
  private readonly value: string;

  private constructor(value: string) {
    this.value = value;
  }

  static create(value: string): Email {
    if (!value || !this.isValid(value)) {
      throw new Error('Invalid email format');
    }
    return new Email(value.toLowerCase().trim());
  }

  private static isValid(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }

  getValue(): string {
    return this.value;
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }

  getDomain(): string {
    return this.value.split('@')[1];
  }

  toString(): string {
    return this.value;
  }
}

Composite Value Object

// domain/value-objects/money.ts

export type Currency = 'JPY' | 'USD' | 'EUR';

export class Money {
  private constructor(
    private readonly amount: number,
    private readonly currency: Currency
  ) {
    if (amount < 0) {
      throw new Error('Amount cannot be negative');
    }
  }

  static create(amount: number, currency: Currency): Money {
    return new Money(amount, currency);
  }

  static zero(currency: Currency): Money {
    return new Money(0, currency);
  }

  getAmount(): number {
    return this.amount;
  }

  getCurrency(): Currency {
    return this.currency;
  }

  add(other: Money): Money {
    this.assertSameCurrency(other);
    return new Money(this.amount + other.amount, this.currency);
  }

  subtract(other: Money): Money {
    this.assertSameCurrency(other);
    const result = this.amount - other.amount;
    if (result < 0) {
      throw new Error('Result cannot be negative');
    }
    return new Money(result, this.currency);
  }

  multiply(factor: number): Money {
    if (factor < 0) {
      throw new Error('Factor cannot be negative');
    }
    return new Money(Math.round(this.amount * factor), this.currency);
  }

  isGreaterThan(other: Money): boolean {
    this.assertSameCurrency(other);
    return this.amount > other.amount;
  }

  isLessThan(other: Money): boolean {
    this.assertSameCurrency(other);
    return this.amount < other.amount;
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }

  private assertSameCurrency(other: Money): void {
    if (this.currency !== other.currency) {
      throw new Error('Currency mismatch');
    }
  }

  format(): string {
    const formatter = new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: this.currency,
    });
    return formatter.format(this.amount);
  }
}

Address Value Object

// domain/value-objects/address.ts

interface AddressProps {
  postalCode: string;
  state: string;
  city: string;
  street: string;
  building?: string;
}

export class Address {
  private constructor(
    private readonly postalCode: string,
    private readonly state: string,
    private readonly city: string,
    private readonly street: string,
    private readonly building?: string
  ) {}

  static create(props: AddressProps): Address {
    this.validatePostalCode(props.postalCode);
    this.validateState(props.state);

    return new Address(
      props.postalCode,
      props.state,
      props.city,
      props.street,
      props.building
    );
  }

  private static validatePostalCode(postalCode: string): void {
    if (!/^\d{5}(-\d{4})?$/.test(postalCode)) {
      throw new Error('Invalid postal code format');
    }
  }

  private static validateState(state: string): void {
    const states = [
      'Alabama', 'Alaska', 'Arizona', /* ... */
    ];
    if (!states.includes(state)) {
      throw new Error('Invalid state');
    }
  }

  getPostalCode(): string {
    return this.postalCode;
  }

  getState(): string {
    return this.state;
  }

  getFullAddress(): string {
    const parts = [
      this.street,
      this.city,
      this.state,
      this.postalCode,
    ];
    if (this.building) {
      parts.unshift(this.building);
    }
    return parts.join(', ');
  }

  equals(other: Address): boolean {
    return (
      this.postalCode === other.postalCode &&
      this.state === other.state &&
      this.city === other.city &&
      this.street === other.street &&
      this.building === other.building
    );
  }
}

Entities

Base Entity Class

// domain/shared/entity.ts

export abstract class Entity<T> {
  protected readonly _id: T;

  constructor(id: T) {
    this._id = id;
  }

  get id(): T {
    return this._id;
  }

  equals(other: Entity<T>): boolean {
    if (other === null || other === undefined) {
      return false;
    }
    if (!(other instanceof Entity)) {
      return false;
    }
    return this._id === other._id;
  }
}

User Entity

// domain/entities/user.ts

import { Entity } from '../shared/entity';
import { Email } from '../value-objects/email';
import { UserId } from '../value-objects/user-id';
import { UserCreatedEvent } from '../events/user-created';

interface UserProps {
  email: Email;
  name: string;
  role: UserRole;
  status: UserStatus;
  createdAt: Date;
  updatedAt: Date;
}

type UserRole = 'admin' | 'member' | 'guest';
type UserStatus = 'active' | 'inactive' | 'suspended';

export class User extends Entity<UserId> {
  private email: Email;
  private name: string;
  private role: UserRole;
  private status: UserStatus;
  private readonly createdAt: Date;
  private updatedAt: Date;

  private constructor(id: UserId, props: UserProps) {
    super(id);
    this.email = props.email;
    this.name = props.name;
    this.role = props.role;
    this.status = props.status;
    this.createdAt = props.createdAt;
    this.updatedAt = props.updatedAt;
  }

  // Factory method
  static create(email: Email, name: string): User {
    const id = UserId.create();
    const now = new Date();

    const user = new User(id, {
      email,
      name,
      role: 'member',
      status: 'active',
      createdAt: now,
      updatedAt: now,
    });

    // Publish domain event
    user.addDomainEvent(new UserCreatedEvent(user.id, email));

    return user;
  }

  // Reconstitution method (for restoration from repository)
  static reconstruct(id: UserId, props: UserProps): User {
    return new User(id, props);
  }

  // Business logic
  changeName(newName: string): void {
    if (!newName || newName.trim().length < 2) {
      throw new Error('Name must be at least 2 characters');
    }
    this.name = newName.trim();
    this.updatedAt = new Date();
  }

  changeEmail(newEmail: Email): void {
    if (this.email.equals(newEmail)) {
      return;
    }
    this.email = newEmail;
    this.updatedAt = new Date();
  }

  promote(): void {
    if (this.role === 'admin') {
      throw new Error('User is already an admin');
    }
    this.role = 'admin';
    this.updatedAt = new Date();
  }

  suspend(): void {
    if (this.status === 'suspended') {
      throw new Error('User is already suspended');
    }
    this.status = 'suspended';
    this.updatedAt = new Date();
  }

  activate(): void {
    if (this.status === 'active') {
      throw new Error('User is already active');
    }
    this.status = 'active';
    this.updatedAt = new Date();
  }

  isActive(): boolean {
    return this.status === 'active';
  }

  isAdmin(): boolean {
    return this.role === 'admin';
  }

  // Getters
  getEmail(): Email {
    return this.email;
  }

  getName(): string {
    return this.name;
  }

  getRole(): UserRole {
    return this.role;
  }

  getStatus(): UserStatus {
    return this.status;
  }

  getCreatedAt(): Date {
    return this.createdAt;
  }
}

Aggregates

Order Aggregate

// domain/aggregates/order/order.ts

import { AggregateRoot } from '../../shared/aggregate-root';
import { OrderId } from './order-id';
import { OrderItem } from './order-item';
import { OrderStatus } from './order-status';
import { Money } from '../../value-objects/money';
import { UserId } from '../../value-objects/user-id';
import { OrderCreatedEvent } from '../../events/order-created';
import { OrderCompletedEvent } from '../../events/order-completed';

interface OrderProps {
  userId: UserId;
  items: OrderItem[];
  status: OrderStatus;
  shippingAddress: Address;
  createdAt: Date;
  updatedAt: Date;
}

export class Order extends AggregateRoot<OrderId> {
  private userId: UserId;
  private items: OrderItem[];
  private status: OrderStatus;
  private shippingAddress: Address;
  private readonly createdAt: Date;
  private updatedAt: Date;

  private constructor(id: OrderId, props: OrderProps) {
    super(id);
    this.userId = props.userId;
    this.items = props.items;
    this.status = props.status;
    this.shippingAddress = props.shippingAddress;
    this.createdAt = props.createdAt;
    this.updatedAt = props.updatedAt;
  }

  static create(userId: UserId, shippingAddress: Address): Order {
    const id = OrderId.create();
    const now = new Date();

    const order = new Order(id, {
      userId,
      items: [],
      status: OrderStatus.DRAFT,
      shippingAddress,
      createdAt: now,
      updatedAt: now,
    });

    return order;
  }

  // Add item
  addItem(productId: ProductId, quantity: number, unitPrice: Money): void {
    this.assertCanModify();

    const existingItem = this.items.find(
      item => item.getProductId().equals(productId)
    );

    if (existingItem) {
      existingItem.increaseQuantity(quantity);
    } else {
      this.items.push(
        OrderItem.create(productId, quantity, unitPrice)
      );
    }

    this.updatedAt = new Date();
  }

  // Remove item
  removeItem(productId: ProductId): void {
    this.assertCanModify();

    const index = this.items.findIndex(
      item => item.getProductId().equals(productId)
    );

    if (index === -1) {
      throw new Error('Item not found');
    }

    this.items.splice(index, 1);
    this.updatedAt = new Date();
  }

  // Place order
  place(): void {
    if (this.items.length === 0) {
      throw new Error('Cannot place order with no items');
    }

    if (this.status !== OrderStatus.DRAFT) {
      throw new Error('Order is not in draft status');
    }

    this.status = OrderStatus.PLACED;
    this.updatedAt = new Date();

    this.addDomainEvent(new OrderCreatedEvent(
      this.id,
      this.userId,
      this.calculateTotal()
    ));
  }

  // Mark as paid
  markAsPaid(): void {
    if (this.status !== OrderStatus.PLACED) {
      throw new Error('Order is not placed');
    }

    this.status = OrderStatus.PAID;
    this.updatedAt = new Date();
  }

  // Ship
  ship(): void {
    if (this.status !== OrderStatus.PAID) {
      throw new Error('Order is not paid');
    }

    this.status = OrderStatus.SHIPPED;
    this.updatedAt = new Date();
  }

  // Complete delivery
  complete(): void {
    if (this.status !== OrderStatus.SHIPPED) {
      throw new Error('Order is not shipped');
    }

    this.status = OrderStatus.COMPLETED;
    this.updatedAt = new Date();

    this.addDomainEvent(new OrderCompletedEvent(this.id));
  }

  // Cancel
  cancel(): void {
    if (this.status === OrderStatus.SHIPPED ||
        this.status === OrderStatus.COMPLETED) {
      throw new Error('Cannot cancel shipped or completed order');
    }

    this.status = OrderStatus.CANCELLED;
    this.updatedAt = new Date();
  }

  // Calculate total
  calculateTotal(): Money {
    return this.items.reduce(
      (total, item) => total.add(item.calculateSubtotal()),
      Money.zero('JPY')
    );
  }

  private assertCanModify(): void {
    if (this.status !== OrderStatus.DRAFT) {
      throw new Error('Cannot modify non-draft order');
    }
  }

  // Getters
  getUserId(): UserId {
    return this.userId;
  }

  getItems(): ReadonlyArray<OrderItem> {
    return [...this.items];
  }

  getStatus(): OrderStatus {
    return this.status;
  }

  getShippingAddress(): Address {
    return this.shippingAddress;
  }
}

// order-item.ts (entity within aggregate)
export class OrderItem {
  private constructor(
    private readonly productId: ProductId,
    private quantity: number,
    private readonly unitPrice: Money
  ) {}

  static create(
    productId: ProductId,
    quantity: number,
    unitPrice: Money
  ): OrderItem {
    if (quantity <= 0) {
      throw new Error('Quantity must be positive');
    }
    return new OrderItem(productId, quantity, unitPrice);
  }

  increaseQuantity(amount: number): void {
    if (amount <= 0) {
      throw new Error('Amount must be positive');
    }
    this.quantity += amount;
  }

  calculateSubtotal(): Money {
    return this.unitPrice.multiply(this.quantity);
  }

  getProductId(): ProductId {
    return this.productId;
  }

  getQuantity(): number {
    return this.quantity;
  }

  getUnitPrice(): Money {
    return this.unitPrice;
  }
}

// order-status.ts (value object)
export enum OrderStatus {
  DRAFT = 'DRAFT',
  PLACED = 'PLACED',
  PAID = 'PAID',
  SHIPPED = 'SHIPPED',
  COMPLETED = 'COMPLETED',
  CANCELLED = 'CANCELLED',
}

Repositories

Repository Interface

// domain/repositories/order-repository.ts

export interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: OrderId): Promise<Order | null>;
  findByUserId(userId: UserId): Promise<Order[]>;
  delete(id: OrderId): Promise<void>;
  nextId(): OrderId;
}

// domain/repositories/user-repository.ts
export interface UserRepository {
  save(user: User): Promise<void>;
  findById(id: UserId): Promise<User | null>;
  findByEmail(email: Email): Promise<User | null>;
  exists(email: Email): Promise<boolean>;
  delete(id: UserId): Promise<void>;
}

Repository Implementation

// infrastructure/repositories/prisma-order-repository.ts

import { PrismaClient } from '@prisma/client';
import { OrderRepository } from '../../domain/repositories/order-repository';
import { Order } from '../../domain/aggregates/order/order';
import { OrderId } from '../../domain/aggregates/order/order-id';
import { OrderMapper } from '../mappers/order-mapper';

export class PrismaOrderRepository implements OrderRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async save(order: Order): Promise<void> {
    const data = OrderMapper.toPersistence(order);

    await this.prisma.order.upsert({
      where: { id: data.id },
      create: {
        id: data.id,
        userId: data.userId,
        status: data.status,
        shippingAddress: data.shippingAddress,
        createdAt: data.createdAt,
        updatedAt: data.updatedAt,
        items: {
          create: data.items.map(item => ({
            productId: item.productId,
            quantity: item.quantity,
            unitPrice: item.unitPrice,
          })),
        },
      },
      update: {
        status: data.status,
        updatedAt: data.updatedAt,
        items: {
          deleteMany: {},
          create: data.items.map(item => ({
            productId: item.productId,
            quantity: item.quantity,
            unitPrice: item.unitPrice,
          })),
        },
      },
    });
  }

  async findById(id: OrderId): Promise<Order | null> {
    const data = await this.prisma.order.findUnique({
      where: { id: id.getValue() },
      include: { items: true },
    });

    if (!data) return null;

    return OrderMapper.toDomain(data);
  }

  async findByUserId(userId: UserId): Promise<Order[]> {
    const orders = await this.prisma.order.findMany({
      where: { userId: userId.getValue() },
      include: { items: true },
      orderBy: { createdAt: 'desc' },
    });

    return orders.map(OrderMapper.toDomain);
  }

  async delete(id: OrderId): Promise<void> {
    await this.prisma.order.delete({
      where: { id: id.getValue() },
    });
  }

  nextId(): OrderId {
    return OrderId.create();
  }
}

Domain Services

// domain/services/order-domain-service.ts

export class OrderDomainService {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly productRepository: ProductRepository,
    private readonly inventoryService: InventoryService
  ) {}

  async canPlaceOrder(order: Order): Promise<boolean> {
    // Check inventory
    for (const item of order.getItems()) {
      const available = await this.inventoryService.checkAvailability(
        item.getProductId(),
        item.getQuantity()
      );

      if (!available) {
        return false;
      }
    }

    return true;
  }

  async calculateShippingFee(order: Order): Promise<Money> {
    const total = order.calculateTotal();
    const address = order.getShippingAddress();

    // Free shipping threshold
    if (total.isGreaterThan(Money.create(100, 'USD'))) {
      return Money.zero('USD');
    }

    // Region-based shipping
    const baseFee = this.getBaseFeeByRegion(address.getState());
    return baseFee;
  }

  private getBaseFeeByRegion(state: string): Money {
    const alaska = ['Alaska'];
    const hawaii = ['Hawaii'];

    if (alaska.includes(state)) {
      return Money.create(15, 'USD');
    }
    if (hawaii.includes(state)) {
      return Money.create(20, 'USD');
    }
    return Money.create(8, 'USD');
  }
}

Application Services

// application/services/order-application-service.ts

export class OrderApplicationService {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly userRepository: UserRepository,
    private readonly orderDomainService: OrderDomainService,
    private readonly eventPublisher: DomainEventPublisher,
    private readonly unitOfWork: UnitOfWork
  ) {}

  async createOrder(command: CreateOrderCommand): Promise<OrderDto> {
    return this.unitOfWork.execute(async () => {
      // Verify user exists
      const user = await this.userRepository.findById(
        UserId.create(command.userId)
      );

      if (!user) {
        throw new UserNotFoundError(command.userId);
      }

      // Create order
      const order = Order.create(
        user.id,
        Address.create(command.shippingAddress)
      );

      // Add items
      for (const item of command.items) {
        order.addItem(
          ProductId.create(item.productId),
          item.quantity,
          Money.create(item.unitPrice, 'USD')
        );
      }

      // Check if order can be placed
      const canPlace = await this.orderDomainService.canPlaceOrder(order);
      if (!canPlace) {
        throw new InsufficientInventoryError();
      }

      // Place order
      order.place();

      // Save
      await this.orderRepository.save(order);

      // Publish domain events
      await this.eventPublisher.publishAll(order.pullDomainEvents());

      return OrderDto.fromDomain(order);
    });
  }

  async getOrder(orderId: string): Promise<OrderDto | null> {
    const order = await this.orderRepository.findById(
      OrderId.create(orderId)
    );

    if (!order) return null;

    return OrderDto.fromDomain(order);
  }

  async cancelOrder(orderId: string): Promise<void> {
    return this.unitOfWork.execute(async () => {
      const order = await this.orderRepository.findById(
        OrderId.create(orderId)
      );

      if (!order) {
        throw new OrderNotFoundError(orderId);
      }

      order.cancel();

      await this.orderRepository.save(order);
      await this.eventPublisher.publishAll(order.pullDomainEvents());
    });
  }
}

Summary

DDD is most effective in projects dealing with complex business logic.

Tactical Pattern Selection

PatternUse Case
Value ObjectImmutable values, encapsulating rules
EntityObject with identity
AggregateConsistency boundary, transaction unit
RepositoryPersistence abstraction
Domain ServiceLogic not belonging to entities

Key Points for Adoption

  1. Ubiquitous Language: Create a shared language with domain experts
  2. Bounded Context: Partition the system at appropriate granularity
  3. Gradual Adoption: Start with the core domain
  4. Testing: Unit test domain logic

DDD has a high learning cost, but when applied correctly, it enables building highly maintainable systems.

References

← Back to list