関数型プログラミングとDomain-Driven Designの原則を組み合わせたTypeScript実装ガイド。fp-tsライブラリを使用し、純粋関数、不変性、型安全性を重視したアーキテクチャを提供します。
- 純粋なドメイン層: 副作用なしの純粋関数のみ
- 型安全性: ブランド型とZodによる実行時検証
- 関数合成: TaskEitherパターンによるエラーハンドリング
- 不変性: 全データ構造の不変性保証
npm run type-check # 型チェック(厳密モード)
npm run test # ドメインロジックテスト実行
npm run lint:functional # 関数型パターンのリント
npm run example # 実行可能サンプル
npm run build # 本番ビルド{
"name": "typescript-functional-ddd",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc --strict",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit --strict",
"lint:functional": "eslint --config .eslintrc.functional.js",
"example": "tsx src/example.ts"
},
"dependencies": {
"fp-ts": "^2.16.9",
"zod": "^3.22.4",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/uuid": "^9.0.7",
"typescript": "^5.3.0",
"tsx": "^4.6.0",
"vitest": "^1.0.0"
}
}src/
├── shared/
│ ├── types.ts # 共通型定義とエラー型
│ └── functional.ts # 関数型ユーティリティ
└── order/
├── domain/
│ ├── types.ts # Zodスキーマ + 状態遷移定義
│ └── functions.ts # 純粋ドメイン関数
├── application/
│ └── service.ts # TaskEitherサービス層
└── infrastructure/
└── repository.ts # 永続化実装
/**
* @concept 値オブジェクト - アイデンティティを持たないドメインオブジェクト
* @reasoning 属性によって定義され、不変性を保証
* @invariants 作成後は変更不可、等価性は値で判定
* Value Objectsの実装はZodのスキーマで実現する
*//**
* @concept エンティティ - アイデンティティを持つドメインオブジェクト
* @reasoning IDによる同一性、ライフサイクルを持つ
* @invariants IDは不変、状態変更は明示的メソッドのみ
* Entityの実装はZodのスキーマで実現する
*//**
* @concept 集約 - 整合性境界を持つエンティティ群
* @reasoning ビジネス不変条件の境界、トランザクション境界
* @invariants 外部からはルートエンティティ経由でのみアクセス
*//**
* @reasoning 状態遷移システムの設計原則
* 1. 代数データ型: discriminatedUnionで状態ごとの必要フィールドを明確化
* 2. 設定駆動: 状態遷移ルールをマップで管理
* 3. アクションビルダー: 型安全なアクション作成
* 4. 不正状態の排除: optionalフィールドを使わず型で整合性保証
*/
// 基本スキーマ定義
const MoneySchema = z.object({
amount: z.number().nonnegative().describe('非負の金額'),
currency: z.string().regex(/^[A-Z]{3}$/).describe('ISO 4217通貨コード')
});
const AddressSchema = z.object({
street: z.string().min(1).describe('住所(番地)'),
city: z.string().min(1).describe('市区町村'),
zipCode: z.string().regex(/^\d{3}-\d{4}$/).describe('郵便番号(XXX-XXXX形式)')
});
const OrderItemSchema = z.object({
productId: z.string().min(1).describe('商品ID'),
quantity: z.number().int().positive().max(100).describe('数量(1-100)'),
unitPrice: MoneySchema.describe('単価')
});/**
* @description 注文の状態別スキーマ定義
* @reasoning 各状態で必要なフィールドのみを定義、不正状態を型で排除
* @verification
* - Q: なぜoptionalフィールドを使わないのか?
* A: 状態によって必須/不要なフィールドが明確になり、型安全性が向上
* - Q: 新しい状態を追加するには?
* A: discriminatedUnionに新しい状態スキーマを追加
*/
const BaseOrderSchema = z.object({
id: z.string().uuid().describe('注文ID'),
customerId: z.string().uuid().describe('顧客ID'),
items: z.array(OrderItemSchema).min(1).max(10).describe('注文商品リスト'),
totalAmount: MoneySchema.describe('合計金額'),
createdAt: z.date().describe('作成日時'),
updatedAt: z.date().describe('更新日時')
});
export const OrderSchema = z.discriminatedUnion('status', [
// ドラフト状態(作成直後)
BaseOrderSchema.extend({
status: z.literal('draft').describe('ドラフト状態')
}),
// 確定状態(決済・配送先確定済み)
BaseOrderSchema.extend({
status: z.literal('confirmed').describe('確定状態'),
paymentId: z.string().describe('決済ID'),
shippingAddress: AddressSchema.describe('配送先住所'),
confirmedAt: z.date().describe('確定日時')
}),
// 配送中状態
BaseOrderSchema.extend({
status: z.literal('shipped').describe('配送中状態'),
paymentId: z.string().describe('決済ID'),
shippingAddress: AddressSchema.describe('配送先住所'),
confirmedAt: z.date().describe('確定日時'),
trackingNumber: z.string().describe('追跡番号'),
shippedAt: z.date().describe('発送日時')
}),
// キャンセル状態
BaseOrderSchema.extend({
status: z.literal('cancelled').describe('キャンセル状態'),
cancelReason: z.enum(['customer_request', 'payment_failed', 'out_of_stock']).describe('キャンセル理由'),
cancelDetails: z.string().describe('キャンセル詳細'),
cancelledAt: z.date().describe('キャンセル日時'),
refundAmount: z.number().nonnegative().describe('返金額')
})
]);/**
* @description 状態遷移アクションの定義
* @reasoning 型安全なアクション作成、バリデーション自動化
*/
const ConfirmActionSchema = z.object({
type: z.literal('CONFIRM'),
payload: z.object({
paymentId: z.string().describe('決済システムからのID'),
shippingAddress: AddressSchema.describe('配送先住所')
})
});
const ShipActionSchema = z.object({
type: z.literal('SHIP'),
payload: z.object({
trackingNumber: z.string().describe('配送業者の追跡番号')
})
});
const CancelActionSchema = z.object({
type: z.literal('CANCEL'),
payload: z.object({
reason: z.enum(['customer_request', 'payment_failed', 'out_of_stock']).describe('キャンセル理由'),
details: z.string().min(5).describe('詳細説明(5文字以上)'),
refundAmount: z.number().nonnegative().describe('返金額')
})
});
export const OrderActionSchema = z.union([ConfirmActionSchema, ShipActionSchema, CancelActionSchema]);
// 型推論
export type Order = z.infer<typeof OrderSchema>;
export type OrderAction = z.infer<typeof OrderActionSchema>;
/**
* @description 状態遷移マップ(設定駆動)
* @reasoning 遷移ルールの一元管理、新しい状態・遷移の追加容易性
*/
export const TRANSITION_MAP = {
draft: { CONFIRM: 'confirmed', CANCEL: 'cancelled' },
confirmed: { SHIP: 'shipped', CANCEL: 'cancelled' },
shipped: { CANCEL: 'cancelled' },
cancelled: {}
} as const;/**
* @description 注文集約の作成
* @reasoning
* 1. 入力の検証(顧客ID、商品リスト)
* 2. 合計金額の計算(ビジネスルール適用)
* 3. 初期状態(draft)での注文オブジェクト生成
* 4. Zodスキーマによる最終検証
* @param customerId 顧客ID(UUID形式)
* @param items 注文商品リスト(1-10個)
* @returns Result<Order> 成功時は注文オブジェクト、失敗時はエラーメッセージ
* @example
* ```typescript
* import { createOrder } from './domain/functions';
*
* const items: OrderItem[] = [{
* productId: 'laptop-001',
* quantity: 1,
* unitPrice: { amount: 98000, currency: 'JPY' }
* }];
*
* const result = createOrder('customer-uuid' as CustomerId, items);
* if (result.success) {
* console.log('注文作成成功:', result.value.id);
* } else {
* console.error('注文作成失敗:', result.error);
* }
* ```
*/
export const createOrder = (
customerId: CustomerId,
items: readonly OrderItem[]
): Result<Order> => {
const now = new Date();
// ビジネスルール: 合計金額計算
const totalAmount = {
amount: items.reduce((sum, item) => sum + (item.quantity * item.unitPrice.amount), 0),
currency: items[0]?.unitPrice.currency || 'JPY'
};
// 注文オブジェクト構築
const order = {
id: createOrderId(),
customerId,
items,
status: 'draft' as const,
totalAmount,
createdAt: now,
updatedAt: now
};
// Zodスキーマによる検証
const result = OrderSchema.safeParse(order);
return result.success
? { success: true, value: result.data }
: { success: false, error: result.error.errors[0]?.message || '不正な注文データ' };
};/**
* @description 注文状態の遷移処理
* @reasoning
* 1. アクションの形式検証(Zodスキーマ)
* 2. 現在状態からの遷移可能性チェック(TRANSITION_MAP)
* 3. 新しい状態の構築(代数データ型)
* 4. 最終的な状態の検証(Zodスキーマ)
* @param order 現在の注文状態
* @param action 実行するアクション
* @returns Result<Order> 遷移後の注文状態または エラー
* @verification
* - Q: 不正な遷移を実行したらどうなる?
* A: TRANSITION_MAPでチェックし、不正な場合はエラーを返す
* - Q: 部分的なデータ更新は可能?
* A: 不可。新しい状態を完全に構築し、Zodで検証する
* @example
* ```typescript
* const confirmAction = Actions.confirm('PAY-123', {
* street: '1-1-1 Shibuya',
* city: 'Tokyo',
* zipCode: '150-0002'
* });
*
* const result = transition(draftOrder, confirmAction);
* ```
*/
export const transition = (order: Order, action: OrderAction): Result<Order> => {
// 1. アクション検証
const actionResult = OrderActionSchema.safeParse(action);
if (!actionResult.success) {
return { success: false, error: `不正なアクション: ${actionResult.error.message}` };
}
// 2. 遷移可能性チェック
const allowedActions = TRANSITION_MAP[order.status];
const newStatus = allowedActions[action.type as keyof typeof allowedActions];
if (!newStatus) {
return {
success: false,
error: `状態 '${order.status}' からアクション '${action.type}' は実行できません`
};
}
// 3. 新しい状態構築(代数データ型パターン)
let updatedOrder: Order;
switch (action.type) {
case 'CONFIRM':
updatedOrder = {
...order,
status: 'confirmed' as const,
paymentId: action.payload.paymentId,
shippingAddress: action.payload.shippingAddress,
confirmedAt: new Date(),
updatedAt: new Date()
} as Order;
break;
case 'SHIP':
if (order.status !== 'confirmed') {
return { success: false, error: '確定済み注文のみ発送可能' };
}
updatedOrder = {
...order,
status: 'shipped' as const,
trackingNumber: action.payload.trackingNumber,
shippedAt: new Date(),
updatedAt: new Date()
} as Order;
break;
case 'CANCEL':
updatedOrder = {
id: order.id,
customerId: order.customerId,
items: order.items,
totalAmount: order.totalAmount,
createdAt: order.createdAt,
status: 'cancelled' as const,
cancelReason: action.payload.reason,
cancelDetails: action.payload.details,
cancelledAt: new Date(),
refundAmount: action.payload.refundAmount,
updatedAt: new Date()
} as Order;
break;
default:
return { success: false, error: '未知のアクションタイプ' };
}
// 4. 最終検証
const result = OrderSchema.safeParse(updatedOrder);
return result.success
? { success: true, value: result.data }
: { success: false, error: `不正な注文状態: ${result.error.message}` };
};/**
* @description 型安全なアクション作成ヘルパー
* @reasoning 手動でのアクション作成時のタイポ、型エラーを防止
*/
export const Actions = {
/**
* @description 注文確定アクション作成
* @param paymentId 決済システムからの決済ID
* @param address 配送先住所
*/
confirm: (paymentId: string, address: Address): OrderAction => ({
type: 'CONFIRM',
payload: { paymentId, shippingAddress: address }
}),
/**
* @description 発送開始アクション作成
* @param trackingNumber 配送業者の追跡番号
*/
ship: (trackingNumber: string): OrderAction => ({
type: 'SHIP',
payload: { trackingNumber }
}),
/**
* @description キャンセルアクション作成
* @param reason キャンセル理由(列挙型)
* @param details 詳細説明(5文字以上)
* @param refundAmount 返金額(非負数)
*/
cancel: (
reason: 'customer_request' | 'payment_failed' | 'out_of_stock',
details: string,
refundAmount: number
): OrderAction => ({
type: 'CANCEL',
payload: { reason, details, refundAmount }
})
};/**
* @description 注文に関するユーティリティ関数群
*/
export const OrderUtils = {
/**
* @description 指定アクションが実行可能かチェック
* @param order 現在の注文状態
* @param actionType チェック対象のアクションタイプ
* @returns boolean 実行可能な場合true
*/
canTransition: (order: Order, actionType: string): boolean =>
actionType in (TRANSITION_MAP[order.status] || {}),
/**
* @description 終端状態(それ以上遷移できない状態)かチェック
* @param order チェック対象の注文
* @returns boolean 終端状態の場合true
*/
isTerminalState: (order: Order): boolean =>
Object.keys(TRANSITION_MAP[order.status] || {}).length === 0,
/**
* @description 現在状態から実行可能なアクション一覧取得
* @param order 対象の注文
* @returns string[] 実行可能なアクション名の配列
*/
getAvailableActions: (order: Order): string[] =>
Object.keys(TRANSITION_MAP[order.status] || {})
};/**
* @description アプリケーション層のエラー型
* @reasoning 判別可能ユニオンでエラーハンドリングを型安全に
*/
export type AppError =
| { type: 'ValidationError'; message: string; details?: unknown }
| { type: 'DomainError'; message: string; context?: Record<string, unknown> }
| { type: 'TransitionError'; message: string; currentState?: string }
| { type: 'NotFoundError'; resource: string; id: string }
| { type: 'ConcurrencyError'; message: string; version?: number };
/**
* @description エラー作成ヘルパー関数
*/
export const validationError = (message: string, details?: unknown): AppError =>
({ type: 'ValidationError', message, details });
export const domainError = (message: string, context?: Record<string, unknown>): AppError =>
({ type: 'DomainError', message, context });
export const transitionError = (message: string, currentState?: string): AppError =>
({ type: 'TransitionError', message, currentState });
export const notFoundError = (resource: string, id: string): AppError =>
({ type: 'NotFoundError', resource, id });
/**
* @description TaskEither型のエイリアス
* @reasoning アプリケーション層での標準的な戻り値型
*/
export type AppTaskEither<T> = TaskEither<AppError, T>;/**
* @description 注文作成コマンド
* @reasoning CQRSパターン、コマンドとクエリの分離
*/
export const CreateOrderCommandSchema = z.object({
customerId: z.string().uuid().describe('顧客ID'),
items: z.array(z.object({
productId: z.string().min(1).describe('商品ID'),
quantity: z.number().int().positive().max(100).describe('注文数量'),
unitPrice: z.number().nonnegative().describe('単価'),
currency: z.string().regex(/^[A-Z]{3}$/).describe('通貨コード')
})).min(1).max(10).describe('注文商品リスト')
});
export type CreateOrderCommand = z.infer<typeof CreateOrderCommandSchema>;
/**
* @description 状態遷移コマンド
*/
export const TransitionOrderCommandSchema = z.object({
orderId: z.string().uuid().describe('注文ID'),
action: OrderActionSchema.describe('実行するアクション')
});
export type TransitionOrderCommand = z.infer<typeof TransitionOrderCommandSchema>;/**
* @description 注文リポジトリインターフェース
* @reasoning インフラストラクチャ層への依存性の抽象化
*/
export interface OrderRepository {
/**
* @description 注文の保存
* @param order 保存対象の注文
* @returns AppTaskEither<Order> 保存後の注文(楽観的ロック用バージョン更新済み)
*/
save(order: Order): AppTaskEither<Order>;
/**
* @description ID による注文検索
* @param orderId 検索対象の注文ID
* @returns AppTaskEither<Order> 見つかった注文
* @errors NotFoundError 注文が存在しない場合
*/
findById(orderId: string): AppTaskEither<Order>;
/**
* @description 顧客ID による注文一覧取得
* @param customerId 検索対象の顧客ID
* @returns AppTaskEither<readonly Order[]> 該当する注文の配列
*/
findByCustomerId(customerId: CustomerId): AppTaskEither<readonly Order[]>;
/**
* @description 注文の存在確認
* @param orderId 確認対象の注文ID
* @returns AppTaskEither<boolean> 存在する場合true
*/
exists(orderId: string): AppTaskEither<boolean>;
}/**
* @description 注文作成ハンドラー
* @reasoning TaskEitherモナドによる関数合成、エラーハンドリング
* @param repository 注文リポジトリ
* @returns ハンドラー関数
* @example
* ```typescript
* import { pipe } from 'fp-ts/function';
* import { fold } from 'fp-ts/TaskEither';
*
* const handler = createOrderHandler(repository);
* const command = {
* customerId: 'customer-uuid',
* items: [{ productId: 'item-1', quantity: 1, unitPrice: 1000, currency: 'JPY' }]
* };
*
* const result = await handler(command)();
* pipe(
* result,
* E.fold(
* error => console.error('エラー:', error),
* order => console.log('作成成功:', order.id)
* )
* );
* ```
*/
export const createOrderHandler = (repository: OrderRepository) => {
return (command: CreateOrderCommand): AppTaskEither<Order> => {
/**
* @step 1. コマンド検証
*/
const validateCommand = (): Either<AppError, CreateOrderCommand> => {
const result = CreateOrderCommandSchema.safeParse(command);
return result.success
? right(command)
: left(validationError('不正なコマンド', result.error.errors));
};
/**
* @step 2. ドメインオブジェクト構築
*/
const buildOrderItems = (): Either<AppError, OrderItem[]> => {
try {
const items: OrderItem[] = command.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
unitPrice: { amount: item.unitPrice, currency: item.currency }
}));
return right(items);
} catch (error) {
return left(domainError('商品リスト構築エラー', { error }));
}
};
/**
* @step 3. 注文作成(ドメインロジック)
*/
const buildOrder = (items: OrderItem[]): Either<AppError, Order> => {
const result = createOrder(command.customerId as CustomerId, items);
return result.success
? right(result.value)
: left(domainError(result.error));
};
/**
* @step 4. TaskEitherパイプライン実行
*/
return pipe(
fromEither(validateCommand()),
chain(() => fromEither(buildOrderItems())),
chain(items => fromEither(buildOrder(items))),
chain(order => repository.save(order))
);
};
};
/**
* @description 注文状態遷移ハンドラー
* @reasoning 状態遷移ロジックの統一的な処理
* @param repository 注文リポジトリ
* @returns ハンドラー関数
*/
export const transitionOrderHandler = (repository: OrderRepository) => {
return (orderId: string, action: OrderAction): AppTaskEither<Order> => {
/**
* @step 1. 現在の注文状態取得
*/
const getCurrentOrder = (): AppTaskEither<Order> =>
repository.findById(orderId);
/**
* @step 2. 状態遷移適用(ドメインロジック)
*/
const applyTransition = (order: Order): Either<AppError, Order> => {
const result = transition(order, action);
return result.success
? right(result.value)
: left(transitionError(result.error, order.status));
};
/**
* @step 3. 更新後の注文保存
*/
return pipe(
getCurrentOrder(),
chain(order => fromEither(applyTransition(order))),
chain(updatedOrder => repository.save(updatedOrder))
);
};
};/**
* @description インメモリ注文リポジトリ実装
* @reasoning テスト用、プロトタイプ用の軽量実装
* @usage 本番環境では PostgreSQL/MongoDB 等の実装に置き換え
*/
export class InMemoryOrderRepository implements OrderRepository {
private orders: Map<string, Order> = new Map();
private versionCounter = 0;
/**
* @description 注文の保存
* @reasoning 楽観的ロック用のバージョン管理
*/
save(order: Order): AppTaskEither<Order> {
try {
// バージョン管理(楽観的ロック)
const savedOrder = {
...order,
updatedAt: new Date(),
version: ++this.versionCounter
} as Order;
this.orders.set(order.id, savedOrder);
return fromEither(right(savedOrder));
} catch (error) {
return fromEither(left(domainError('注文保存エラー', { error })));
}
}
/**
* @description ID による注文検索
*/
findById(orderId: string): AppTaskEither<Order> {
const order = this.orders.get(orderId);
return order
? fromEither(right(order))
: fromEither(left(notFoundError('Order', orderId)));
}
/**
* @description 顧客ID による注文一覧取得
*/
findByCustomerId(customerId: CustomerId): AppTaskEither<readonly Order[]> {
const orders = Array.from(this.orders.values())
.filter(order => order.customerId === customerId);
return fromEither(right(orders));
}
/**
* @description 注文の存在確認
*/
exists(orderId: string): AppTaskEither<boolean> {
return fromEither(right(this.orders.has(orderId)));
}
/**
* @description テスト用: データクリア
*/
clear(): void {
this.orders.clear();
this.versionCounter = 0;
}
/**
* @description テスト用: 全注文取得
*/
getAll(): readonly Order[] {
return Array.from(this.orders.values());
}
}/**
* @description PostgreSQL 注文リポジトリ実装例
* @reasoning 本番環境での永続化実装
* @requires pg, @types/pg
*/
export class PostgreSQLOrderRepository implements OrderRepository {
constructor(private pool: Pool) {}
save(order: Order): AppTaskEither<Order> {
return pipe(
TaskEither.tryCatch(
async () => {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// 注文基本情報の保存
const orderQuery = `
INSERT INTO orders (id, customer_id, status, total_amount, total_currency, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at
RETURNING *
`;
await client.query(orderQuery, [
order.id,
order.customerId,
order.status,
order.totalAmount.amount,
order.totalAmount.currency,
order.createdAt,
order.updatedAt
]);
// 注文商品の保存
await this.saveOrderItems(client, order);
// 状態固有データの保存
await this.saveStatusSpecificData(client, order);
await client.query('COMMIT');
return order;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
},
(error) => domainError('注文保存エラー', { error })
)
);
}
findById(orderId: string): AppTaskEither<Order> {
return pipe(
TaskEither.tryCatch(
async () => {
const client = await this.pool.connect();
try {
const result = await client.query(
'SELECT * FROM orders WHERE id = $1',
[orderId]
);
if (result.rows.length === 0) {
throw new Error('Order not found');
}
const orderData = result.rows[0];
return await this.buildOrderFromData(client, orderData);
} finally {
client.release();
}
},
(error) => error.message === 'Order not found'
? notFoundError('Order', orderId)
: domainError('注文検索エラー', { error })
)
);
}
/**
* @description 注文商品の保存
* @private
*/
private async saveOrderItems(client: PoolClient, order: Order): Promise<void> {
// 既存商品削除
await client.query('DELETE FROM order_items WHERE order_id = $1', [order.id]);
// 新規商品挿入
for (const item of order.items) {
await client.query(`
INSERT INTO order_items (order_id, product_id, quantity, unit_price, currency)
VALUES ($1, $2, $3, $4, $5)
`, [order.id, item.productId, item.quantity, item.unitPrice.amount, item.unitPrice.currency]);
}
}
}/**
* @example 注文作成から配送までの完全フロー
* @description 実際のアプリケーションでの使用パターン
*/
import { pipe } from 'fp-ts/function';
import { fold } from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';
const runCompleteOrderFlow = async () => {
// 依存関係のセットアップ
const repository = new InMemoryOrderRepository();
const createHandler = createOrderHandler(repository);
const transitionHandler = transitionOrderHandler(repository);
console.log('=== 注文作成から配送完了までのフロー ===\n');
// 1. 注文作成
const createCommand: CreateOrderCommand = {
customerId: '550e8400-e29b-41d4-a716-446655440000',
items: [
{
productId: 'laptop-001',
quantity: 1,
unitPrice: 98000,
currency: 'JPY'
},
{
productId: 'mouse-002',
quantity: 2,
unitPrice: 2500,
currency: 'JPY'
}
]
};
const createResult = await createHandler(createCommand)();
const order = await pipe(
createResult,
fold(
async (error) => {
console.error('❌ 注文作成エラー:', error);
throw new Error('注文作成失敗');
},
async (order) => {
console.log(`✅ 注文作成成功: ${order.id}`);
console.log(` 状態: ${order.status}`);
console.log(` 合計: ${order.totalAmount.amount} ${order.totalAmount.currency}\n`);
return order;
}
)
)();
// 2. 注文確定
const confirmAction = Actions.confirm('PAY-123456', {
street: '1-1-1 Shibuya',
city: 'Tokyo',
zipCode: '150-0002'
});
const confirmResult = await transitionHandler(order.id, confirmAction)();
const confirmedOrder = await pipe(
confirmResult,
fold(
async (error) => {
console.error('❌ 注文確定エラー:', error);
throw new Error('注文確定失敗');
},
async (confirmedOrder) => {
console.log(`✅ 注文確定成功: ${confirmedOrder.id}`);
console.log(` 状態: ${confirmedOrder.status}`);
if (confirmedOrder.status === 'confirmed') {
console.log(` 決済ID: ${confirmedOrder.paymentId}`);
console.log(` 配送先: ${confirmedOrder.shippingAddress.street}, ${confirmedOrder.shippingAddress.city}\n`);
}
return confirmedOrder;
}
)
)();
// 3. 発送開始
const shipAction = Actions.ship('TRACK-789012');
const shipResult = await transitionHandler(confirmedOrder.id, shipAction)();
await pipe(
shipResult,
fold(
async (error) => {
console.error('❌ 発送エラー:', error);
},
async (shippedOrder) => {
console.log(`✅ 発送開始: ${shippedOrder.id}`);
console.log(` 状態: ${shippedOrder.status}`);
if (shippedOrder.status === 'shipped') {
console.log(` 追跡番号: ${shippedOrder.trackingNumber}`);
console.log(` 発送日時: ${shippedOrder.shippedAt?.toISOString()}\n`);
}
}
)
)();
// 4. 利用可能アクションの確認
const finalOrder = repository.findById(order.id);
await pipe(
finalOrder,
fold(
async () => {},
async (order) => {
const availableActions = OrderUtils.getAvailableActions(order);
console.log(`📋 利用可能なアクション: ${availableActions.join(', ')}`);
console.log(`🔚 終端状態: ${OrderUtils.isTerminalState(order) ? 'はい' : 'いいえ'}`);
}
)
)();
};
// 実行
runCompleteOrderFlow().catch(console.error);/**
* @description ドメイン関数のテストパターン
* @testing-approach 純粋関数のため、入力と出力のテストが容易
*/
import { describe, it, expect, beforeEach } from 'vitest';
describe('Order Domain Functions', () => {
describe('createOrder', () => {
it('正常な注文を作成できる', () => {
// Arrange
const customerId = '123e4567-e89b-12d3-a456-426614174000' as CustomerId;
const items: OrderItem[] = [{
productId: 'item-1',
quantity: 2,
unitPrice: { amount: 1000, currency: 'JPY' }
}];
// Act
const result = createOrder(customerId, items);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.customerId).toBe(customerId);
expect(result.value.status).toBe('draft');
expect(result.value.totalAmount.amount).toBe(2000);
expect(result.value.items).toHaveLength(1);
}
});
it('空の商品リストはエラーを返す', () => {
// Arrange
const customerId = '123e4567-e89b-12d3-a456-426614174000' as CustomerId;
const items: OrderItem[] = [];
// Act
const result = createOrder(customerId, items);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('items');
}
});
it('不正な数量はエラーを返す', () => {
// Arrange
const customerId = '123e4567-e89b-12d3-a456-426614174000' as CustomerId;
const items: OrderItem[] = [{
productId: 'item-1',
quantity: 0, // 不正な数量
unitPrice: { amount: 1000, currency: 'JPY' }
}];
// Act
const result = createOrder(customerId, items);
// Assert
expect(result.success).toBe(false);
});
});
describe('transition', () => {
let draftOrder: Order;
beforeEach(() => {
const createResult = createOrder(
'123e4567-e89b-12d3-a456-426614174000' as CustomerId,
[{
productId: 'item-1',
quantity: 1,
unitPrice: { amount: 1000, currency: 'JPY' }
}]
);
if (createResult.success) {
draftOrder = createResult.value;
} else {
throw new Error('テスト用注文作成失敗');
}
});
it('draft から confirmed への遷移が成功する', () => {
// Arrange
const confirmAction = Actions.confirm('PAY-123', {
street: '1-1-1 Test',
city: 'Tokyo',
zipCode: '100-0001'
});
// Act
const result = transition(draftOrder, confirmAction);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.status).toBe('confirmed');
expect(result.value.paymentId).toBe('PAY-123');
}
});
it('不正な遷移はエラーを返す', () => {
// Arrange
const shipAction = Actions.ship('TRACK-123');
// Act
const result = transition(draftOrder, shipAction);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('SHIP');
expect(result.error).toContain('draft');
}
});
});
});
/**
* @description アプリケーションサービスのテストパターン
* @testing-approach モックリポジトリを使用した統合テスト
*/
describe('Order Application Services', () => {
let mockRepository: jest.Mocked<OrderRepository>;
let createHandler: ReturnType<typeof createOrderHandler>;
beforeEach(() => {
mockRepository = {
save: jest.fn(),
findById: jest.fn(),
findByCustomerId: jest.fn(),
exists: jest.fn()
};
createHandler = createOrderHandler(mockRepository);
});
it('正常な注文作成フロー', async () => {
// Arrange
const command: CreateOrderCommand = {
customerId: '123e4567-e89b-12d3-a456-426614174000',
items: [{
productId: 'item-1',
quantity: 1,
unitPrice: 1000,
currency: 'JPY'
}]
};
mockRepository.save.mockReturnValue(
TaskEither.right({
id: 'order-123',
status: 'draft',
customerId: command.customerId,
items: [{
productId: 'item-1',
quantity: 1,
unitPrice: { amount: 1000, currency: 'JPY' }
}],
totalAmount: { amount: 1000, currency: 'JPY' },
createdAt: new Date(),
updatedAt: new Date()
} as Order)
);
// Act
const result = await createHandler(command)();
// Assert
expect(E.isRight(result)).toBe(true);
expect(mockRepository.save).toHaveBeenCalledTimes(1);
if (E.isRight(result)) {
expect(result.right.customerId).toBe(command.customerId);
expect(result.right.status).toBe('draft');
}
});
it('バリデーションエラーのハンドリング', async () => {
// Arrange
const invalidCommand = {
customerId: 'invalid-uuid', // 不正なUUID
items: []
} as CreateOrderCommand;
// Act
const result = await createHandler(invalidCommand)();
// Assert
expect(E.isLeft(result)).toBe(true);
expect(mockRepository.save).not.toHaveBeenCalled();
if (E.isLeft(result)) {
expect(result.left.type).toBe('ValidationError');
}
});
});# ステップ 1: 基本型定義
touch src/shared/types.ts
touch src/order/domain/types.ts
# ステップ 2: 型チェック実行
npm run type-check
# ステップ 3: 基本関数実装
touch src/order/domain/functions.ts
# ステップ 4: ドメインテスト作成
touch src/order/domain/__tests__/functions.test.ts実装チェックリスト:
- Zodスキーマ定義完了
- ブランド型とエイリアス定義
- 状態遷移マップ作成
- 基本ドメイン関数実装
- 単体テスト作成・実行
# ステップ 5: サービス層実装
touch src/order/application/service.ts
# ステップ 6: リポジトリインターフェース定義
# ステップ 7: エラー型とハンドラー実装
# ステップ 8: 統合テスト作成
touch src/order/application/__tests__/service.test.ts実装チェックリスト:
- TaskEitherパイプライン構築
- エラーハンドリング実装
- コマンド/クエリ分離
- モックを使った統合テスト
# ステップ 9: リポジトリ実装
touch src/order/infrastructure/repository.ts
# ステップ 10: E2Eテスト作成
touch src/__tests__/e2e/order-flow.test.ts
# ステップ 11: 実行例作成
touch src/example.ts最終チェックリスト:
-
npm run test全テスト通過 -
npm run type-checkエラーなし -
npm run example正常実行 -
npm run build成功
失敗時のエスケープルール:
- 3回連続テスト失敗 → 設計見直し、要件再確認
- 型エラーが5個以上 → アーキテクチャ再検討
- 関数が20行超過 → 関数分割、単一責任原則確認
コードレビューチェックポイント:
- 純粋関数の原則遵守
- 適切なエラーハンドリング
- 型安全性の確保
- テストカバレッジ 80% 以上
- ドキュメントと実装の一致
同じプロセスで以下の機能を順次実装:
- 在庫管理システム (Inventory Aggregate)
- 顧客管理システム (Customer Aggregate)
- 決済処理システム (Payment Service)
- 通知システム (Notification Events)
各機能で Phase 1-3 を繰り返し、段階的にシステムを拡張していきます。
型エラー関連:
// ❌ 問題: Type 'string' is not assignable to 'OrderId'
const orderId: OrderId = "some-id";
// ✅ 解決: ブランド型の正しい作成
const orderId: OrderId = createOrderId();
// または
const orderId = "some-id" as OrderId; // 型アサーション(注意して使用)fp-ts関連:
// ❌ 問題: Cannot find namespace 'E'
const result = E.right(value);
// ✅ 解決: 正しいインポート
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';Zodバリデーション:
// ❌ 問題: 期待した型にならない
const order = OrderSchema.parse(data); // throwする
// ✅ 解決: safeParseを使用
const result = OrderSchema.safeParse(data);
if (result.success) {
const order = result.data; // 型安全
} else {
console.error(result.error);
}状態遷移エラー:
// ❌ 問題: 「状態 'shipped' からアクション 'CONFIRM' は実行できません」
// ✅ 解決: 利用可能アクションの事前確認
const availableActions = OrderUtils.getAvailableActions(order);
if (availableActions.includes('CONFIRM')) {
// 実行可能
}このガイドは、TypeScriptと関数型プログラミングを使用したDDD実装の包括的なリファレンスです。
主要な特徴:
- 型安全性: Zodとブランド型による実行時・コンパイル時検証
- 純粋性: 副作用のない純粋関数によるドメインロジック
- 合成可能性: TaskEitherモナドによる関数合成
- 保守性: 明確な責任分離とテスト可能な設計
実装効果:
- バグの早期発見(コンパイル時)
- 状態不整合の防止(型レベル)
- テストの容易性(純粋関数)
- 拡張性の向上(関数合成)
このアプローチにより、複雑なビジネスロジックを型安全で保守可能な形で実装できます。