- Published on
クリーンアーキテクチャとヘキサゴナルアーキテクチャの実践実装:ポートとアダプターパターンでドメイン独立性を確保
- Authors
- Name
- はじめに
- アーキテクチャ比較:ヘキサゴナル vs クリーン vs オニオン vs レイヤード
- TypeScript実践実装
- Python実践実装
- テスト戦略
- アンチパターンと注意事項
- レイヤードアーキテクチャからのマイグレーション
- 本番環境チェックリスト
- 参考資料

はじめに
ソフトウェアが成長するにつれて最初に崩壊するのは、通常アーキテクチャである。最初はシンプルなレイヤードアーキテクチャで始まったものの、ビジネスロジックがコントローラに浸透し、データベーススキーマの変更がドメインロジックに影響を与え、フレームワークのアップグレードがコードベース全体を揺るがす状況を多くの開発者が経験している。
クリーンアーキテクチャ(Clean Architecture)とヘキサゴナルアーキテクチャ(Hexagonal Architecture、Ports and Adapters)は、この問題を解決するための代表的なアプローチである。両アーキテクチャともに、コアビジネスロジックを外部の関心事(データベース、フレームワーク、UI)から隔離することを目標としている。
本記事では、両アーキテクチャの理論的背景を比較した上で、TypeScriptとPythonで実際に実装する方法をコードとともに解説する。
アーキテクチャ比較:ヘキサゴナル vs クリーン vs オニオン vs レイヤード
コア概念比較表
| 項目 | ヘキサゴナルアーキテクチャ | クリーンアーキテクチャ | オニオンアーキテクチャ | レイヤードアーキテクチャ |
|---|---|---|---|---|
| 提案者 | Alistair Cockburn (2005) | Robert C. Martin (2012) | Jeffrey Palermo (2008) | 伝統的パターン |
| 核心原則 | ポートとアダプター | 依存性ルール | ドメイン中心レイヤー | レイヤー間単方向依存 |
| レイヤー構造 | コア + ポート + アダプター | エンティティ/ユースケース/インターフェースアダプター/フレームワーク | ドメイン/サービス/インフラ | プレゼンテーション/ビジネス/データ |
| 依存性の方向 | 外側 -> 内側 | 外側 -> 内側 | 外側 -> 内側 | 上 -> 下 |
| テスト容易性 | 高い | 高い | 高い | 普通 |
| インフラ交換 | アダプター交換で簡単 | 外部レイヤー交換 | 外部レイヤー交換 | レイヤー全体の修正が必要 |
| 学習曲線 | 普通 | 高い | 普通 | 低い |
| 適切な規模 | 中~大規模 | 大規模 | 中~大規模 | 小~中規模 |
クリーンアーキテクチャの依存性ルール
クリーンアーキテクチャの核心は**依存性ルール(Dependency Rule)**である。依存性は必ず外側から内側に向かわなければならない。
内側レイヤーから外側レイヤーまで4層で構成される:
- Entities(エンティティ): エンタープライズビジネスルール。外部依存性は一切ない。
- Use Cases(ユースケース): アプリケーションビジネスルール。エンティティにのみ依存する。
- Interface Adapters(インターフェースアダプター): ユースケースと外部世界を接続。コントローラ、プレゼンター、ゲートウェイがここに位置する。
- Frameworks & Drivers(フレームワーク): Webフレームワーク、DB、UI等の外部ツール。
ヘキサゴナルアーキテクチャのポートとアダプター
ヘキサゴナルアーキテクチャはアプリケーションを六角形(Hexagon)で表現し、2種類のポートを定義する:
- インバウンドポート(Driving Port): 外部からアプリケーションを呼び出すためのインターフェース(例:ユースケースインターフェース)
- アウトバウンドポート(Driven Port): アプリケーションが外部システムにアクセスするためのインターフェース(例:リポジトリインターフェース)
各ポートには対応するアダプターがある:
- インバウンドアダプター(Driving Adapter): HTTPコントローラ、CLI、メッセージリスナー等
- アウトバウンドアダプター(Driven Adapter): DBリポジトリ、外部APIクライアント、メッセージパブリッシャー等
TypeScript実践実装
プロジェクト構造
src/
domain/ # ドメインレイヤー(エンティティ、バリューオブジェクト)
entities/
Order.ts
Product.ts
value-objects/
Money.ts
OrderStatus.ts
application/ # アプリケーションレイヤー(ユースケース、ポート)
ports/
inbound/
CreateOrderUseCase.ts
GetOrderUseCase.ts
outbound/
OrderRepository.ts
PaymentGateway.ts
EventPublisher.ts
services/
CreateOrderService.ts
GetOrderService.ts
infrastructure/ # インフラストラクチャレイヤー(アダプター)
adapters/
inbound/
http/
OrderController.ts
messaging/
OrderEventListener.ts
outbound/
persistence/
TypeOrmOrderRepository.ts
payment/
StripePaymentAdapter.ts
messaging/
KafkaEventPublisher.ts
config/
DependencyContainer.ts
ドメインレイヤー:エンティティとバリューオブジェクト
// domain/value-objects/Money.ts
export class Money {
private constructor(
private readonly amount: number,
private readonly currency: string
) {
if (amount < 0) {
throw new Error('Amount cannot be negative')
}
}
static of(amount: number, currency: string = 'KRW'): Money {
return new Money(amount, currency)
}
add(other: Money): Money {
this.ensureSameCurrency(other)
return Money.of(this.amount + other.amount, this.currency)
}
multiply(factor: number): Money {
return Money.of(this.amount * factor, this.currency)
}
isGreaterThan(other: Money): boolean {
this.ensureSameCurrency(other)
return this.amount > other.amount
}
getAmount(): number {
return this.amount
}
private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new Error('Currency mismatch')
}
}
}
// domain/value-objects/OrderStatus.ts
export enum OrderStatus {
PENDING = 'PENDING',
CONFIRMED = 'CONFIRMED',
PAID = 'PAID',
SHIPPED = 'SHIPPED',
DELIVERED = 'DELIVERED',
CANCELLED = 'CANCELLED',
}
// domain/entities/Order.ts
import { Money } from '../value-objects/Money'
import { OrderStatus } from '../value-objects/OrderStatus'
export interface OrderItem {
productId: string
productName: string
quantity: number
unitPrice: Money
}
export class Order {
private constructor(
private readonly id: string,
private readonly customerId: string,
private items: OrderItem[],
private status: OrderStatus,
private readonly createdAt: Date
) {}
static create(id: string, customerId: string, items: OrderItem[]): Order {
if (items.length === 0) {
throw new Error('Order must have at least one item')
}
return new Order(id, customerId, items, OrderStatus.PENDING, new Date())
}
confirm(): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error('Only pending orders can be confirmed')
}
this.status = OrderStatus.CONFIRMED
}
pay(): void {
if (this.status !== OrderStatus.CONFIRMED) {
throw new Error('Only confirmed orders can be paid')
}
this.status = OrderStatus.PAID
}
cancel(): void {
if (this.status === OrderStatus.SHIPPED || this.status === OrderStatus.DELIVERED) {
throw new Error('Cannot cancel shipped or delivered orders')
}
this.status = OrderStatus.CANCELLED
}
getTotalAmount(): Money {
return this.items.reduce(
(total, item) => total.add(item.unitPrice.multiply(item.quantity)),
Money.of(0)
)
}
getId(): string {
return this.id
}
getCustomerId(): string {
return this.customerId
}
getStatus(): OrderStatus {
return this.status
}
getItems(): ReadonlyArray<OrderItem> {
return [...this.items]
}
}
アプリケーションレイヤー:ポートとユースケース
// application/ports/inbound/CreateOrderUseCase.ts
export interface CreateOrderCommand {
customerId: string
items: Array<{
productId: string
productName: string
quantity: number
unitPrice: number
}>
}
export interface CreateOrderResult {
orderId: string
totalAmount: number
status: string
}
export interface CreateOrderUseCase {
execute(command: CreateOrderCommand): Promise<CreateOrderResult>
}
// application/ports/outbound/OrderRepository.ts
import { Order } from '../../domain/entities/Order'
export interface OrderRepository {
save(order: Order): Promise<void>
findById(id: string): Promise<Order | null>
findByCustomerId(customerId: string): Promise<Order[]>
}
// application/ports/outbound/PaymentGateway.ts
export interface PaymentRequest {
orderId: string
amount: number
currency: string
}
export interface PaymentResult {
transactionId: string
success: boolean
errorMessage?: string
}
export interface PaymentGateway {
processPayment(request: PaymentRequest): Promise<PaymentResult>
}
// application/ports/outbound/EventPublisher.ts
export interface DomainEvent {
eventType: string
aggregateId: string
payload: Record<string, unknown>
occurredAt: Date
}
export interface EventPublisher {
publish(event: DomainEvent): Promise<void>
}
// application/services/CreateOrderService.ts
import {
CreateOrderUseCase,
CreateOrderCommand,
CreateOrderResult,
} from '../ports/inbound/CreateOrderUseCase'
import { OrderRepository } from '../ports/outbound/OrderRepository'
import { PaymentGateway } from '../ports/outbound/PaymentGateway'
import { EventPublisher } from '../ports/outbound/EventPublisher'
import { Order, OrderItem } from '../../domain/entities/Order'
import { Money } from '../../domain/value-objects/Money'
import { v4 as uuidv4 } from 'uuid'
export class CreateOrderService implements CreateOrderUseCase {
constructor(
private readonly orderRepository: OrderRepository,
private readonly paymentGateway: PaymentGateway,
private readonly eventPublisher: EventPublisher
) {}
async execute(command: CreateOrderCommand): Promise<CreateOrderResult> {
// 1. ドメインオブジェクトの作成
const orderItems: OrderItem[] = command.items.map((item) => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: Money.of(item.unitPrice),
}))
const order = Order.create(uuidv4(), command.customerId, orderItems)
// 2. 注文確認
order.confirm()
// 3. 決済処理
const paymentResult = await this.paymentGateway.processPayment({
orderId: order.getId(),
amount: order.getTotalAmount().getAmount(),
currency: 'KRW',
})
if (!paymentResult.success) {
order.cancel()
await this.orderRepository.save(order)
throw new Error('Payment failed: ' + (paymentResult.errorMessage || 'Unknown error'))
}
// 4. 決済完了処理
order.pay()
// 5. 保存
await this.orderRepository.save(order)
// 6. ドメインイベント発行
await this.eventPublisher.publish({
eventType: 'OrderCreated',
aggregateId: order.getId(),
payload: {
customerId: order.getCustomerId(),
totalAmount: order.getTotalAmount().getAmount(),
itemCount: order.getItems().length,
},
occurredAt: new Date(),
})
return {
orderId: order.getId(),
totalAmount: order.getTotalAmount().getAmount(),
status: order.getStatus(),
}
}
}
インフラストラクチャレイヤー:アダプター実装
// infrastructure/adapters/outbound/persistence/TypeOrmOrderRepository.ts
import { OrderRepository } from '../../../application/ports/outbound/OrderRepository'
import { Order, OrderItem } from '../../../domain/entities/Order'
import { Repository, DataSource } from 'typeorm'
import { OrderEntity } from './entities/OrderEntity'
import { OrderItemEntity } from './entities/OrderItemEntity'
export class TypeOrmOrderRepository implements OrderRepository {
private readonly repository: Repository<OrderEntity>
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(OrderEntity)
}
async save(order: Order): Promise<void> {
const entity = this.toEntity(order)
await this.repository.save(entity)
}
async findById(id: string): Promise<Order | null> {
const entity = await this.repository.findOne({
where: { id },
relations: ['items'],
})
return entity ? this.toDomain(entity) : null
}
async findByCustomerId(customerId: string): Promise<Order[]> {
const entities = await this.repository.find({
where: { customerId },
relations: ['items'],
})
return entities.map((e) => this.toDomain(e))
}
private toEntity(order: Order): OrderEntity {
const entity = new OrderEntity()
entity.id = order.getId()
entity.customerId = order.getCustomerId()
entity.status = order.getStatus()
entity.totalAmount = order.getTotalAmount().getAmount()
return entity
}
private toDomain(entity: OrderEntity): Order {
return Order.create(entity.id, entity.customerId, /* items mapping */ [])
}
}
// infrastructure/adapters/inbound/http/OrderController.ts
import { Router, Request, Response } from 'express'
import { CreateOrderUseCase } from '../../../application/ports/inbound/CreateOrderUseCase'
export class OrderController {
private readonly router: Router
constructor(private readonly createOrderUseCase: CreateOrderUseCase) {
this.router = Router()
this.setupRoutes()
}
private setupRoutes(): void {
this.router.post('/orders', this.createOrder.bind(this))
}
private async createOrder(req: Request, res: Response): Promise<void> {
try {
const result = await this.createOrderUseCase.execute({
customerId: req.body.customerId,
items: req.body.items,
})
res.status(201).json(result)
} catch (error) {
if (error instanceof Error) {
res.status(400).json({ error: error.message })
} else {
res.status(500).json({ error: 'Internal server error' })
}
}
}
getRouter(): Router {
return this.router
}
}
DIコンテナ設定
// infrastructure/config/DependencyContainer.ts
import { DataSource } from 'typeorm'
import { CreateOrderService } from '../../application/services/CreateOrderService'
import { TypeOrmOrderRepository } from '../adapters/outbound/persistence/TypeOrmOrderRepository'
import { StripePaymentAdapter } from '../adapters/outbound/payment/StripePaymentAdapter'
import { KafkaEventPublisher } from '../adapters/outbound/messaging/KafkaEventPublisher'
import { OrderController } from '../adapters/inbound/http/OrderController'
export class DependencyContainer {
private static instance: DependencyContainer
private constructor(private readonly dataSource: DataSource) {}
static async create(dataSource: DataSource): Promise<DependencyContainer> {
if (!DependencyContainer.instance) {
DependencyContainer.instance = new DependencyContainer(dataSource)
}
return DependencyContainer.instance
}
createOrderController(): OrderController {
// アウトバウンドアダプターの作成
const orderRepository = new TypeOrmOrderRepository(this.dataSource)
const paymentGateway = new StripePaymentAdapter()
const eventPublisher = new KafkaEventPublisher()
// ユースケースの作成(ポート実装体を注入)
const createOrderService = new CreateOrderService(
orderRepository,
paymentGateway,
eventPublisher
)
// インバウンドアダプターの作成
return new OrderController(createOrderService)
}
}
Python実践実装
プロジェクト構造とドメインレイヤー
# domain/entities/order.py
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
from typing import List
from domain.value_objects.money import Money
class OrderStatus(Enum):
PENDING = "PENDING"
CONFIRMED = "CONFIRMED"
PAID = "PAID"
SHIPPED = "SHIPPED"
CANCELLED = "CANCELLED"
@dataclass
class OrderItem:
product_id: str
product_name: str
quantity: int
unit_price: Money
class Order:
def __init__(
self,
order_id: str,
customer_id: str,
items: List[OrderItem],
status: OrderStatus = OrderStatus.PENDING,
created_at: datetime = None,
):
if not items:
raise ValueError("Order must have at least one item")
self._id = order_id
self._customer_id = customer_id
self._items = items
self._status = status
self._created_at = created_at or datetime.utcnow()
def confirm(self) -> None:
if self._status != OrderStatus.PENDING:
raise ValueError("Only pending orders can be confirmed")
self._status = OrderStatus.CONFIRMED
def pay(self) -> None:
if self._status != OrderStatus.CONFIRMED:
raise ValueError("Only confirmed orders can be paid")
self._status = OrderStatus.PAID
def cancel(self) -> None:
if self._status in (OrderStatus.SHIPPED,):
raise ValueError("Cannot cancel shipped orders")
self._status = OrderStatus.CANCELLED
@property
def total_amount(self) -> Money:
total = Money(0)
for item in self._items:
total = total.add(item.unit_price.multiply(item.quantity))
return total
@property
def id(self) -> str:
return self._id
@property
def customer_id(self) -> str:
return self._customer_id
@property
def status(self) -> OrderStatus:
return self._status
ポート定義(抽象クラス)
# application/ports/outbound/order_repository.py
from abc import ABC, abstractmethod
from typing import Optional, List
from domain.entities.order import Order
class OrderRepository(ABC):
@abstractmethod
async def save(self, order: Order) -> None:
pass
@abstractmethod
async def find_by_id(self, order_id: str) -> Optional[Order]:
pass
@abstractmethod
async def find_by_customer_id(self, customer_id: str) -> List[Order]:
pass
# application/ports/outbound/payment_gateway.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class PaymentRequest:
order_id: str
amount: float
currency: str
@dataclass
class PaymentResult:
transaction_id: str
success: bool
error_message: str = ""
class PaymentGateway(ABC):
@abstractmethod
async def process_payment(self, request: PaymentRequest) -> PaymentResult:
pass
ユースケース実装
# application/services/create_order_service.py
import uuid
from dataclasses import dataclass
from typing import List
from domain.entities.order import Order, OrderItem
from domain.value_objects.money import Money
from application.ports.outbound.order_repository import OrderRepository
from application.ports.outbound.payment_gateway import PaymentGateway, PaymentRequest
@dataclass
class CreateOrderCommand:
customer_id: str
items: List[dict]
@dataclass
class CreateOrderResult:
order_id: str
total_amount: float
status: str
class CreateOrderService:
def __init__(
self,
order_repository: OrderRepository,
payment_gateway: PaymentGateway,
):
self._order_repository = order_repository
self._payment_gateway = payment_gateway
async def execute(self, command: CreateOrderCommand) -> CreateOrderResult:
items = [
OrderItem(
product_id=item["product_id"],
product_name=item["product_name"],
quantity=item["quantity"],
unit_price=Money(item["unit_price"]),
)
for item in command.items
]
order = Order(
order_id=str(uuid.uuid4()),
customer_id=command.customer_id,
items=items,
)
order.confirm()
payment_result = await self._payment_gateway.process_payment(
PaymentRequest(
order_id=order.id,
amount=order.total_amount.amount,
currency="KRW",
)
)
if not payment_result.success:
order.cancel()
await self._order_repository.save(order)
raise Exception(f"Payment failed: {payment_result.error_message}")
order.pay()
await self._order_repository.save(order)
return CreateOrderResult(
order_id=order.id,
total_amount=order.total_amount.amount,
status=order.status.value,
)
テスト戦略
ドメインユニットテスト
ドメインレイヤーは外部依存性がないため、純粋なユニットテストが可能である。
// tests/domain/entities/Order.test.ts
import { Order, OrderItem } from '../../../domain/entities/Order'
import { Money } from '../../../domain/value-objects/Money'
import { OrderStatus } from '../../../domain/value-objects/OrderStatus'
describe('Order', () => {
const sampleItems: OrderItem[] = [
{
productId: 'prod-1',
productName: 'Widget',
quantity: 2,
unitPrice: Money.of(10000),
},
]
it('should create an order with PENDING status', () => {
const order = Order.create('order-1', 'customer-1', sampleItems)
expect(order.getStatus()).toBe(OrderStatus.PENDING)
})
it('should throw when creating order with empty items', () => {
expect(() => Order.create('order-1', 'customer-1', [])).toThrow(
'Order must have at least one item'
)
})
it('should calculate total amount correctly', () => {
const order = Order.create('order-1', 'customer-1', sampleItems)
expect(order.getTotalAmount().getAmount()).toBe(20000)
})
it('should transition from PENDING to CONFIRMED', () => {
const order = Order.create('order-1', 'customer-1', sampleItems)
order.confirm()
expect(order.getStatus()).toBe(OrderStatus.CONFIRMED)
})
it('should not allow paying before confirming', () => {
const order = Order.create('order-1', 'customer-1', sampleItems)
expect(() => order.pay()).toThrow('Only confirmed orders can be paid')
})
})
ユースケース統合テスト(アダプターのモック)
// tests/application/services/CreateOrderService.test.ts
import { CreateOrderService } from '../../../application/services/CreateOrderService'
import { OrderRepository } from '../../../application/ports/outbound/OrderRepository'
import { PaymentGateway } from '../../../application/ports/outbound/PaymentGateway'
import { EventPublisher } from '../../../application/ports/outbound/EventPublisher'
describe('CreateOrderService', () => {
let service: CreateOrderService
let mockOrderRepo: jest.Mocked<OrderRepository>
let mockPayment: jest.Mocked<PaymentGateway>
let mockEvents: jest.Mocked<EventPublisher>
beforeEach(() => {
mockOrderRepo = {
save: jest.fn().mockResolvedValue(undefined),
findById: jest.fn(),
findByCustomerId: jest.fn(),
}
mockPayment = {
processPayment: jest.fn().mockResolvedValue({
transactionId: 'tx-1',
success: true,
}),
}
mockEvents = {
publish: jest.fn().mockResolvedValue(undefined),
}
service = new CreateOrderService(mockOrderRepo, mockPayment, mockEvents)
})
it('should create and pay for an order successfully', async () => {
const result = await service.execute({
customerId: 'cust-1',
items: [
{
productId: 'prod-1',
productName: 'Widget',
quantity: 1,
unitPrice: 10000,
},
],
})
expect(result.status).toBe('PAID')
expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
expect(mockPayment.processPayment).toHaveBeenCalledTimes(1)
expect(mockEvents.publish).toHaveBeenCalledTimes(1)
})
it('should cancel order when payment fails', async () => {
mockPayment.processPayment.mockResolvedValue({
transactionId: '',
success: false,
errorMessage: 'Insufficient funds',
})
await expect(
service.execute({
customerId: 'cust-1',
items: [
{
productId: 'prod-1',
productName: 'Widget',
quantity: 1,
unitPrice: 10000,
},
],
})
).rejects.toThrow('Payment failed')
expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
})
})
アンチパターンと注意事項
アンチパターン1:ドメインがインフラに依存
// 悪い例:ドメインエンティティがTypeORMデコレータに依存
import { Entity, Column, PrimaryColumn } from 'typeorm'
@Entity() // インフラフレームワーク依存!
export class Order {
@PrimaryColumn()
id: string
@Column()
status: string
}
// 良い例:ドメインエンティティは純粋なクラスとして維持
// 永続化マッピングはインフラレイヤーで別のEntityクラスで処理
export class Order {
private readonly id: string
private status: OrderStatus
// 純粋なビジネスロジックのみを含む
}
アンチパターン2:貧血ドメインモデル(Anemic Domain Model)
// 悪い例:ドメインエンティティが単なるデータ構造
export class Order {
id: string
status: string
items: OrderItem[]
totalAmount: number
// getter/setterのみ、ビジネスロジックなし
}
// ビジネスロジックがすべてサービスに集中
export class OrderService {
confirmOrder(order: Order): void {
if (order.status !== 'PENDING') throw new Error('...')
order.status = 'CONFIRMED'
order.totalAmount = this.calculateTotal(order.items)
}
}
// 良い例:ドメインエンティティ内にビジネスロジックをカプセル化
export class Order {
confirm(): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error('Only pending orders can be confirmed')
}
this.status = OrderStatus.CONFIRMED
}
getTotalAmount(): Money {
return this.items.reduce(
(total, item) => total.add(item.unitPrice.multiply(item.quantity)),
Money.of(0)
)
}
}
アンチパターン3:過剰エンジニアリング(Over-engineering)
小規模なCRUDアプリケーションにヘキサゴナルアーキテクチャを適用すると、ボイラープレートコードが増加するだけで生産性が低下する。アーキテクチャの選択はプロジェクトの複雑さと寿命に合わせるべきである。
レイヤードアーキテクチャからのマイグレーション
段階的マイグレーション戦略
既存のレイヤードアーキテクチャからヘキサゴナル/クリーンアーキテクチャに移行する場合は、段階的にアプローチする必要がある。
- フェーズ1: ドメインレイヤーの分離 -- ビジネスロジックをサービスからエンティティに移動
- フェーズ2: ポート(インターフェース)の定義 -- リポジトリと外部サービスに対するインターフェースの抽出
- フェーズ3: アダプターの実装 -- 既存の実装体をアダプターでラップ
- フェーズ4: DIコンテナの設定 -- 依存性注入でポートとアダプターを接続
- フェーズ5: テストの追加 -- ドメインユニットテストとポートモックテストの作成
バウンダリ決定の失敗事例
誤ったバウンダリ(境界)設定はアーキテクチャの利点を無効化する。例えば、一つのユースケースが多すぎるアウトバウンドポートに依存している場合、そのユースケースの責務が大きすぎるというシグナルである。ユースケースをより小さな単位に分割するのが望ましい。
逆に、ポートを過度に細分化するとポートの数が爆発的に増加し、管理が困難になる。一般的に、1つのAggregate Rootにつき1つのRepositoryポート、1つの外部システムにつき1つのGatewayポートが適切である。
本番環境チェックリスト
アーキテクチャ原則
- 依存性が常に外側から内側に向いているか確認
- ドメインレイヤーにフレームワークのimportがないか確認
- ドメインエンティティが十分なビジネスロジックをカプセル化しているか確認
- ポートインターフェースがドメイン用語で定義されているか確認(技術用語ではなく)
- DIコンテナがComposition Rootでのみ使用されているか確認
テスト
- ドメインレイヤーユニットテストカバレッジ90%以上
- ユースケーステストでアウトバウンドポートがモックされているか確認
- アダプター統合テストが存在するか確認
- E2Eテストで全体フローが検証されているか確認
コード品質
- パッケージ間の循環依存がないか確認
- ユースケースごとの責務が単一であるか確認(SRP)
- ポートインターフェースが最小限のメソッドのみを定義しているか確認(ISP)
- ArchUnitまたは類似ツールでアーキテクチャルールの自動検証