TypeScriptでDDDを実装する意義
TypeScriptは、JavaやC#のようなクラスベースのOOP機能と、関数型プログラミングの要素を兼ね備えた言語です。 その構造的型付け(Structural Typing)と強力な型推論により、DDDのパターンを型レベルで表現できます。
この章では、DDDの主要なビルディングブロックをTypeScriptで実装する具体的な方法を学びます。 単にコードを書くだけでなく、型システムを活用してドメインルールをコンパイル時に強制するテクニックに焦点を当てます。
Value Objectの実装
Value Object(値オブジェクト)は、不変で属性値のみで等価性が判断されるオブジェクトです。 TypeScriptでは大きく2つのアプローチがあります。
アプローチ1: クラスベース(Object.freeze + equals)
伝統的なDDD実装スタイルです。Object.freezeで不変性を保証し、equalsメソッドで等価性を判定します。
// 値オブジェクトの基底クラス
abstract class ValueObject<T extends Record<string, unknown>> {
protected readonly props: Readonly<T>;
protected constructor(props: T) {
this.props = Object.freeze({ ...props });
}
equals(other: ValueObject<T>): boolean {
if (other === null || other === undefined) return false;
return JSON.stringify(this.props) === JSON.stringify(other.props);
}
}
// 具体的な値オブジェクト: Money
class Money extends ValueObject<{ amount: number; currency: string }> {
private constructor(amount: number, currency: string) {
super({ amount, currency });
}
// ファクトリメソッドでバリデーション
static create(amount: number, currency: string): Money {
if (amount < 0) throw new Error('金額は0以上');
if (!['JPY', 'USD', 'EUR'].includes(currency)) {
throw new Error(`未対応通貨: ${currency}`);
}
return new Money(amount, currency);
}
get amount(): number { return this.props.amount; }
get currency(): string { return this.props.currency; }
// 副作用なし: 新しいインスタンスを返す
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('異なる通貨は加算できません');
}
return Money.create(this.amount + other.amount, this.currency);
}
} アプローチ2: Branded Types(ランタイムコストゼロ)
TypeScript独自のテクニックで、型レベルでのみ区別される「ブランド」をプリミティブ型に付与します。
ランタイムでは通常のstringやnumberと同じで、パフォーマンスの低下がありません。
// Branded Typeの定義
type Brand<T, B extends string> = T & { readonly __brand: B };
// 値オブジェクトをBranded Typeで表現
type Email = Brand<string, 'Email'>;
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
// コンストラクタ関数でバリデーション
function createEmail(value: string): Email {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
throw new Error(`無効なメールアドレス: ${value}`);
}
return value as Email;
}
function createUserId(value: string): UserId {
if (!value || value.length === 0) {
throw new Error('UserIdは空にできません');
}
return value as UserId;
}
// 型安全: UserIdとOrderIdを混同するとコンパイルエラー
function findUser(id: UserId): User { /* ... */ }
const userId = createUserId('user-123');
const orderId = 'order-456' as OrderId;
findUser(userId); // OK
findUser(orderId); // コンパイルエラー!型 'OrderId' を型 'UserId' に割り当てられません | 観点 | クラスベース | Branded Types |
|---|---|---|
| ランタイムコスト | Object.freeze分のオーバーヘッドあり | ゼロ(型情報は消去される) |
| 等価性判定 | equalsメソッドで明示的に比較 | ===演算子で比較可能(プリミティブ) |
| メソッド追加 | add, formatなどのメソッドを定義可能 | スタンドアロン関数として定義 |
| バリデーション | ファクトリメソッド内で実行 | コンストラクタ関数内で実行 |
| 適用場面 | 振る舞いを持つ複合的な値(Money等) | ID類、メールアドレス等の単純なラッパー |
Entityの実装
Entity(エンティティ)は、IDベースで同一性が判定され、ライフサイクルを通じて状態が変化するオブジェクトです。 TypeScriptではprivateコンストラクタ + ファクトリメソッドパターンを用いて、常に有効な状態で生成されることを保証します。
// エンティティの基底クラス
abstract class Entity<TId> {
protected constructor(
private readonly _id: TId
) {}
get id(): TId { return this._id; }
// IDベースの等価性判定
equals(other: Entity<TId>): boolean {
if (other === null || other === undefined) return false;
return this._id === other._id;
}
}
// 具体的なエンティティ: User
type UserStatus = 'active' | 'suspended' | 'deleted';
class User extends Entity<UserId> {
private _name: string;
private _email: Email;
private _status: UserStatus;
// privateコンストラクタ: 外部からのnewを禁止
private constructor(
id: UserId,
name: string,
email: Email,
status: UserStatus
) {
super(id);
this._name = name;
this._email = email;
this._status = status;
}
// ファクトリメソッド: バリデーション付き
static create(params: {
id: UserId;
name: string;
email: Email;
}): User {
if (params.name.length < 1 || params.name.length > 50) {
throw new Error('名前は1〜50文字');
}
return new User(params.id, params.name, params.email, 'active');
}
// ドメインロジックをエンティティ内に保持
suspend(reason: string): void {
if (this._status !== 'active') {
throw new Error('アクティブなユーザーのみ停止できます');
}
this._status = 'suspended';
}
reactivate(): void {
if (this._status !== 'suspended') {
throw new Error('停止中のユーザーのみ再開できます');
}
this._status = 'active';
}
} Aggregate Rootの実装
Aggregate Root(集約ルート)は、トランザクション境界を定義するエンティティです。 TypeScriptでの実装では、ドメインイベントの管理を集約ルートの責務として組み込みます。
classDiagram
class AggregateRoot~TId~ {
-id: TId
-domainEvents: DomainEvent[]
+addDomainEvent(event)
+clearDomainEvents()
+getDomainEvents()
}
class Order {
-items: OrderItem[]
-status: OrderStatus
-shippingAddress: Address
+addItem(productId, quantity, price)
+confirm()
+cancel(reason)
}
class OrderItem {
-productId: ProductId
-quantity: Quantity
-unitPrice: Money
}
class Address {
-street: string
-city: string
-postalCode: string
+equals(other)
}
AggregateRoot <|-- Order
Order *-- OrderItem
Order *-- Address
note for Order "集約ルート:\n外部からのアクセスポイント"
note for OrderItem "エンティティ:\n集約内部でのみ操作"
note for Address "値オブジェクト:\n不変"// 集約ルートの基底クラス: ドメインイベント管理機能付き
abstract class AggregateRoot<TId> extends Entity<TId> {
private _domainEvents: IDomainEvent[] = [];
protected addDomainEvent(event: IDomainEvent): void {
this._domainEvents.push(event);
}
getDomainEvents(): readonly IDomainEvent[] {
return [...this._domainEvents];
}
clearDomainEvents(): void {
this._domainEvents = [];
}
}
// Order集約ルート
class Order extends AggregateRoot<OrderId> {
private _items: OrderItem[] = [];
private _status: OrderStatus = 'draft';
private _shippingAddress: Address;
private constructor(
id: OrderId,
customerId: CustomerId,
shippingAddress: Address
) {
super(id);
this._shippingAddress = shippingAddress;
}
static create(params: {
id: OrderId;
customerId: CustomerId;
shippingAddress: Address;
}): Order {
const order = new Order(
params.id,
params.customerId,
params.shippingAddress
);
// 生成時にドメインイベントを発行
order.addDomainEvent(new OrderCreated({
orderId: params.id,
customerId: params.customerId,
createdAt: new Date(),
}));
return order;
}
addItem(productId: ProductId, quantity: Quantity, unitPrice: Money): void {
if (this._status !== 'draft') {
throw new Error('下書き状態でのみ商品を追加できます');
}
this._items.push(new OrderItem(productId, quantity, unitPrice));
}
confirm(): void {
if (this._items.length === 0) {
throw new Error('商品がない注文は確定できません');
}
this._status = 'confirmed';
this.addDomainEvent(new OrderConfirmed({
orderId: this.id,
totalAmount: this.calculateTotal(),
confirmedAt: new Date(),
}));
}
private calculateTotal(): Money {
return this._items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.create(0, 'JPY')
);
}
} Domain Eventの実装
Domain Event(ドメインイベント)は、ドメインで起きた重要な出来事を表す不変のオブジェクトです。
TypeScriptではIDomainEventインターフェースを定義し、中央ディスパッチャで配信します。
// ドメインイベントのインターフェース
interface IDomainEvent {
readonly eventId: string;
readonly occurredOn: Date;
readonly eventType: string;
}
// 具体的なドメインイベント
class OrderConfirmed implements IDomainEvent {
readonly eventId: string;
readonly occurredOn: Date;
readonly eventType = 'OrderConfirmed';
constructor(
readonly payload: {
orderId: OrderId;
totalAmount: Money;
confirmedAt: Date;
}
) {
this.eventId = crypto.randomUUID();
this.occurredOn = new Date();
}
}
// 中央ディスパッチャ
class DomainEventDispatcher {
private handlers = new Map<string, Array<(event: IDomainEvent) => void>>();
register<T extends IDomainEvent>(
eventType: string,
handler: (event: T) => void
): void {
const existing = this.handlers.get(eventType) ?? [];
existing.push(handler as (event: IDomainEvent) => void);
this.handlers.set(eventType, existing);
}
dispatch(events: IDomainEvent[]): void {
for (const event of events) {
const handlers = this.handlers.get(event.eventType) ?? [];
handlers.forEach(handler => handler(event));
}
}
} Repositoryの実装
Repository(リポジトリ)はドメイン層にインターフェースを定義し、インフラストラクチャ層に具象実装を配置します。 依存性逆転の原則(DIP)により、ドメイン層がインフラストラクチャの詳細に依存しません。
// ドメイン層: リポジトリインターフェース
// src/domain/repositories/IOrderRepository.ts
interface IOrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
delete(id: OrderId): Promise<void>;
nextId(): OrderId;
}
// インフラストラクチャ層: 具象実装
// src/infrastructure/repositories/PrismaOrderRepository.ts
class PrismaOrderRepository implements IOrderRepository {
constructor(private readonly prisma: PrismaClient) {}
async findById(id: OrderId): Promise<Order | null> {
const record = await this.prisma.order.findUnique({
where: { id },
include: { items: true },
});
if (!record) return null;
return OrderMapper.toDomain(record);
}
async save(order: Order): Promise<void> {
const data = OrderMapper.toPersistence(order);
await this.prisma.order.upsert({
where: { id: order.id },
create: data,
update: data,
});
// 永続化成功後にドメインイベントをディスパッチ
this.dispatcher.dispatch(order.getDomainEvents());
order.clearDomainEvents();
}
nextId(): OrderId {
return createOrderId(crypto.randomUUID());
}
} TypeScript型システムの高度な活用
Branded Types — IDの型安全性
前述のBranded Typesをさらに発展させ、unique symbolを使ったより厳密な実装を見てみましょう。
// unique symbolによるBranded Types(より厳密)
declare const UserIdBrand: unique symbol;
declare const OrderIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: never };
type OrderId = string & { readonly [OrderIdBrand]: never };
// この方法ではasによるキャストも型安全
function createUserId(value: string): UserId {
return value as UserId;
}
// UserIdとOrderIdは絶対に混同できない
function getUser(id: UserId): User { /* ... */ }
function getOrder(id: OrderId): Order { /* ... */ }
const userId = createUserId('u-1');
const orderId = 'o-1' as OrderId;
getUser(userId); // OK
getUser(orderId); // コンパイルエラー
getUser('raw-str'); // コンパイルエラー(生のstringも不可) Discriminated Unions — 状態遷移の型表現
TypeScriptのDiscriminated Unions(判別可能なユニオン型)は、 エンティティの状態遷移を型レベルで表現する強力なツールです。 不正な状態遷移をコンパイル時に防止できます。
// 注文の状態をDiscriminated Unionsで表現
type DraftOrder = {
readonly status: 'draft';
readonly id: OrderId;
readonly items: OrderItem[];
// 下書き状態でのみ商品追加が可能
addItem(item: OrderItem): DraftOrder;
confirm(): ConfirmedOrder;
};
type ConfirmedOrder = {
readonly status: 'confirmed';
readonly id: OrderId;
readonly items: readonly OrderItem[];
readonly confirmedAt: Date;
// 確定後は商品追加不可 → addItemメソッドが存在しない
ship(trackingNumber: string): ShippedOrder;
cancel(reason: string): CancelledOrder;
};
type ShippedOrder = {
readonly status: 'shipped';
readonly id: OrderId;
readonly items: readonly OrderItem[];
readonly confirmedAt: Date;
readonly shippedAt: Date;
readonly trackingNumber: string;
// 出荷後はキャンセル不可 → cancelメソッドが存在しない
deliver(): DeliveredOrder;
};
type CancelledOrder = {
readonly status: 'cancelled';
readonly id: OrderId;
readonly cancelledAt: Date;
readonly reason: string;
// 最終状態: 操作メソッドなし
};
type DeliveredOrder = {
readonly status: 'delivered';
readonly id: OrderId;
readonly deliveredAt: Date;
// 最終状態: 操作メソッドなし
};
// ユニオン型で全状態を表現
type Order = DraftOrder | ConfirmedOrder | ShippedOrder
| CancelledOrder | DeliveredOrder;
// 型ガードで状態に応じた処理を安全に分岐
function processOrder(order: Order): void {
switch (order.status) {
case 'draft':
order.addItem(newItem); // OK: DraftOrderにのみ存在
break;
case 'confirmed':
order.ship('TRK-123'); // OK: ConfirmedOrderにのみ存在
break;
case 'shipped':
// order.cancel('理由'); // コンパイルエラー!ShippedOrderにcancelはない
order.deliver();
break;
}
} stateDiagram-v2
[*] --> Draft
Draft --> Confirmed: confirm()
Draft --> Cancelled: cancel()
Confirmed --> Shipped: ship()
Confirmed --> Cancelled: cancel()
Shipped --> Delivered: deliver()
Cancelled --> [*]
Delivered --> [*]
state Draft {
addItem() 可能
}
state Confirmed {
addItem() 不可
}
state Shipped {
cancel() 不可
}Mapped Types / Template Literal Types
TypeScriptのMapped TypesとTemplate Literal Typesを組み合わせると、 ドメインイベントのハンドラ型を自動生成できます。
// イベント名からハンドラ名を自動生成
type EventHandlerName<T extends string> = `on${Capitalize<T>}`;
// イベントマップの定義
interface OrderEvents {
orderCreated: { orderId: OrderId; createdAt: Date };
orderConfirmed: { orderId: OrderId; totalAmount: Money };
orderShipped: { orderId: OrderId; trackingNumber: string };
}
// Mapped Typesでハンドラ型を自動生成
type OrderEventHandlers = {
[K in keyof OrderEvents as EventHandlerName<K & string>]:
(event: OrderEvents[K]) => void;
};
// 生成される型:
// {
// onOrderCreated: (event: { orderId: OrderId; createdAt: Date }) => void;
// onOrderConfirmed: (event: { orderId: OrderId; totalAmount: Money }) => void;
// onOrderShipped: (event: { orderId: OrderId; trackingNumber: string }) => void;
// }
// 実装時にハンドラの漏れがコンパイルエラーで検出される
const handlers: OrderEventHandlers = {
onOrderCreated: (e) => console.log(`注文作成: ${e.orderId}`),
onOrderConfirmed: (e) => console.log(`注文確定: ${e.totalAmount}`),
onOrderShipped: (e) => console.log(`出荷: ${e.trackingNumber}`),
// 1つでも欠けるとコンパイルエラー
}; 関数型DDDアプローチ — Deciderパターン
近年注目されているDeciderパターンは、Jérémie Chassaingが提唱した関数型DDDのアプローチです。 集約を「コマンドを受け取り、現在の状態に基づいてイベントを返す純粋関数」として表現します。
// Deciderパターンの型定義
interface Decider<Command, Event, State> {
decide: (command: Command, state: State) => Event[];
evolve: (state: State, event: Event) => State;
initialState: State;
}
// 注文集約をDeciderパターンで実装
type OrderCommand =
| { type: 'CreateOrder'; orderId: string; customerId: string }
| { type: 'AddItem'; productId: string; quantity: number; price: number }
| { type: 'ConfirmOrder' }
| { type: 'CancelOrder'; reason: string };
type OrderEvent =
| { type: 'OrderCreated'; orderId: string; customerId: string }
| { type: 'ItemAdded'; productId: string; quantity: number; price: number }
| { type: 'OrderConfirmed'; totalAmount: number }
| { type: 'OrderCancelled'; reason: string };
type OrderState = {
exists: boolean;
status: 'draft' | 'confirmed' | 'cancelled';
items: Array<{ productId: string; quantity: number; price: number }>;
};
const orderDecider: Decider<OrderCommand, OrderEvent, OrderState> = {
initialState: { exists: false, status: 'draft', items: [] },
// 純粋関数: 副作用なし、テストが容易
decide: (command, state) => {
switch (command.type) {
case 'CreateOrder':
if (state.exists) return []; // 冪等性
return [{ type: 'OrderCreated', orderId: command.orderId,
customerId: command.customerId }];
case 'AddItem':
if (state.status !== 'draft') return [];
return [{ type: 'ItemAdded', productId: command.productId,
quantity: command.quantity, price: command.price }];
case 'ConfirmOrder':
if (state.items.length === 0) return [];
const total = state.items.reduce((s, i) => s + i.price * i.quantity, 0);
return [{ type: 'OrderConfirmed', totalAmount: total }];
case 'CancelOrder':
if (state.status === 'cancelled') return [];
return [{ type: 'OrderCancelled', reason: command.reason }];
}
},
// 状態遷移: イベントを状態に適用
evolve: (state, event) => {
switch (event.type) {
case 'OrderCreated':
return { ...state, exists: true, status: 'draft' };
case 'ItemAdded':
return { ...state, items: [...state.items,
{ productId: event.productId, quantity: event.quantity,
price: event.price }] };
case 'OrderConfirmed':
return { ...state, status: 'confirmed' };
case 'OrderCancelled':
return { ...state, status: 'cancelled' };
}
},
};
// テスト: 純粋関数なのでモック不要
const state = orderDecider.initialState;
const events = orderDecider.decide(
{ type: 'CreateOrder', orderId: 'o-1', customerId: 'c-1' },
state
);
console.log(events);
// [{ type: 'OrderCreated', orderId: 'o-1', customerId: 'c-1' }] TypeScript DDDの課題
TypeScriptでDDDを実装する際には、言語の特性から生じるいくつかの課題があります。 これらを認識した上で、適切なトレードオフを選択することが重要です。
| 課題 | 詳細 | 対処法 |
|---|---|---|
| 構造的型付けの限界 | TypeScriptは構造的型付けのため、同じ構造を持つ異なるValue Objectが互換になる | Branded Typesで名目的型付けを模倣する |
| ボイラープレートの多さ | Value Object・Entity・Aggregate毎にファクトリ、equals、バリデーションが必要 | 基底クラスやヘルパー関数で共通化する |
| ランタイムバリデーション | 型はコンパイル時のみ有効で、APIからの入力はランタイムチェックが必須 | zodやvalibotで入力バリデーション、ドメイン層はBranded Typesで保護 |
| privateの不完全さ | TypeScriptのprivateはコンパイル時のみ。ランタイムではアクセス可能 | ES2022の#privateフィールドを使用する |
| イミュータビリティの限界 | Object.freezeはshallow freeze。ネストしたオブジェクトは変更可能 | Readonly |
理解度チェック
Branded Typesの最大の利点は何ですか?
キーボード: 1〜4 で選択、Enter で回答