本文 AI 產出,尚未審核

TypeScript 實務開發與架構應用

主題:Domain 型別設計(DDD 思維)


簡介

在大型系統開發中,業務概念的清晰表達是維持可維護性與可擴充性的關鍵。Domain‑Driven Design(DDD)主張以 「領域」 為核心,將業務規則、概念與行為封裝在模型裡,而不是散落在服務層或資料庫 CRUD 程式碼中。

使用 TypeScript 進行 Domain 型別設計,不僅可以利用靜態類型的保護,還能在編譯階段捕捉業務錯誤,讓開發者在寫程式時即得到 「這個值是否合法」 的即時回饋。本文將從概念說明、實作範例、常見陷阱到最佳實踐,完整展示如何在 TypeScript 中落實 DDD 思維,協助你在實務專案中建立乾淨、可測試的領域模型。


核心概念

1. 什麼是 Domain 型別?

Domain 型別(Domain Model)是 業務概念的程式化表示,它應該具備以下特性:

特性 說明
語意完整 型別名稱與屬性、方法直接對應業務語言(Ubiquitous Language)。
封裝行為 僅透過方法改變內部狀態,避免外部直接修改屬性。
不可變性 大多數情況下,Domain 物件是 不可變(immutable),變更時返回新實例。
值物件 vs. 實體 值物件(Value Object)只關心屬性相等,實體(Entity)則有唯一標識(ID)。

2. 值物件(Value Object)

值物件代表 沒有唯一識別碼、只關心「值」的概念,例如金額、地址、電子郵件等。它們通常是 不可變,相等性依賴於所有屬性。

範例 1:Email 值物件

// Email.ts
export class Email {
  private readonly _value: string;

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

  /** 建立 Email,若格式不正確則拋出例外 */
  static create(value: string): Email {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(value)) {
      throw new Error(`Invalid email format: ${value}`);
    }
    return new Email(value);
  }

  /** 取得原始字串 */
  get value(): string {
    return this._value;
  }

  /** 值物件相等性比較 */
  equals(other: Email): boolean {
    return this._value === other._value;
  }
}

重點:建構子被 private 隱藏,外部只能透過 create 方法建立,確保所有 Email 都已驗證過。


3. 實體(Entity)

實體擁有 唯一標識(ID),即使屬性變化,其身份仍保持不變。實體通常會包裝多個值物件或其他實體。

範例 2:User 實體

// User.ts
import { Email } from "./Email";

export class User {
  /** 唯一識別碼,使用 UUID */
  readonly id: string;
  private _email: Email;
  private _name: string;

  private constructor(id: string, email: Email, name: string) {
    this.id = id;
    this._email = email;
    this._name = name;
  }

  /** 建立新使用者 */
  static create(id: string, email: string, name: string): User {
    const emailVO = Email.create(email);
    return new User(id, emailVO, name);
  }

  /** 取得 Email */
  get email(): Email {
    return this._email;
  }

  /** 變更 Email(返回新實例) */
  changeEmail(newEmail: string): User {
    const emailVO = Email.create(newEmail);
    return new User(this.id, emailVO, this._name);
  }

  /** 取得名稱 */
  get name(): string {
    return this._name;
  }

  /** 變更名稱(返回新實例) */
  rename(newName: string): User {
    return new User(this.id, this._email, newName);
  }
}

說明User 內部使用 Email 值物件,所有變更都透過方法返回 新實例,保持不可變性,方便測試與快照(snapshot)管理。


4. 聚合根(Aggregate Root)與領域服務(Domain Service)

  • 聚合根:聚合是一組相關的實體與值物件,聚合根是外部唯一可以直接存取的入口。
  • 領域服務:當行為不屬於單一實體或值物件時,抽離成服務。

範例 3:訂單聚合與領域服務

// Order.ts
import { OrderItem } from "./OrderItem";
import { Money } from "./Money";

export class Order {
  readonly id: string;
  private _items: OrderItem[];
  private _total: Money;

  private constructor(id: string, items: OrderItem[], total: Money) {
    this.id = id;
    this._items = items;
    this._total = total;
  }

  /** 建立訂單 */
  static create(id: string, items: OrderItem[]): Order {
    const total = items.reduce((sum, item) => sum.add(item.subTotal), Money.zero());
    return new Order(id, items, total);
  }

  /** 取得所有項目(只讀) */
  get items(): readonly OrderItem[] {
    return this._items;
  }

  /** 取得總金額 */
  get total(): Money {
    return this._total;
  }

  /** 新增項目,返回新實例 */
  addItem(item: OrderItem): Order {
    const newItems = [...this._items, item];
    const newTotal = this._total.add(item.subTotal);
    return new Order(this.id, newItems, newTotal);
  }
}
// OrderService.ts
import { Order } from "./Order";
import { OrderRepository } from "./OrderRepository";

export class OrderService {
  constructor(private readonly repo: OrderRepository) {}

  /** 透過聚合根完成下單流程 */
  async placeOrder(order: Order): Promise<void> {
    // 1. 檢查庫存(領域服務內部可呼叫其他服務或外部 API)
    // 2. 產生付款交易
    // 3. 永續化聚合根
    await this.repo.save(order);
    // 4. 發佈 Domain Event(略)
  }
}

重點Order 為聚合根,外部只能透過 OrderService 進行業務流程,避免散彈式修改聚合內部狀態。


