layers

ドメイン駆動設計(DDD)実践ガイド - TypeScriptで学ぶ戦術的設計

2025.12.02

ドメイン駆動設計(DDD)は、複雑なビジネスロジックを持つソフトウェアを効果的に設計・実装するための手法です。本記事では、DDDの戦術的設計パターンをTypeScriptで実装する方法を解説します。

DDDの基本概念

戦略的設計と戦術的設計

flowchart TB
    subgraph Strategic["戦略的設計"]
        S1["ユビキタス言語<br/>(共通言語の定義)"]
        S2["境界づけられたコンテキスト<br/>(システムの分割)"]
        S3["コンテキストマップ<br/>(コンテキスト間の関係)"]
        S4["サブドメイン<br/>(コア/サポート/汎用)"]
    end

    subgraph Tactical["戦術的設計"]
        T1["エンティティ<br/>(識別子を持つオブジェクト)"]
        T2["値オブジェクト<br/>(不変の値)"]
        T3["集約<br/>(一貫性の境界)"]
        T4["リポジトリ<br/>(永続化の抽象化)"]
        T5["ドメインサービス<br/>(エンティティに属さないロジック)"]
        T6["ドメインイベント<br/>(状態変化の通知)"]
        T7["ファクトリ<br/>(複雑なオブジェクト生成)"]
    end

    Strategic --> Tactical

レイヤードアーキテクチャ

flowchart TB
    subgraph P["Presentation Layer"]
        P1["Controllers, API, UI"]
    end

    subgraph A["Application Layer"]
        A1["Use Cases, Application Services, DTOs"]
    end

    subgraph D["Domain Layer"]
        D1["Entities, Value Objects, Aggregates, Domain Services"]
    end

    subgraph I["Infrastructure Layer"]
        I1["Repositories, External Services, DB"]
    end

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

値オブジェクト

基本的な値オブジェクト

// 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;
  }
}

複合値オブジェクト

// 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('ja-JP', {
      style: 'currency',
      currency: this.currency,
    });
    return formatter.format(this.amount);
  }
}

住所値オブジェクト

// domain/value-objects/address.ts

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

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

  static create(props: AddressProps): Address {
    this.validatePostalCode(props.postalCode);
    this.validatePrefecture(props.prefecture);

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

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

  private static validatePrefecture(prefecture: string): void {
    const prefectures = [
      '北海道', '青森県', '岩手県', /* ... */
    ];
    if (!prefectures.includes(prefecture)) {
      throw new Error('Invalid prefecture');
    }
  }

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

  getPrefecture(): string {
    return this.prefecture;
  }

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

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

エンティティ

基底エンティティクラス

// 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;
  }
}

ユーザーエンティティ

// 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;
  }

  // ファクトリメソッド
  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,
    });

    // ドメインイベント発行
    user.addDomainEvent(new UserCreatedEvent(user.id, email));

    return user;
  }

  // 再構築メソッド(リポジトリから復元時)
  static reconstruct(id: UserId, props: UserProps): User {
    return new User(id, props);
  }

  // ビジネスロジック
  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';
  }

  // Getter
  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;
  }
}

集約

注文集約

// 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;
  }

  // 商品追加
  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();
  }

  // 商品削除
  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(): 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()
    ));
  }

  // 支払い完了
  markAsPaid(): void {
    if (this.status !== OrderStatus.PLACED) {
      throw new Error('Order is not placed');
    }

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

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

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

  // 配送完了
  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(): 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();
  }

  // 合計金額計算
  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');
    }
  }

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

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

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

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

// order-item.ts (集約内のエンティティ)
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 (値オブジェクト)
export enum OrderStatus {
  DRAFT = 'DRAFT',
  PLACED = 'PLACED',
  PAID = 'PAID',
  SHIPPED = 'SHIPPED',
  COMPLETED = 'COMPLETED',
  CANCELLED = 'CANCELLED',
}

リポジトリ

リポジトリインターフェース

// 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>;
}

リポジトリ実装

// 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/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> {
    // 在庫チェック
    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();

    // 送料無料の閾値
    if (total.isGreaterThan(Money.create(10000, 'JPY'))) {
      return Money.zero('JPY');
    }

    // 地域別送料
    const baseFee = this.getBaseFeeByRegion(address.getPrefecture());
    return baseFee;
  }

  private getBaseFeeByRegion(prefecture: string): Money {
    const hokkaido = ['北海道'];
    const okinawa = ['沖縄県'];

    if (hokkaido.includes(prefecture)) {
      return Money.create(1500, 'JPY');
    }
    if (okinawa.includes(prefecture)) {
      return Money.create(2000, 'JPY');
    }
    return Money.create(800, 'JPY');
  }
}

アプリケーションサービス

// 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 () => {
      // ユーザー存在確認
      const user = await this.userRepository.findById(
        UserId.create(command.userId)
      );

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

      // 注文作成
      const order = Order.create(
        user.id,
        Address.create(command.shippingAddress)
      );

      // 商品追加
      for (const item of command.items) {
        order.addItem(
          ProductId.create(item.productId),
          item.quantity,
          Money.create(item.unitPrice, 'JPY')
        );
      }

      // 注文確定可能かチェック
      const canPlace = await this.orderDomainService.canPlaceOrder(order);
      if (!canPlace) {
        throw new InsufficientInventoryError();
      }

      // 注文確定
      order.place();

      // 保存
      await this.orderRepository.save(order);

      // ドメインイベント発行
      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());
    });
  }
}

まとめ

DDDは、複雑なビジネスロジックを扱うプロジェクトで効果を発揮します。

戦術的パターンの選択

パターン用途
値オブジェクト不変の値、ルールのカプセル化
エンティティ識別子を持つオブジェクト
集約一貫性の境界、トランザクション単位
リポジトリ永続化の抽象化
ドメインサービスエンティティに属さないロジック

導入のポイント

  1. ユビキタス言語: ドメインエキスパートと共通言語を作る
  2. 境界づけられたコンテキスト: 適切な粒度でシステムを分割
  3. 段階的導入: コアドメインから始める
  4. テスト: ドメインロジックのユニットテスト

DDDは学習コストが高いですが、適切に適用することで保守性の高いシステムを構築できます。

参考リンク

← 一覧に戻る