- Authors
- Name

はじめに
「データベースをMySQLからPostgreSQLに変更する必要があります。」 「REST APIの代わりにgRPCもサポートする必要があります。」
このような要件を聞いたとき、コード全体を書き直す必要があるなら、アーキテクチャに問題があります。**Hexagonal Architecture(ヘキサゴナルアーキテクチャ)**は、ビジネスロジックを外部依存から完全に分離し、このような変更を容易にします。
核心概念
3つの核心コンポーネント
1. ドメイン(Domain / Core)
- ビジネスロジックの核心
- 外部依存なし(純粋なコード)
- Entity、Value Object、Domain Service
2. ポート(Port)
- ドメインと外部世界のインターフェース(契約)
- Input Port: 外部 → ドメイン(Use Case)
- Output Port: ドメイン → 外部(Repositoryインターフェース)
3. アダプター(Adapter)
- ポートの具体的な実装
- Input Adapter: REST Controller、gRPC Handler、CLI
- Output Adapter: MySQL Repository、Redis Cache、HTTP Client
Layered Architectureとの違い
# Layered Architecture(従来型)
# Controller → Service → Repository → DB
# 依存方向: 上 → 下(DBに従属)
# Hexagonal Architecture
# Adapter → Port → Domain ← Port ← Adapter
# 依存方向: 外側 → 内側(Domainが中心)
Python実践実装
プロジェクト構造
order-service/
├── domain/ # コアドメイン
│ ├── models/
│ │ ├── order.py # Entity
│ │ └── order_item.py # Value Object
│ └── services/
│ └── order_service.py # Domain Service
├── ports/ # ポート(インターフェース)
│ ├── input/
│ │ └── order_use_case.py # Input Port
│ └── output/
│ ├── order_repository.py # Output Port
│ └── payment_gateway.py # Output Port
├── adapters/ # アダプター(実装)
│ ├── input/
│ │ ├── rest_controller.py # REST API
│ │ └── grpc_handler.py # gRPC
│ └── output/
│ ├── postgres_order_repo.py # PostgreSQL実装
│ ├── redis_order_cache.py # Redisキャッシュ
│ └── stripe_payment.py # Stripe決済
└── config/
└── dependency_injection.py # DI設定
ドメインモデル
# domain/models/order.py
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import List
from uuid import UUID, uuid4
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
CANCELLED = "cancelled"
@dataclass
class OrderItem:
product_id: str
product_name: str
quantity: int
unit_price: float
@property
def subtotal(self) -> float:
return self.quantity * self.unit_price
@dataclass
class Order:
"""注文エンティティ — ビジネスルールを含む"""
id: UUID = field(default_factory=uuid4)
customer_id: str = ""
items: List[OrderItem] = field(default_factory=list)
status: OrderStatus = OrderStatus.PENDING
created_at: datetime = field(default_factory=datetime.now)
@property
def total_amount(self) -> float:
return sum(item.subtotal for item in self.items)
def add_item(self, item: OrderItem) -> None:
if self.status != OrderStatus.PENDING:
raise ValueError("確定済みの注文には商品を追加できません")
if item.quantity <= 0:
raise ValueError("数量は1以上である必要があります")
self.items.append(item)
def confirm(self) -> None:
if not self.items:
raise ValueError("商品のない注文は確定できません")
if self.status != OrderStatus.PENDING:
raise ValueError(f"'{self.status.value}'ステータスでは確定できません")
self.status = OrderStatus.CONFIRMED
def cancel(self) -> None:
if self.status == OrderStatus.SHIPPED:
raise ValueError("配送済みの注文はキャンセルできません")
self.status = OrderStatus.CANCELLED
ポート定義
# ports/input/order_use_case.py
from abc import ABC, abstractmethod
from uuid import UUID
from domain.models.order import Order, OrderItem
class CreateOrderUseCase(ABC):
@abstractmethod
def execute(self, customer_id: str, items: list[OrderItem]) -> Order:
pass
class ConfirmOrderUseCase(ABC):
@abstractmethod
def execute(self, order_id: UUID) -> Order:
pass
class CancelOrderUseCase(ABC):
@abstractmethod
def execute(self, order_id: UUID) -> Order:
pass
# ports/output/order_repository.py
from abc import ABC, abstractmethod
from uuid import UUID
from domain.models.order import Order
class OrderRepository(ABC):
@abstractmethod
def save(self, order: Order) -> None:
pass
@abstractmethod
def find_by_id(self, order_id: UUID) -> Order | None:
pass
@abstractmethod
def find_by_customer(self, customer_id: str) -> list[Order]:
pass
# ports/output/payment_gateway.py
from abc import ABC, abstractmethod
from uuid import UUID
class PaymentGateway(ABC):
@abstractmethod
def charge(self, order_id: UUID, amount: float, customer_id: str) -> bool:
pass
@abstractmethod
def refund(self, order_id: UUID) -> bool:
pass
ドメインサービス(Use Case実装)
# domain/services/order_service.py
from uuid import UUID
from domain.models.order import Order, OrderItem
from ports.input.order_use_case import (
CreateOrderUseCase, ConfirmOrderUseCase, CancelOrderUseCase
)
from ports.output.order_repository import OrderRepository
from ports.output.payment_gateway import PaymentGateway
class OrderService(CreateOrderUseCase, ConfirmOrderUseCase, CancelOrderUseCase):
"""注文サービス — Input Portの実装"""
def __init__(
self,
order_repo: OrderRepository,
payment_gateway: PaymentGateway
):
# Output Portに依存(具体的な実装ではない!)
self._order_repo = order_repo
self._payment_gateway = payment_gateway
def execute(self, customer_id: str = None, items: list[OrderItem] = None,
order_id: UUID = None) -> Order:
# dispatch based on params (simplified)
if customer_id and items:
return self._create_order(customer_id, items)
raise ValueError("Invalid parameters")
def _create_order(self, customer_id: str, items: list[OrderItem]) -> Order:
order = Order(customer_id=customer_id)
for item in items:
order.add_item(item)
self._order_repo.save(order)
return order
def confirm_order(self, order_id: UUID) -> Order:
order = self._order_repo.find_by_id(order_id)
if not order:
raise ValueError(f"注文が見つかりません: {order_id}")
# 決済処理
success = self._payment_gateway.charge(
order_id=order.id,
amount=order.total_amount,
customer_id=order.customer_id
)
if not success:
raise ValueError("決済に失敗しました")
order.confirm()
self._order_repo.save(order)
return order
def cancel_order(self, order_id: UUID) -> Order:
order = self._order_repo.find_by_id(order_id)
if not order:
raise ValueError(f"注文が見つかりません: {order_id}")
order.cancel()
self._payment_gateway.refund(order_id)
self._order_repo.save(order)
return order
アダプター実装
# adapters/output/postgres_order_repo.py
import psycopg2
from uuid import UUID
from domain.models.order import Order, OrderItem, OrderStatus
from ports.output.order_repository import OrderRepository
class PostgresOrderRepository(OrderRepository):
def __init__(self, connection_string: str):
self._conn_str = connection_string
def save(self, order: Order) -> None:
with psycopg2.connect(self._conn_str) as conn:
with conn.cursor() as cur:
cur.execute("""
INSERT INTO orders (id, customer_id, status, created_at)
VALUES (%s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET status = %s
""", (str(order.id), order.customer_id,
order.status.value, order.created_at,
order.status.value))
for item in order.items:
cur.execute("""
INSERT INTO order_items
(order_id, product_id, product_name, quantity, unit_price)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT DO NOTHING
""", (str(order.id), item.product_id,
item.product_name, item.quantity, item.unit_price))
def find_by_id(self, order_id: UUID) -> Order | None:
with psycopg2.connect(self._conn_str) as conn:
with conn.cursor() as cur:
cur.execute("SELECT * FROM orders WHERE id = %s", (str(order_id),))
row = cur.fetchone()
if not row:
return None
return self._to_domain(row, cur)
def find_by_customer(self, customer_id: str) -> list[Order]:
# 実装省略
pass
def _to_domain(self, row, cursor) -> Order:
# DB行 → ドメインモデル変換
pass
# adapters/input/rest_controller.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from uuid import UUID
from domain.services.order_service import OrderService
from domain.models.order import OrderItem
app = FastAPI()
class CreateOrderRequest(BaseModel):
customer_id: str
items: list[dict]
class OrderResponse(BaseModel):
id: str
customer_id: str
status: str
total_amount: float
def create_rest_controller(order_service: OrderService):
@app.post("/orders", response_model=OrderResponse)
async def create_order(request: CreateOrderRequest):
items = [
OrderItem(
product_id=i["product_id"],
product_name=i["product_name"],
quantity=i["quantity"],
unit_price=i["unit_price"]
)
for i in request.items
]
order = order_service._create_order(request.customer_id, items)
return OrderResponse(
id=str(order.id),
customer_id=order.customer_id,
status=order.status.value,
total_amount=order.total_amount
)
@app.post("/orders/{order_id}/confirm")
async def confirm_order(order_id: UUID):
try:
order = order_service.confirm_order(order_id)
return {"status": order.status.value}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return app
依存性注入の設定
# config/dependency_injection.py
from domain.services.order_service import OrderService
from adapters.output.postgres_order_repo import PostgresOrderRepository
from adapters.output.stripe_payment import StripePaymentGateway
from adapters.input.rest_controller import create_rest_controller
def bootstrap():
# Output Adapters
order_repo = PostgresOrderRepository(
connection_string="postgresql://user:pass@localhost/orders"
)
payment_gateway = StripePaymentGateway(
api_key="sk_test_xxx"
)
# Domain Service(Port実装)
order_service = OrderService(
order_repo=order_repo,
payment_gateway=payment_gateway
)
# Input Adapter
app = create_rest_controller(order_service)
return app
# DBを変更したい場合は?
# PostgresOrderRepository → MongoOrderRepositoryに差し替えるだけ!
# ドメインコードの変更は不要!
テスト戦略
ドメイン単体テスト(外部依存なし)
import pytest
from domain.models.order import Order, OrderItem, OrderStatus
class TestOrder:
def test_add_item(self):
order = Order(customer_id="C001")
item = OrderItem("P001", "ノートPC", 1, 1500000)
order.add_item(item)
assert len(order.items) == 1
assert order.total_amount == 1500000
def test_cannot_add_item_to_confirmed_order(self):
order = Order(customer_id="C001")
order.add_item(OrderItem("P001", "ノートPC", 1, 1500000))
order.confirm()
with pytest.raises(ValueError, match="確定済みの注文"):
order.add_item(OrderItem("P002", "マウス", 1, 50000))
def test_cannot_confirm_empty_order(self):
order = Order(customer_id="C001")
with pytest.raises(ValueError, match="商品のない"):
order.confirm()
def test_cannot_cancel_shipped_order(self):
order = Order(customer_id="C001")
order.add_item(OrderItem("P001", "ノートPC", 1, 1500000))
order.confirm()
order.status = OrderStatus.SHIPPED
with pytest.raises(ValueError, match="配送済み"):
order.cancel()
Mockを使ったサービステスト
from unittest.mock import MagicMock
from domain.services.order_service import OrderService
from domain.models.order import Order, OrderItem
class TestOrderService:
def setup_method(self):
self.mock_repo = MagicMock()
self.mock_payment = MagicMock()
self.service = OrderService(self.mock_repo, self.mock_payment)
def test_create_order(self):
items = [OrderItem("P001", "ノートPC", 1, 1500000)]
order = self.service._create_order("C001", items)
assert order.customer_id == "C001"
assert len(order.items) == 1
self.mock_repo.save.assert_called_once()
def test_confirm_order_with_payment(self):
order = Order(customer_id="C001")
order.add_item(OrderItem("P001", "ノートPC", 1, 1500000))
self.mock_repo.find_by_id.return_value = order
self.mock_payment.charge.return_value = True
result = self.service.confirm_order(order.id)
self.mock_payment.charge.assert_called_once()
assert result.status.value == "confirmed"
まとめ
Hexagonal Architectureの核心的な価値:
- ドメインの独立性: ビジネスロジックがDB・フレームワークに依存しない
- 交換の容易さ: アダプターを差し替えるだけで外部システムを変更可能
- テストの容易さ: ドメインはMockなしで、サービスはポートのMockでテスト
- ポートが契約: インターフェース(Port)が内部と外部の明確な契約の役割を果たす
クイズ(6問)
Q1. Hexagonal Architectureの3つの核心コンポーネントは? Domain(Core)、Port(インターフェース)、Adapter(実装)
Q2. Input PortとOutput Portの違いは? Input Port: 外部からドメインへ(Use Case)。Output Port: ドメインから外部へ(Repositoryインターフェース)
Q3. Hexagonal Architectureにおける依存方向は? 外側(Adapter)→ 内側(Domain)。ドメインは外部に依存しない
Q4. DBをMySQLからPostgreSQLに変更する際に修正が必要なものは? Output Adapterのみ差し替え(ドメインコードの変更は不要)
Q5. ドメイン単体テストでMockが不要な理由は? ドメインモデルに外部依存がないため、純粋なロジックのみテスト可能
Q6. Layered Architectureに対するHexagonalの主な利点は? ビジネスロジックがDB/フレームワークに依存しないため、交換とテストが容易