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方法建立,確保所有
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內部使用
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)分發。 |
最佳實踐清單
- 使用 Ubiquitous Language:型別、屬性與方法名稱直接映射業務語言。
- 封裝驗證:所有驗證邏輯放在 工廠或值物件 中,避免在服務層重複檢查。
- 保持聚合邊界:聚合根是唯一可以被外部引用的入口,子實體保持私有。
- 採用不可變模式:變更返回新實例,讓快照與測試更簡單。
- 分層責任:Domain 層僅負責業務,Application 層負責協調,Infrastructure 層負責 I/O。
- 使用 TypeScript 的型別工具:
readonly,private,enum,union,讓編譯器幫你捕捉非法狀態。
實際應用場景
場景 1:電商平台的訂單流程
- 前端 送出訂單 DTO(
CreateOrderDto)。 - Application Service 透過
OrderFactory把 DTO 轉成Order聚合根,所有商品、金額、客戶資訊皆為值物件。 - Domain Service (
OrderService) 先檢查庫存(呼叫InventoryDomainService),若成功則呼叫PaymentGateway完成付款。 - Domain Event
OrderPlaced被發佈,通知物流、行銷等子系統。 - Infrastructure 把
Order寫入資料庫,使用OrderRepository只接受聚合根,避免「半成品」寫入。
透過 Domain 型別設計,每一步的資料都已在類型層面保證正確,開發者在編寫服務時不必再手寫繁瑣的驗證程式。
堲景 2:金融系統的金額計算
- 金額使用
Money值物件,內建 幣別、四捨五入、加減乘除 方法。 - 任何金額運算必須透過
Money,避免浮點數誤差。 Transaction為實體,擁有唯一transactionId,其狀態 (Pending | Completed | Reversed) 用enum表示。- 若需要跨帳戶轉帳,領域服務
TransferService只接受Money與Account實體,保證「轉帳金額永遠不會小於 0」的業務規則在類型層面被強制。
總結
Domain 型別設計是 把業務規則寫進程式語言的型別系統,在 TypeScript 中,我們可以:
- 利用 值物件 把驗證與不可變性封裝起來。
- 以 實體 與 聚合根 定義唯一身份與一致性邊界。
- 透過 enum、readonly、private 等語法,讓編譯器成為第一道防線。
- 把跨聚合的行為抽成 領域服務,保持模型的單一職責。
掌握這套思維後,開發者能在 編譯階段即捕捉大部分業務錯誤,減少後期除錯成本,同時讓程式碼更貼近業務語言,提升團隊溝通效率。未來在實作更大型、複雜的系統時,建議從 Domain 型別設計 入手,逐步將 DDD 的核心概念落實於 TypeScript 專案之中,打造既安全又易於演進的軟體架構。