ストーリー
Driving Adapter(入力アダプター)
外部のリクエストを、Use Caseが理解できるCommandやQueryに変換します。
HTTP Adapter(REST Controller)
// adapters/in/http/OrderController.ts
import express from 'express';
class OrderController {
constructor(
private createOrderUseCase: CreateOrderUseCase,
private getOrderUseCase: GetOrderUseCase,
private cancelOrderUseCase: CancelOrderUseCase
) {}
async create(req: express.Request, res: express.Response): Promise<void> {
try {
// HTTPリクエスト → Command への変換
const command: CreateOrderCommand = {
customerId: req.body.customerId,
items: req.body.items,
shippingAddress: req.body.shippingAddress,
};
const orderId = await this.createOrderUseCase.execute(command);
// Use Caseの結果 → HTTPレスポンス への変換
res.status(201).json({ orderId: orderId.value });
} catch (error) {
if (error instanceof ValidationError) {
res.status(400).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
}
async getById(req: express.Request, res: express.Response): Promise<void> {
const query: GetOrderQuery = { orderId: req.params.id };
const order = await this.getOrderUseCase.execute(query);
if (!order) {
res.status(404).json({ error: 'Order not found' });
return;
}
res.json(order);
}
async cancel(req: express.Request, res: express.Response): Promise<void> {
const command: CancelOrderCommand = {
orderId: req.params.id,
reason: req.body.reason,
};
await this.cancelOrderUseCase.execute(command);
res.status(204).send();
}
}
Adapterの責任
Driving Adapterが行うのは変換だけです。ビジネスロジックは含めません。
// NG: Adapterにビジネスロジックが入っている
class OrderController {
async create(req: Request, res: Response) {
// バリデーションや計算をControllerでやってはいけない
if (req.body.items.length > 10) {
return res.status(400).json({ error: 'Too many items' });
}
const total = req.body.items.reduce((s, i) => s + i.price * i.qty, 0);
// ...
}
}
// OK: Adapterは変換のみ、ロジックはUse Caseに委譲
class OrderController {
async create(req: Request, res: Response) {
const command = this.toCommand(req.body); // 変換のみ
const result = await this.createOrderUseCase.execute(command); // 委譲
res.status(201).json(this.toResponse(result)); // 変換のみ
}
}
Driven Adapter(出力アダプター)
Use Caseが必要とする外部機能を、具体的な技術で実装します。
Repository Adapter(Prisma)
// adapters/out/persistence/PrismaOrderRepository.ts
import { PrismaClient } from '@prisma/client';
class PrismaOrderRepository implements OrderRepository {
constructor(private prisma: PrismaClient) {}
async findById(id: OrderId): Promise<Order | null> {
const row = await this.prisma.order.findUnique({
where: { id: id.value },
include: { items: true },
});
if (!row) return null;
// DB行 → ドメインモデル への変換
return Order.fromPersistence({
id: row.id,
customerId: row.customerId,
items: row.items.map(item => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
currency: item.currency,
})),
status: row.status,
totalAmount: row.totalAmount,
currency: row.currency,
createdAt: row.createdAt,
});
}
async save(order: Order): Promise<void> {
// ドメインモデル → DB行 への変換
const data = order.toPersistence();
await this.prisma.order.upsert({
where: { id: data.id },
create: {
id: data.id,
customerId: data.customerId,
status: data.status,
totalAmount: data.totalAmount,
currency: data.currency,
createdAt: data.createdAt,
items: {
create: data.items.map(item => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
currency: item.currency,
})),
},
},
update: {
status: data.status,
totalAmount: data.totalAmount,
},
});
}
async delete(id: OrderId): Promise<void> {
await this.prisma.order.delete({ where: { id: id.value } });
}
async findByCustomerId(customerId: string): Promise<Order[]> {
const rows = await this.prisma.order.findMany({
where: { customerId },
include: { items: true },
});
return rows.map(row => Order.fromPersistence(row));
}
async existsById(id: OrderId): Promise<boolean> {
const count = await this.prisma.order.count({
where: { id: id.value },
});
return count > 0;
}
}
外部API Adapter
// adapters/out/payment/StripePaymentGateway.ts
import Stripe from 'stripe';
class StripePaymentGateway implements PaymentGateway {
private stripe: Stripe;
constructor(apiKey: string) {
this.stripe = new Stripe(apiKey);
}
async charge(amount: Money, method: PaymentMethod): Promise<PaymentResult> {
try {
const intent = await this.stripe.paymentIntents.create({
amount: amount.toCents(),
currency: amount.currency.toLowerCase(),
payment_method: method.externalId,
confirm: true,
});
// Stripe固有のレスポンス → ドメインの型 への変換
return PaymentResult.success(intent.id, amount);
} catch (error) {
return PaymentResult.failure(error.message);
}
}
async refund(paymentId: string, amount: Money): Promise<RefundResult> {
const refund = await this.stripe.refunds.create({
payment_intent: paymentId,
amount: amount.toCents(),
});
return RefundResult.of(refund.id, refund.status);
}
}
テスト用 Adapter
// adapters/out/persistence/InMemoryOrderRepository.ts
class InMemoryOrderRepository implements OrderRepository {
private store: Map<string, Order> = new Map();
async findById(id: OrderId): Promise<Order | null> {
return this.store.get(id.value) ?? null;
}
async save(order: Order): Promise<void> {
this.store.set(order.id.value, order);
}
async delete(id: OrderId): Promise<void> {
this.store.delete(id.value);
}
async findByCustomerId(customerId: string): Promise<Order[]> {
return [...this.store.values()].filter(o => o.customerId === customerId);
}
async existsById(id: OrderId): Promise<boolean> {
return this.store.has(id.value);
}
// テスト用ヘルパー
clear(): void { this.store.clear(); }
size(): number { return this.store.size; }
}
ディレクトリ構造
src/
└── adapters/
├── in/ # Driving Adapters
│ ├── http/
│ │ ├── OrderController.ts
│ │ └── router.ts
│ └── cli/
│ └── CreateOrderCli.ts
└── out/ # Driven Adapters
├── persistence/
│ ├── PrismaOrderRepository.ts
│ └── InMemoryOrderRepository.ts # テスト用
├── payment/
│ └── StripePaymentGateway.ts
└── notification/
└── SendGridNotificationSender.ts
まとめ
| ポイント | 内容 |
|---|---|
| Driving Adapter | HTTPリクエスト↔Commandの変換のみ |
| Driven Adapter | ドメインモデル↔外部技術の変換 |
| 責任の限定 | Adapterにビジネスロジックを含めない |
| テスト用Adapter | InMemory実装で高速なテストを実現 |
| 変換の場所 | ドメイン↔外部の変換はAdapter内で行う |
チェックリスト
- Driving Adapterの責任(変換のみ)を説明できる
- Driven Adapterの実装方法を理解した
- テスト用InMemory Adapterの利点を説明できる
- Adapterのディレクトリ構成を理解した
次のステップへ
次は演習です。ここまで学んだ知識を使って、実際にヘキサゴナルアーキテクチャでアプリケーションを構築してみましょう。
推定読了時間: 30分