5. 使用 TypeScript 型別系統強化領域模型

TypeScript 功能 在 DDD 中的應用
readonly 確保屬性不可被外部直接改寫,配合不可變物件策略。
private / protected 隱藏建構子或內部狀態,只允許透過意圖明確的方法變更。
enum 表示有限狀態(如訂單狀態 `Pending
union types 表現「可能的值」或「錯誤結果」 (Result<T, E>)。
generic 建構通用的值物件(如 Money<TCurrency>),保持類型安全。

範例 4:OrderStatus Enum 與狀態變更

// OrderStatus.ts
export enum OrderStatus {
  Pending = "PENDING",
  Paid = "PAID",
  Shipped = "SHIPPED",
  Cancelled = "CANCELLED",
}
// Order.ts(加入狀態)
import { OrderStatus } from "./OrderStatus";

export class Order {
  // ... 先前的屬性
  private _status: OrderStatus;

  private constructor(
    id: string,
    items: OrderItem[],
    total: Money,
    status: OrderStatus = OrderStatus.Pending
  ) {
    this.id = id;
    this._items = items;
    this._total = total;
    this._status = status;
  }

  get status(): OrderStatus {
    return this._status;
  }

  /** 付款成功後變更狀態 */
  markAsPaid(): Order {
    if (this._status !== OrderStatus.Pending) {
      throw new Error("Only pending orders can be marked as paid.");
    }
    return new Order(this.id, this._items, this._total, OrderStatus.Paid);
  }

  // 其他狀態變更同理...
}

常見陷阱與最佳實踐

陷阱 為什麼會發生 解決方案
把所有 DTO 直接當 Domain Model DTO 只負責資料傳輸,若直接使用會把基礎結構耦合到業務邏輯。 分層:Domain Model ↔️ Application Service ↔️ DTO,使用映射函式 (mapper) 轉換。
過度使用類別,忽略簡單型別 產生過多的樣板程式,減少可讀性。 值物件 可用 type + 工廠函式 取代;只有需要行為與驗證時才使用類別。
實體可變,導致不一致 多個地方同時改變同一實體,測試與除錯困難。 不可變實體:所有變更返回新實例,或使用 immer 等工具保證不可變。
聚合根外部直接操作子實體 破壞聚合的一致性邊界。 僅透過聚合根提供的方法修改子實體,例如 order.addItem()
忽略領域事件 失去跨界限的解耦機制。 在聚合根內部觸發 Domain Event,透過事件總線(如 EventEmitter)分發。

最佳實踐清單

  1. 使用 Ubiquitous Language:型別、屬性與方法名稱直接映射業務語言。
  2. 封裝驗證:所有驗證邏輯放在 工廠或值物件 中,避免在服務層重複檢查。
  3. 保持聚合邊界:聚合根是唯一可以被外部引用的入口,子實體保持私有。
  4. 採用不可變模式:變更返回新實例,讓快照與測試更簡單。
  5. 分層責任:Domain 層僅負責業務,Application 層負責協調,Infrastructure 層負責 I/O。
  6. 使用 TypeScript 的型別工具readonly, private, enum, union,讓編譯器幫你捕捉非法狀態。

實際應用場景

場景 1:電商平台的訂單流程

  1. 前端 送出訂單 DTO(CreateOrderDto)。
  2. Application Service 透過 OrderFactory 把 DTO 轉成 Order 聚合根,所有商品、金額、客戶資訊皆為值物件。
  3. Domain Service (OrderService) 先檢查庫存(呼叫 InventoryDomainService),若成功則呼叫 PaymentGateway 完成付款。
  4. Domain Event OrderPlaced 被發佈,通知物流、行銷等子系統。
  5. InfrastructureOrder 寫入資料庫,使用 OrderRepository 只接受聚合根,避免「半成品」寫入。

透過 Domain 型別設計,每一步的資料都已在類型層面保證正確,開發者在編寫服務時不必再手寫繁瑣的驗證程式。

堲景 2:金融系統的金額計算

  • 金額使用 Money 值物件,內建 幣別四捨五入加減乘除 方法。
  • 任何金額運算必須透過 Money,避免浮點數誤差。
  • Transaction 為實體,擁有唯一 transactionId,其狀態 (Pending | Completed | Reversed) 用 enum 表示。
  • 若需要跨帳戶轉帳,領域服務 TransferService 只接受 MoneyAccount 實體,保證「轉帳金額永遠不會小於 0」的業務規則在類型層面被強制。

總結

Domain 型別設計是 把業務規則寫進程式語言的型別系統,在 TypeScript 中,我們可以:

  • 利用 值物件 把驗證與不可變性封裝起來。
  • 實體聚合根 定義唯一身份與一致性邊界。
  • 透過 enum、readonly、private 等語法,讓編譯器成為第一道防線。
  • 把跨聚合的行為抽成 領域服務,保持模型的單一職責。

掌握這套思維後,開發者能在 編譯階段即捕捉大部分業務錯誤,減少後期除錯成本,同時讓程式碼更貼近業務語言,提升團隊溝通效率。未來在實作更大型、複雜的系統時,建議從 Domain 型別設計 入手,逐步將 DDD 的核心概念落實於 TypeScript 專案之中,打造既安全又易於演進的軟體架構。