ストーリー
ヘキサゴナルアーキテクチャの全体像
┌─────────────────────┐
│ HTTP Adapter │
│ (Controller) │
└────────┬────────────┘
│
┌────────▼────────────┐
│ Port (in) │
│ (Use Case IF) │
┌───────┼────────────────────┐ │
│ │ │ │
CLI ─────┤ Port │ APPLICATION │ │
Adapter │ (in) │ │ │
│ │ ┌────────────┐ │ │
└───────┤ │ DOMAIN │ │ │
│ │ MODEL │ │ │
│ └────────────┘ │ │
│ │ │
│ Port (out) │ │──── DB Adapter
│ (Repository IF) │ │ (PostgreSQL)
└────────────────────┘ │
│ Port (out) │
│ (Notification IF) │
└─────────┬───────────┘
│
┌─────────▼───────────┐
│ Email Adapter │
│ (SendGrid) │
└─────────────────────┘
Port(ポート)とは
Portはアプリケーションの境界に定義されたインターフェースです。外部との接続点を抽象化します。
Driving Port(入力ポート / Primary Port)
外部からアプリケーションを駆動するためのインターフェースです。「アプリケーションに何ができるか」を定義します。
// domain/ports/in/CreateOrderUseCase.ts
// Driving Port: アプリケーションが提供する機能を定義
interface CreateOrderUseCase {
execute(command: CreateOrderCommand): Promise<OrderId>;
}
// コマンドオブジェクト
interface CreateOrderCommand {
customerId: string;
items: Array<{
productId: string;
quantity: number;
}>;
}
Driven Port(出力ポート / Secondary Port)
アプリケーションが外部リソースを利用するためのインターフェースです。「アプリケーションが何を必要としているか」を定義します。
// domain/ports/out/OrderRepository.ts
// Driven Port: アプリケーションが必要とする外部機能を定義
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
findByCustomerId(customerId: string): Promise<Order[]>;
}
// domain/ports/out/PaymentGateway.ts
interface PaymentGateway {
charge(amount: Money, paymentMethod: PaymentMethod): Promise<PaymentResult>;
refund(paymentId: string): Promise<RefundResult>;
}
// domain/ports/out/NotificationService.ts
interface NotificationService {
notify(userId: string, message: string): Promise<void>;
}
Adapter(アダプター)とは
AdapterはPortの具体的な実装です。外部の技術的詳細をアプリケーションに接続します。
Driving Adapter(入力アダプター / Primary Adapter)
// adapters/in/http/OrderController.ts
// Driving Adapter: HTTPリクエストをUse Caseに変換
class OrderController {
constructor(private createOrderUseCase: CreateOrderUseCase) {}
async create(req: Request, res: Response): Promise<void> {
const command: CreateOrderCommand = {
customerId: req.body.customerId,
items: req.body.items,
};
const orderId = await this.createOrderUseCase.execute(command);
res.status(201).json({ orderId: orderId.value });
}
}
// adapters/in/cli/CreateOrderCli.ts
// 同じUse Caseを別のAdapterから呼び出せる
class CreateOrderCli {
constructor(private createOrderUseCase: CreateOrderUseCase) {}
async run(args: string[]): Promise<void> {
const command = this.parseArgs(args);
const orderId = await this.createOrderUseCase.execute(command);
console.log(`Order created: ${orderId.value}`);
}
}
Driven Adapter(出力アダプター / Secondary Adapter)
// adapters/out/persistence/PrismaOrderRepository.ts
// Driven Adapter: PortをPrismaで実装
class PrismaOrderRepository implements OrderRepository {
async findById(id: OrderId): Promise<Order | null> {
const row = await prisma.order.findUnique({
where: { id: id.value },
include: { items: true },
});
return row ? Order.fromPersistence(row) : null;
}
async save(order: Order): Promise<void> {
const data = order.toPersistence();
await prisma.order.upsert({
where: { id: data.id },
create: data,
update: data,
});
}
async findByCustomerId(customerId: string): Promise<Order[]> {
const rows = await prisma.order.findMany({
where: { customerId },
include: { items: true },
});
return rows.map(Order.fromPersistence);
}
}
// adapters/out/payment/StripePaymentGateway.ts
class StripePaymentGateway implements PaymentGateway {
async charge(amount: Money, method: PaymentMethod): Promise<PaymentResult> {
const intent = await stripe.paymentIntents.create({
amount: amount.toCents(),
currency: amount.currency,
payment_method: method.stripeId,
});
return PaymentResult.fromStripe(intent);
}
async refund(paymentId: string): Promise<RefundResult> {
const refund = await stripe.refunds.create({ payment_intent: paymentId });
return RefundResult.fromStripe(refund);
}
}
PortとAdapterの関係
Driving Adapter ──→ Driving Port ──→ Application ──→ Driven Port ←── Driven Adapter
HTTP Controller Use Case IF Use Case Impl Repository IF Prisma Impl
CLI Command Gateway IF Stripe Impl
GraphQL Resolver Notifier IF SendGrid Impl
| 要素 | 役割 | 依存の方向 |
|---|---|---|
| Driving Port | アプリケーションが提供する機能 | Adapterが依存 |
| Driving Adapter | 外部→アプリケーションの変換 | Portに依存 |
| Driven Port | アプリケーションが必要とする機能 | アプリケーションが依存 |
| Driven Adapter | アプリケーション→外部の変換 | Portに依存 |
なぜPortとAdapterに分けるのか
// テスト時: Driven Adapterをモックに差し替え
class InMemoryOrderRepository implements OrderRepository {
private orders: Map<string, Order> = new Map();
async findById(id: OrderId): Promise<Order | null> {
return this.orders.get(id.value) ?? null;
}
async save(order: Order): Promise<void> {
this.orders.set(order.id.value, order);
}
async findByCustomerId(customerId: string): Promise<Order[]> {
return [...this.orders.values()].filter(o => o.customerId === customerId);
}
}
// テストコード
describe('CreateOrderUseCase', () => {
it('should create an order', async () => {
const repo = new InMemoryOrderRepository(); // DBなしでテスト可能
const useCase = new CreateOrderUseCaseImpl(repo);
const orderId = await useCase.execute(command);
expect(orderId).toBeDefined();
});
});
まとめ
| ポイント | 内容 |
|---|---|
| Port | アプリケーション境界のインターフェース |
| Adapter | Portの具体的な実装 |
| Driving (in) | 外部→アプリケーション(HTTP, CLI, GraphQL) |
| Driven (out) | アプリケーション→外部(DB, API, Email) |
| 利点 | テスト容易性、技術の差し替え、境界の明確化 |
チェックリスト
- Driving PortとDriven Portの違いを説明できる
- Driving AdapterとDriven Adapterの違いを説明できる
- Portがインターフェースであり、Adapterが実装であることを理解した
- テスト時にAdapterを差し替える利点を理解した
次のステップへ
次は「ドメインモデルの隔離」を学びます。ヘキサゴナルアーキテクチャの中心に位置するドメインモデルを、外部の影響から守る方法を理解しましょう。
推定読了時間: 30分