ストーリー
Use Caseの責任
Use Caseはアプリケーション固有のビジネスフローを定義します。Entityのビジネスルールを適切な順序で呼び出し、外部リソースとの連携を調整します。
// 「注文を作成する」というUse Case
class CreateOrderUseCase {
constructor(
private orderRepo: OrderRepository,
private inventoryChecker: InventoryChecker,
private paymentGateway: PaymentGateway,
private notifier: NotificationSender
) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
// 1. 在庫を確認する
for (const item of input.items) {
const available = await this.inventoryChecker.checkAvailability(
item.productId, item.quantity
);
if (!available) {
throw new BusinessError(`商品${item.productId}の在庫が不足しています`);
}
}
// 2. 注文エンティティを作成する(ビジネスルールはEntity内)
const order = Order.create(input.customerId, input.items);
// 3. 決済を行う
const paymentResult = await this.paymentGateway.charge(
order.totalAmount, input.paymentMethod
);
if (!paymentResult.isSuccess) {
throw new BusinessError('決済に失敗しました');
}
// 4. 注文を保存する
await this.orderRepo.save(order);
// 5. 通知を送る
await this.notifier.sendOrderConfirmation(order);
// 6. 結果を返す
return {
orderId: order.id.value,
totalAmount: order.totalAmount.amount,
status: order.status,
};
}
}
Input / Output の設計
Use Caseの入力と出力は、専用の型で定義します。
Input(入力)
// use-cases/dto/CreateOrderInput.ts
interface CreateOrderInput {
readonly customerId: string;
readonly items: ReadonlyArray<{
readonly productId: string;
readonly quantity: number;
}>;
readonly paymentMethod: {
readonly type: 'credit_card' | 'bank_transfer';
readonly token: string;
};
readonly shippingAddress: string;
}
Output(出力)
// use-cases/dto/CreateOrderOutput.ts
interface CreateOrderOutput {
readonly orderId: string;
readonly totalAmount: number;
readonly status: string;
readonly estimatedDelivery: string;
}
なぜ専用の型を使うのか
// NG: Expressのリクエスト型を直接受け取る
class CreateOrderUseCase {
execute(req: express.Request): Promise<Order> { /* ... */ }
// Use CaseがExpressに依存してしまう
}
// NG: ドメインエンティティをそのまま返す
class CreateOrderUseCase {
execute(input: CreateOrderInput): Promise<Order> { /* ... */ }
// 外側の層にドメインモデルの内部が露出する
}
// OK: 専用のInput/Outputを使う
class CreateOrderUseCase {
execute(input: CreateOrderInput): Promise<CreateOrderOutput> { /* ... */ }
// フレームワーク非依存、ドメイン非露出
}
Use Case設計のパターン
Command Use Case(状態変更)
// 注文をキャンセルする
class CancelOrderUseCase {
constructor(
private orderRepo: OrderRepository,
private paymentGateway: PaymentGateway
) {}
async execute(input: CancelOrderInput): Promise<void> {
const order = await this.orderRepo.findById(
OrderId.fromString(input.orderId)
);
if (!order) throw new NotFoundError('注文が見つかりません');
order.cancel();
await this.paymentGateway.refund(input.orderId, order.totalAmount);
await this.orderRepo.save(order);
}
}
Query Use Case(データ取得)
// 注文の詳細を取得する
class GetOrderDetailUseCase {
constructor(private orderRepo: OrderRepository) {}
async execute(input: GetOrderDetailInput): Promise<OrderDetailOutput> {
const order = await this.orderRepo.findById(
OrderId.fromString(input.orderId)
);
if (!order) throw new NotFoundError('注文が見つかりません');
return {
orderId: order.id.value,
customerId: order.customerId,
items: order.items.map(item => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice.amount,
})),
totalAmount: order.totalAmount.amount,
status: order.status,
};
}
}
Use Caseのテスト
describe('CreateOrderUseCase', () => {
let useCase: CreateOrderUseCase;
let orderRepo: InMemoryOrderRepository;
let inventoryChecker: StubInventoryChecker;
let paymentGateway: StubPaymentGateway;
let notifier: SpyNotificationSender;
beforeEach(() => {
orderRepo = new InMemoryOrderRepository();
inventoryChecker = new StubInventoryChecker();
paymentGateway = new StubPaymentGateway();
notifier = new SpyNotificationSender();
useCase = new CreateOrderUseCase(
orderRepo, inventoryChecker, paymentGateway, notifier
);
});
it('正常に注文を作成できる', async () => {
inventoryChecker.setAvailable(true);
paymentGateway.setSuccess(true);
const output = await useCase.execute({
customerId: 'customer-1',
items: [{ productId: 'prod-1', quantity: 2 }],
paymentMethod: { type: 'credit_card', token: 'tok_123' },
shippingAddress: '東京都...',
});
expect(output.orderId).toBeDefined();
expect(output.status).toBe('PENDING');
expect(notifier.wasCalled()).toBe(true);
});
it('在庫不足の場合はエラー', async () => {
inventoryChecker.setAvailable(false);
await expect(useCase.execute({
customerId: 'customer-1',
items: [{ productId: 'prod-1', quantity: 100 }],
paymentMethod: { type: 'credit_card', token: 'tok_123' },
shippingAddress: '東京都...',
})).rejects.toThrow('在庫が不足しています');
});
});
Use Case設計の注意点
| 原則 | 説明 |
|---|---|
| 1 Use Case = 1 クラス | 1つのクラスが1つのユースケースを担当 |
| フレームワーク非依存 | Express, Prismaなどをimportしない |
| 薄いUse Case | ビジネスルールはEntityに委譲する |
| Input/Output型 | 専用の型を使い、ドメインモデルを直接返さない |
| テスタブル | 全依存がインターフェース経由で差し替え可能 |
まとめ
| ポイント | 内容 |
|---|---|
| Use Caseの責任 | アプリケーション固有のビジネスフロー |
| Input/Output | 専用の型で定義し、フレームワーク・ドメイン非依存 |
| Command/Query | 状態変更と取得を分離 |
| テスト | Stub/Spy/InMemoryで全依存を差し替え |
チェックリスト
- Use Caseの責任を説明できる
- Input/Outputの型を適切に設計できる
- Command Use CaseとQuery Use Caseの違いを理解した
- テスト用のStub/Spyを使ったテストが書ける
次のステップへ
次は「依存性注入(DI)の実践」を学びます。Use CaseにPortの実装を注入する具体的な方法を身につけましょう。
推定読了時間: 30分