Skip to content
Published on

クリーンアーキテクチャとヘキサゴナルアーキテクチャの実践実装:ポートとアダプターパターンでドメイン独立性を確保

Authors
  • Name
    Twitter
Clean Architecture and Hexagonal Architecture

はじめに

ソフトウェアが成長するにつれて最初に崩壊するのは、通常アーキテクチャである。最初はシンプルなレイヤードアーキテクチャで始まったものの、ビジネスロジックがコントローラに浸透し、データベーススキーマの変更がドメインロジックに影響を与え、フレームワークのアップグレードがコードベース全体を揺るがす状況を多くの開発者が経験している。

クリーンアーキテクチャ(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層で構成される:

  1. Entities(エンティティ): エンタープライズビジネスルール。外部依存性は一切ない。
  2. Use Cases(ユースケース): アプリケーションビジネスルール。エンティティにのみ依存する。
  3. Interface Adapters(インターフェースアダプター): ユースケースと外部世界を接続。コントローラ、プレゼンター、ゲートウェイがここに位置する。
  4. 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. フェーズ1: ドメインレイヤーの分離 -- ビジネスロジックをサービスからエンティティに移動
  2. フェーズ2: ポート(インターフェース)の定義 -- リポジトリと外部サービスに対するインターフェースの抽出
  3. フェーズ3: アダプターの実装 -- 既存の実装体をアダプターでラップ
  4. フェーズ4: DIコンテナの設定 -- 依存性注入でポートとアダプターを接続
  5. フェーズ5: テストの追加 -- ドメインユニットテストとポートモックテストの作成

バウンダリ決定の失敗事例

誤ったバウンダリ(境界)設定はアーキテクチャの利点を無効化する。例えば、一つのユースケースが多すぎるアウトバウンドポートに依存している場合、そのユースケースの責務が大きすぎるというシグナルである。ユースケースをより小さな単位に分割するのが望ましい。

逆に、ポートを過度に細分化するとポートの数が爆発的に増加し、管理が困難になる。一般的に、1つのAggregate Rootにつき1つのRepositoryポート、1つの外部システムにつき1つのGatewayポートが適切である。

本番環境チェックリスト

アーキテクチャ原則

  • 依存性が常に外側から内側に向いているか確認
  • ドメインレイヤーにフレームワークのimportがないか確認
  • ドメインエンティティが十分なビジネスロジックをカプセル化しているか確認
  • ポートインターフェースがドメイン用語で定義されているか確認(技術用語ではなく)
  • DIコンテナがComposition Rootでのみ使用されているか確認

テスト

  • ドメインレイヤーユニットテストカバレッジ90%以上
  • ユースケーステストでアウトバウンドポートがモックされているか確認
  • アダプター統合テストが存在するか確認
  • E2Eテストで全体フローが検証されているか確認

コード品質

  • パッケージ間の循環依存がないか確認
  • ユースケースごとの責務が単一であるか確認(SRP)
  • ポートインターフェースが最小限のメソッドのみを定義しているか確認(ISP)
  • ArchUnitまたは類似ツールでアーキテクチャルールの自動検証

参考資料