- Published on
Clean Architecture & Hexagonalアーキテクチャ完全ガイド2025:依存性逆転、Ports & Adapters
- Authors

- Name
- Youngju Kim
- @fjvbn20031
目次(もくじ)
1. アーキテクチャの進化(しんか):Big Ball of MudからClean Architectureまで
ソフトウェアアーキテクチャは数十年にわたって進化してきました。各段階は前の段階の問題を解決するために登場しました。
1.1 アーキテクチャ進化の旅(たび)
Big Ball of Mud -> Layered -> Hexagonal -> Onion -> Clean -> Modular
(混沌) (階層化) (ポート/アダプター) (玉ねぎ) (クリーン) (モジュラー)
1990s 2000s 2005 2008 2012 2020s
Big Ball of Mud(混沌の時代)
すべてのコードが絡み合っています。UIコードから直接DBを呼び出し、ビジネスロジックがあちこちに散在しています。
Layered Architecture(階層型アーキテクチャ)
最も伝統的なアプローチです。Presentation - Business Logic - Data Accessの3層に分けます。
+---------------------------+
| Presentation Layer | <- UI, Controllers
+---------------------------+
| Business Logic Layer | <- Services, Rules
+---------------------------+
| Data Access Layer | <- Repositories, ORM
+---------------------------+
| Database |
+---------------------------+
依存性は上から下へ
問題点:
- すべての依存性が下向き → ビジネスロジックがDBに依存
- フレームワーク/DBの交換が困難
- テスト時にDB接続が必要
なぜ依存性逆転(いぞんせいぎゃくてん)が必要なのか
核心的な問い:ビジネスロジックはDBを知る必要があるのか?
伝統的な階層型では、OrderService -> OrderRepository(PostgreSQL) のようにビジネスレイヤーがインフラレイヤーに直接依存します。DBをMongoDBに変えるとビジネスロジックも修正が必要です。
依存性逆転を適用すると:OrderService -> OrderRepository(Interface) <- PostgresOrderRepository
ビジネスレイヤーはインターフェースのみを知り、インフラレイヤーがそのインターフェースを実装します。
2. Hexagonal Architecture(Ports & Adapters)
2.1 Alistair Cockburnのアイデア(2005)
Hexagonal Architectureの核心的なアイデアはシンプルです:アプリケーションを外部世界から隔離(かくり)せよ。
+-----------------------------------+
| Driving Side |
| (Primary/Input Adapters) |
| |
| +------+ +------+ +-------+ |
| | REST | | gRPC | | CLI | |
| | API | | | | | |
| +--+---+ +--+---+ +--+----+ |
| | | | |
| v v v |
| +---------------------------+ |
| | Input Ports | |
| | (Use Cases) | |
| +---------------------------+ |
| | | |
| | Application Core | |
| | (Domain Logic) | |
| | | |
| +---------------------------+ |
| | Output Ports | |
| | (Interfaces) | |
| +---------------------------+ |
| | | | |
| v v v |
| +------+ +-------+ +-------+ |
| | DB | | Email | | Queue | |
| |Adapt.| |Adapt. | |Adapt. | |
| +------+ +-------+ +-------+ |
| |
| Driven Side |
| (Secondary/Output Adapters) |
+-----------------------------------+
2.2 Ports(ポート)
Portはドメインが定義するインターフェースです。
Input Port(Driving Port)
外部からアプリケーションへのリクエストを定義します。
// Input Port:Use Caseインターフェース
interface PlaceOrderUseCase {
execute(command: PlaceOrderCommand): Promise<OrderId>;
}
interface GetOrderUseCase {
execute(query: GetOrderQuery): Promise<OrderDTO>;
}
interface CancelOrderUseCase {
execute(command: CancelOrderCommand): Promise<void>;
}
Output Port(Driven Port)
アプリケーションが外部システムに要求するものを定義します。
// Output Port:Repositoryインターフェース
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
nextId(): OrderId;
}
// Output Port:通知サービスインターフェース
interface NotificationPort {
sendOrderConfirmation(
customerId: CustomerId,
orderId: OrderId
): Promise<void>;
}
// Output Port:決済サービスインターフェース
interface PaymentPort {
processPayment(
orderId: OrderId,
amount: Money
): Promise<PaymentResult>;
}
2.3 Adapters(アダプター)
AdapterはPortを実装する外部技術です。
Primary Adapter(Driving Adapter)
外部リクエストをInput Portに転送します。
// Primary Adapter:REST Controller
class OrderController {
constructor(
private placeOrder: PlaceOrderUseCase,
private getOrder: GetOrderUseCase,
private cancelOrder: CancelOrderUseCase
) {}
async handlePlaceOrder(req: Request, res: Response): Promise<void> {
const command = new PlaceOrderCommand(
req.body.customerId,
req.body.items.map((item: any) => ({
productId: item.productId,
productName: item.productName,
price: item.price,
quantity: item.quantity,
})),
new Address(
req.body.address.street,
req.body.address.city,
req.body.address.state,
req.body.address.zipCode
)
);
const orderId = await this.placeOrder.execute(command);
res.status(201).json({ orderId: orderId.value });
}
async handleGetOrder(req: Request, res: Response): Promise<void> {
const query = new GetOrderQuery(req.params.orderId);
const order = await this.getOrder.execute(query);
res.json(order);
}
async handleCancelOrder(req: Request, res: Response): Promise<void> {
const command = new CancelOrderCommand(
req.params.orderId,
req.body.reason
);
await this.cancelOrder.execute(command);
res.status(204).send();
}
}
// Primary Adapter:gRPC Handler
class OrderGrpcHandler {
constructor(private placeOrder: PlaceOrderUseCase) {}
async placeOrder(
call: grpc.ServerUnaryCall<PlaceOrderRequest, PlaceOrderResponse>,
callback: grpc.sendUnaryData<PlaceOrderResponse>
): Promise<void> {
const command = this.toCommand(call.request);
const orderId = await this.placeOrder.execute(command);
callback(null, { orderId: orderId.value });
}
}
// Primary Adapter:CLIコマンド
class PlaceOrderCLI {
constructor(private placeOrder: PlaceOrderUseCase) {}
async run(args: string[]): Promise<void> {
const command = this.parseArgs(args);
const orderId = await this.placeOrder.execute(command);
console.log(`Order created: ${orderId.value}`);
}
}
Secondary Adapter(Driven Adapter)
Output Portを実装して外部システムと通信します。
// Secondary Adapter:PostgreSQL Repository
class PostgresOrderRepository implements OrderRepository {
constructor(private pool: Pool) {}
async findById(id: OrderId): Promise<Order | null> {
const result = await this.pool.query(
'SELECT * FROM orders WHERE id = $1',
[id.value]
);
if (result.rows.length === 0) return null;
return this.toDomain(result.rows[0]);
}
async save(order: Order): Promise<void> {
await this.pool.query(
`INSERT INTO orders (id, customer_id, status, total_amount)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET status = $3, total_amount = $4`,
[order.id.value, order.customerId.value,
order.status.value, order.totalAmount.value]
);
}
nextId(): OrderId {
return OrderId.generate();
}
}
// Secondary Adapter:MongoDB Repository(同じPort、異なる実装)
class MongoOrderRepository implements OrderRepository {
constructor(private collection: Collection) {}
async findById(id: OrderId): Promise<Order | null> {
const doc = await this.collection.findOne({ _id: id.value });
if (!doc) return null;
return this.toDomain(doc);
}
async save(order: Order): Promise<void> {
await this.collection.updateOne(
{ _id: order.id.value },
{ $set: this.toDocument(order) },
{ upsert: true }
);
}
nextId(): OrderId {
return OrderId.generate();
}
}
// Secondary Adapter:Email通知
class EmailNotificationAdapter implements NotificationPort {
constructor(private mailer: Mailer) {}
async sendOrderConfirmation(
customerId: CustomerId,
orderId: OrderId
): Promise<void> {
const customer = await this.getCustomerEmail(customerId);
await this.mailer.send({
to: customer.email,
subject: `Order ${orderId.value} Confirmed`,
template: 'order-confirmation',
data: { orderId: orderId.value },
});
}
}
// Secondary Adapter:SMS通知(同じPort、異なる実装)
class SmsNotificationAdapter implements NotificationPort {
constructor(private smsClient: SmsClient) {}
async sendOrderConfirmation(
customerId: CustomerId,
orderId: OrderId
): Promise<void> {
const customer = await this.getCustomerPhone(customerId);
await this.smsClient.send({
to: customer.phone,
message: `Your order ${orderId.value} has been confirmed.`,
});
}
}
3. Clean Architecture(Robert C. Martin, 2012)
3.1 同心円(どうしんえん)レイヤーと依存性ルール
Clean Architectureの核心は依存性ルール(Dependency Rule):依存性は常に外側から内側にのみ向かなければなりません。
+-----------------------------------------------+
| Frameworks & Drivers |
| +---------------------------------------+ |
| | Interface Adapters | |
| | +-------------------------------+ | |
| | | Application Business | | |
| | | Rules | | |
| | | +-----------------------+ | | |
| | | | Enterprise Business | | | |
| | | | Rules | | | |
| | | | (Entities) | | | |
| | | +-----------------------+ | | |
| | | (Use Cases) | | |
| | +-------------------------------+ | |
| | (Controllers, Gateways, Presenters)| |
| +---------------------------------------+ |
| (Web, DB, UI, Devices, External Interfaces) |
+-----------------------------------------------+
依存性の方向:外側 → 内側(常に)
3.2 4つのレイヤー
Layer 1: Entities(エンタープライズビジネスルール)
最も内側のレイヤー。ビジネスの核心ルールを含みます。外部の変化に影響されてはなりません。
// Entity Layer
class Order {
private readonly id: OrderId;
private status: OrderStatus;
private items: OrderLineItem[];
place(): void {
if (this.items.length === 0) {
throw new EmptyOrderError();
}
if (this.status !== OrderStatus.DRAFT) {
throw new InvalidOrderStateError(this.status);
}
this.status = OrderStatus.PLACED;
}
get totalAmount(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero('KRW')
);
}
canCancel(): boolean {
return this.status !== OrderStatus.SHIPPED
&& this.status !== OrderStatus.DELIVERED;
}
}
// Value Object(Entity Layer)
class Money {
constructor(
readonly value: number,
readonly currency: Currency
) {
if (value < 0) throw new NegativeMoneyError();
}
add(other: Money): Money {
this.assertSameCurrency(other);
return new Money(this.value + other.value, this.currency);
}
}
Layer 2: Use Cases(アプリケーションビジネスルール)
アプリケーション固有のビジネスルール。Entityを組み合わせてユーザーシナリオを実装します。
// Use Case Layer
class PlaceOrderUseCase {
constructor(
private orderRepo: OrderRepository, // Output Port
private paymentPort: PaymentPort, // Output Port
private notificationPort: NotificationPort // Output Port
) {}
async execute(command: PlaceOrderCommand): Promise<OrderId> {
// 1. Aggregate生成
const orderId = this.orderRepo.nextId();
const order = new Order(
orderId,
new CustomerId(command.customerId)
);
// 2. アイテム追加
for (const item of command.items) {
order.addItem(
new ProductId(item.productId),
item.productName,
Money.of(item.price, 'KRW'),
new Quantity(item.quantity)
);
}
// 3. 注文実行
order.place();
// 4. 決済処理
const paymentResult = await this.paymentPort.processPayment(
orderId,
order.totalAmount
);
if (!paymentResult.isSuccess) {
throw new PaymentFailedError(paymentResult.reason);
}
// 5. 保存
await this.orderRepo.save(order);
// 6. 通知
await this.notificationPort.sendOrderConfirmation(
order.customerId,
orderId
);
return orderId;
}
}
Layer 3: Interface Adapters
外部と内部をつなぐアダプター。Controller、Presenter、Gatewayがここに属します。
// Interface Adapter:Controller
class OrderRestController {
constructor(
private placeOrderUseCase: PlaceOrderUseCase,
private getOrderUseCase: GetOrderUseCase
) {}
async placeOrder(req: Request, res: Response): Promise<void> {
try {
const command = new PlaceOrderCommand(
req.body.customerId,
req.body.items,
req.body.address
);
const orderId = await this.placeOrderUseCase.execute(command);
res.status(201).json(
OrderPresenter.toCreatedResponse(orderId)
);
} catch (error) {
if (error instanceof EmptyOrderError) {
res.status(400).json({ error: 'Order must have items' });
} else if (error instanceof PaymentFailedError) {
res.status(402).json({ error: 'Payment failed' });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
}
}
// Interface Adapter:Presenter
class OrderPresenter {
static toCreatedResponse(orderId: OrderId): object {
return {
orderId: orderId.value,
message: 'Order placed successfully',
links: {
self: `/orders/${orderId.value}`,
cancel: `/orders/${orderId.value}/cancel`,
},
};
}
static toDetailResponse(order: OrderDTO): object {
return {
id: order.id,
status: order.status,
items: order.items.map(item => ({
product: item.productName,
price: item.price,
quantity: item.quantity,
subtotal: item.subtotal,
})),
total: order.totalAmount,
placedAt: order.placedAt.toISOString(),
};
}
}
Layer 4: Frameworks & Drivers
最も外側のレイヤー。Webフレームワーク、DBドライバー、UIフレームワークなどが属します。
// Framework Layer:Express Setup
import express from 'express';
const app = express();
app.use(express.json());
// Dependency Injection(Composition Root)
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const orderRepo = new PostgresOrderGateway(pool);
const paymentAdapter = new StripePaymentAdapter(process.env.STRIPE_KEY);
const notificationAdapter = new EmailNotificationAdapter(mailer);
const placeOrderUseCase = new PlaceOrderUseCase(
orderRepo,
paymentAdapter,
notificationAdapter
);
const getOrderUseCase = new GetOrderUseCase(orderRepo);
const controller = new OrderRestController(
placeOrderUseCase,
getOrderUseCase
);
// Routes
app.post('/orders', (req, res) => controller.placeOrder(req, res));
app.get('/orders/:id', (req, res) => controller.getOrder(req, res));
app.listen(3000);
4. Onion Architecture(Jeffrey Palermo, 2008)
4.1 玉(たま)ねぎレイヤー構造
Onion ArchitectureはClean Architectureの先行者です。同心円構造は類似していますが、レイヤー名が異なります。
+---------------------------------------------+
| Infrastructure |
| +---------------------------------------+ |
| | Application Services | |
| | +-------------------------------+ | |
| | | Domain Services | | |
| | | +-----------------------+ | | |
| | | | Domain Model | | | |
| | | | (Entities + VOs) | | | |
| | | +-----------------------+ | | |
| | +-------------------------------+ | |
| +---------------------------------------+ |
+---------------------------------------------+
レイヤーの説明:
| レイヤー | 役割(やくわり) | 例 |
|---|---|---|
| Domain Model | コアビジネスエンティティ | Order, Money, Customer |
| Domain Services | 複数エンティティにまたがるドメインロジック | OrderPricingService |
| Application Services | ユースケースの調整、トランザクション管理 | PlaceOrderService |
| Infrastructure | 外部技術の実装 | PostgreSQL, Redis, Email |
5. アーキテクチャ比較(ひかく)(10次元)
| 次元 | Layered | Hexagonal | Onion | Clean |
|---|---|---|---|---|
| 依存性の方向 | 上→下 | 外→内 | 外→内 | 外→内 |
| 核心原則 | 関心事の分離 | ポート/アダプター | ドメイン中心 | 依存性ルール |
| 提案者 | 伝統的 | Cockburn (2005) | Palermo (2008) | Martin (2012) |
| レイヤー数 | 3〜4 | 2(内/外) | 4 | 4 |
| ドメイン隔離 | 弱い | 強い | 強い | 強い |
| テスト容易性 | 普通 | 高い | 高い | 高い |
| FW独立性 | 低い | 高い | 高い | 高い |
| 学習曲線 | 低い | 中程度 | 中程度 | 高い |
| 適合規模 | 小〜中 | 中〜大 | 中〜大 | 大規模 |
| オーバーヘッド | 少ない | 中程度 | 中程度 | 多い |
核心的な共通点:
3つのアーキテクチャ(Hexagonal、Onion、Clean)はすべて同じ目標を持ちます:
- ドメインを中心に置く
- 依存性が内側(ドメイン)にのみ向く
- 外部技術(DB、フレームワーク)に依存しない
違いは主に用語と詳細なレイヤー区分にあります。
6. 依存性逆転の実践(じっせん)適用
6.1 Pythonで実装する依存性逆転
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
# === Domain Layer(最も内側) ===
@dataclass(frozen=True)
class Money:
amount: int
currency: str = "KRW"
def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("Currency mismatch")
return Money(self.amount + other.amount, self.currency)
@dataclass(frozen=True)
class OrderId:
value: str
class Order:
def __init__(self, order_id: OrderId, customer_id: str) -> None:
self.id = order_id
self.customer_id = customer_id
self.items: list[dict] = []
self.status = "DRAFT"
@property
def total_amount(self) -> Money:
total = Money(0)
for item in self.items:
subtotal = Money(item["price"] * item["quantity"])
total = total.add(subtotal)
return total
def add_item(self, product_id: str, name: str,
price: int, quantity: int) -> None:
if self.status != "DRAFT":
raise ValueError("Can only add items to draft orders")
self.items.append({
"product_id": product_id,
"name": name,
"price": price,
"quantity": quantity,
})
def place(self) -> None:
if not self.items:
raise ValueError("Cannot place empty order")
if self.status != "DRAFT":
raise ValueError("Can only place draft orders")
self.status = "PLACED"
# === Port(ドメインが定義するインターフェース) ===
class OrderRepository(ABC):
@abstractmethod
async def find_by_id(self, order_id: OrderId) -> Optional[Order]:
...
@abstractmethod
async def save(self, order: Order) -> None:
...
@abstractmethod
def next_id(self) -> OrderId:
...
class PaymentPort(ABC):
@abstractmethod
async def process_payment(
self, order_id: OrderId, amount: Money
) -> bool:
...
class NotificationPort(ABC):
@abstractmethod
async def send_order_confirmation(
self, customer_id: str, order_id: OrderId
) -> None:
...
# === Use Case Layer ===
@dataclass
class PlaceOrderCommand:
customer_id: str
items: list[dict]
class PlaceOrderUseCase:
def __init__(
self,
order_repo: OrderRepository,
payment_port: PaymentPort,
notification_port: NotificationPort,
) -> None:
self._order_repo = order_repo
self._payment_port = payment_port
self._notification_port = notification_port
async def execute(self, command: PlaceOrderCommand) -> str:
order_id = self._order_repo.next_id()
order = Order(order_id, command.customer_id)
for item in command.items:
order.add_item(
product_id=item["product_id"],
name=item["name"],
price=item["price"],
quantity=item["quantity"],
)
order.place()
success = await self._payment_port.process_payment(
order_id, order.total_amount
)
if not success:
raise ValueError("Payment failed")
await self._order_repo.save(order)
await self._notification_port.send_order_confirmation(
command.customer_id, order_id
)
return order_id.value
# === Adapter Layer(最も外側) ===
from uuid import uuid4
class InMemoryOrderRepository(OrderRepository):
def __init__(self) -> None:
self._store: dict[str, Order] = {}
async def find_by_id(self, order_id: OrderId) -> Optional[Order]:
return self._store.get(order_id.value)
async def save(self, order: Order) -> None:
self._store[order.id.value] = order
def next_id(self) -> OrderId:
return OrderId(str(uuid4()))
class StripePaymentAdapter(PaymentPort):
def __init__(self, api_key: str) -> None:
self._api_key = api_key
async def process_payment(
self, order_id: OrderId, amount: Money
) -> bool:
# Stripe API呼び出し
return True
class ConsoleNotificationAdapter(NotificationPort):
async def send_order_confirmation(
self, customer_id: str, order_id: OrderId
) -> None:
print(
f"Order {order_id.value} confirmed "
f"for customer {customer_id}"
)
# === Composition Root ===
async def main():
# 依存性の組み立て
order_repo = InMemoryOrderRepository()
payment = StripePaymentAdapter("sk_test_xxx")
notification = ConsoleNotificationAdapter()
use_case = PlaceOrderUseCase(order_repo, payment, notification)
# 実行
result = await use_case.execute(
PlaceOrderCommand(
customer_id="cust-1",
items=[
{
"product_id": "prod-1",
"name": "Widget",
"price": 10000,
"quantity": 2,
}
],
)
)
print(f"Order created: {result}")
7. プロジェクト構造(こうぞう)
7.1 TypeScriptプロジェクト構造
src/
├── domain/ # Entity Layer
│ ├── model/
│ │ ├── Order.ts # Aggregate Root
│ │ ├── OrderLineItem.ts # Entity
│ │ ├── Money.ts # Value Object
│ │ ├── OrderId.ts # Value Object
│ │ └── OrderStatus.ts # Enum
│ ├── event/
│ │ ├── OrderPlaced.ts
│ │ └── OrderCancelled.ts
│ ├── service/
│ │ └── OrderPricingService.ts # Domain Service
│ └── port/
│ ├── OrderRepository.ts # Output Port(Interface)
│ ├── PaymentPort.ts # Output Port(Interface)
│ └── NotificationPort.ts # Output Port(Interface)
│
├── application/ # Use Case Layer
│ ├── command/
│ │ ├── PlaceOrderCommand.ts
│ │ ├── PlaceOrderHandler.ts # Use Case実装
│ │ ├── CancelOrderCommand.ts
│ │ └── CancelOrderHandler.ts
│ ├── query/
│ │ ├── GetOrderQuery.ts
│ │ └── GetOrderHandler.ts
│ └── dto/
│ ├── OrderDTO.ts
│ └── OrderItemDTO.ts
│
├── adapter/ # Interface Adapter Layer
│ ├── in/ # Primary(Driving)Adapters
│ │ ├── rest/
│ │ │ ├── OrderController.ts
│ │ │ └── OrderPresenter.ts
│ │ ├── grpc/
│ │ │ └── OrderGrpcHandler.ts
│ │ └── cli/
│ │ └── PlaceOrderCLI.ts
│ └── out/ # Secondary(Driven)Adapters
│ ├── persistence/
│ │ ├── PostgresOrderRepository.ts
│ │ ├── MongoOrderRepository.ts
│ │ └── entity/
│ │ └── OrderEntity.ts # ORM Entity
│ ├── payment/
│ │ ├── StripePaymentAdapter.ts
│ │ └── TossPaymentAdapter.ts
│ ├── notification/
│ │ ├── EmailNotificationAdapter.ts
│ │ └── SmsNotificationAdapter.ts
│ └── messaging/
│ └── KafkaEventPublisher.ts
│
├── config/ # Framework Layer
│ ├── container.ts # DI Container
│ ├── database.ts
│ └── server.ts
│
└── main.ts # Composition Root
7.2 Pythonプロジェクト構造
src/
├── domain/
│ ├── __init__.py
│ ├── model/
│ │ ├── order.py
│ │ ├── order_line_item.py
│ │ └── value_objects.py
│ ├── events/
│ │ └── order_events.py
│ ├── services/
│ │ └── pricing_service.py
│ └── ports/
│ ├── order_repository.py # ABC
│ ├── payment_port.py # ABC
│ └── notification_port.py # ABC
│
├── application/
│ ├── __init__.py
│ ├── commands/
│ │ ├── place_order.py
│ │ └── cancel_order.py
│ ├── queries/
│ │ └── get_order.py
│ └── dto/
│ └── order_dto.py
│
├── adapters/
│ ├── __init__.py
│ ├── inbound/
│ │ ├── fastapi_routes.py
│ │ └── cli.py
│ └── outbound/
│ ├── sqlalchemy_order_repo.py
│ ├── stripe_payment.py
│ └── email_notification.py
│
├── config/
│ ├── container.py # DI
│ └── settings.py
│
└── main.py
8. テスト戦略(せんりゃく)
8.1 レイヤー別テスト
Clean Architectureの最大の利点の1つはテスト容易性です。
Domain Layerテスト(Mock不要)
describe('Order', () => {
it('should place order with items', () => {
const order = new Order(
OrderId.generate(),
new CustomerId('cust-1')
);
order.addItem(
new ProductId('prod-1'),
'Widget',
Money.of(10000, 'KRW'),
new Quantity(2)
);
order.place();
expect(order.status).toBe(OrderStatus.PLACED);
expect(order.totalAmount).toEqual(Money.of(20000, 'KRW'));
});
it('should not place empty order', () => {
const order = new Order(
OrderId.generate(),
new CustomerId('cust-1')
);
expect(() => order.place()).toThrow(EmptyOrderError);
});
});
describe('Money', () => {
it('should add same currency', () => {
const a = Money.of(10000, 'KRW');
const b = Money.of(5000, 'KRW');
expect(a.add(b)).toEqual(Money.of(15000, 'KRW'));
});
it('should reject negative amount', () => {
expect(() => Money.of(-100, 'KRW')).toThrow(NegativeMoneyError);
});
it('should reject currency mismatch', () => {
const krw = Money.of(10000, 'KRW');
const usd = Money.of(10, 'USD');
expect(() => krw.add(usd)).toThrow(CurrencyMismatchError);
});
});
Use Caseテスト(Test Doubleを使用)
describe('PlaceOrderUseCase', () => {
let useCase: PlaceOrderUseCase;
let orderRepo: InMemoryOrderRepository;
let paymentPort: FakePaymentAdapter;
let notificationPort: SpyNotificationAdapter;
beforeEach(() => {
orderRepo = new InMemoryOrderRepository();
paymentPort = new FakePaymentAdapter();
notificationPort = new SpyNotificationAdapter();
useCase = new PlaceOrderUseCase(
orderRepo, paymentPort, notificationPort
);
});
it('should place order successfully', async () => {
paymentPort.willSucceed();
const command = new PlaceOrderCommand(
'cust-1',
[{ productId: 'prod-1', productName: 'Widget',
price: 10000, quantity: 2 }],
new AddressDTO('Street', 'City', 'State', '12345')
);
const orderId = await useCase.execute(command);
expect(orderId).toBeDefined();
const saved = await orderRepo.findById(orderId);
expect(saved).not.toBeNull();
expect(saved!.status).toBe(OrderStatus.PLACED);
expect(notificationPort.wasCalled).toBe(true);
});
it('should fail when payment fails', async () => {
paymentPort.willFail('Insufficient funds');
const command = new PlaceOrderCommand(
'cust-1',
[{ productId: 'prod-1', productName: 'Widget',
price: 10000, quantity: 1 }],
new AddressDTO('Street', 'City', 'State', '12345')
);
await expect(useCase.execute(command))
.rejects.toThrow(PaymentFailedError);
});
});
// Test Doubles
class InMemoryOrderRepository implements OrderRepository {
private store = new Map<string, Order>();
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);
}
nextId(): OrderId { return OrderId.generate(); }
}
class FakePaymentAdapter implements PaymentPort {
private shouldSucceed = true;
willSucceed(): void { this.shouldSucceed = true; }
willFail(reason: string): void { this.shouldSucceed = false; }
async processPayment(): Promise<PaymentResult> {
return this.shouldSucceed
? PaymentResult.success()
: PaymentResult.failure('Declined');
}
}
class SpyNotificationAdapter implements NotificationPort {
wasCalled = false;
async sendOrderConfirmation(): Promise<void> {
this.wasCalled = true;
}
}
9. フレームワーク独立性(どくりつせい)
9.1 ExpressからFastifyへの交換
ドメインとユースケースを一切変更せずにフレームワークだけ交換できます。
// Before:Express
import express from 'express';
const app = express();
app.post('/orders', (req, res) =>
controller.placeOrder(req, res)
);
// After:Fastify(ドメイン/ユースケースのコード変更なし!)
import Fastify from 'fastify';
const fastify = Fastify();
// 新しいPrimary Adapterのみ作成
class OrderFastifyAdapter {
constructor(private useCase: PlaceOrderUseCase) {}
async placeOrder(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const command = new PlaceOrderCommand(
request.body.customerId,
request.body.items,
request.body.address
);
const orderId = await this.useCase.execute(command);
reply.code(201).send({ orderId: orderId.value });
}
}
const adapter = new OrderFastifyAdapter(placeOrderUseCase);
fastify.post('/orders', (req, reply) =>
adapter.placeOrder(req, reply)
);
9.2 PostgreSQLからMongoDBへの交換
// ドメインコード:変更なし
// Use Caseコード:変更なし
// Output Port(Interface):変更なし
// 新しいSecondary Adapterのみ作成
class MongoOrderRepository implements OrderRepository {
constructor(private db: Db) {}
async findById(id: OrderId): Promise<Order | null> {
const doc = await this.db
.collection('orders')
.findOne({ _id: id.value });
return doc ? this.toDomain(doc) : null;
}
async save(order: Order): Promise<void> {
await this.db.collection('orders').updateOne(
{ _id: order.id.value },
{ $set: this.toDocument(order) },
{ upsert: true }
);
}
nextId(): OrderId { return OrderId.generate(); }
}
// Composition Rootのみ変更
// Before:
// const orderRepo = new PostgresOrderRepository(pool);
// After:
const mongoClient = new MongoClient(process.env.MONGO_URL);
const orderRepo = new MongoOrderRepository(mongoClient.db('shop'));
10. よくある間違(まちが)いと注意点
10.1 オーバーエンジニアリング
シンプルなCRUDアプリにClean Architectureを適用すると:
Entity -> Use Case -> Input Port -> Controller
Output Port -> Repository
Output Port -> Notification
Output Port -> Payment
ファイル数:20+
これで十分です:
Controller -> Service -> Repository
ファイル数:3
原則:複雑なドメインロジックがある場合にのみClean Architectureを適用しましょう。
10.2 間違ったレイヤー配置
// 悪い例:ドメインがインフラに依存
class Order {
async save(): Promise<void> {
// ドメインがDBを直接知ってはいけない!
await db.query('INSERT INTO orders ...');
}
}
// 悪い例:Use Caseがフレームワークに依存
class PlaceOrderUseCase {
async execute(req: express.Request): Promise<void> {
// Use CaseがExpressを直接知ってはいけない!
const body = req.body;
}
}
// 良い例:各レイヤーが自分の役割のみ
class Order {
place(): void { /* 純粋なドメインロジック */ }
}
class PlaceOrderUseCase {
execute(command: PlaceOrderCommand): Promise<OrderId> {
/* DTOのみ受け取り、Portを通じて外部と通信 */
}
}
10.3 Leaky Abstraction
// 悪い例:Portが特定技術に依存
interface OrderRepository {
// SQLを直接公開 - PostgreSQLに依存!
findByQuery(sql: string): Promise<Order[]>;
// Mongoose Documentを公開 - MongoDBに依存!
findByMongoQuery(filter: FilterQuery<OrderDoc>): Promise<Order[]>;
}
// 良い例:技術中立的なPort
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
findByCustomer(customerId: CustomerId): Promise<Order[]>;
findByStatus(status: OrderStatus): Promise<Order[]>;
save(order: Order): Promise<void>;
}
10.4 いつ使(つか)い、いつ使わないか
使うべき場合:
- 複雑なビジネスドメイン(金融、Eコマース、医療)
- 長期保守が必要なプロジェクト
- 外部システム交換の可能性がある場合
- チーム規模が大きく境界が必要な場合
- 高いテストカバレッジが求められる場合
使うべきでない場合:
- シンプルなCRUDアプリケーション
- プロトタイプ/MVP
- 短期プロジェクト
- 1人開発の小規模プロジェクト
- ビジネスロジックがほとんどない場合
11. クイズ
Q1. Clean Architectureの依存性ルール(Dependency Rule)とは何ですか?
A1. 依存性ルールとは、ソースコードの依存性は常に内側(高水準のポリシー)にのみ向かなければならないという原則です。外側のレイヤー(フレームワーク、DB)が内側のレイヤー(Use Case、Entity)に依存するのは許可されますが、内側のレイヤーが外側のレイヤーに直接依存するのは禁止されています。これによりドメインはフレームワークやDB変更の影響を受けません。
Q2. Hexagonal ArchitectureにおけるPortとAdapterの違いは何ですか?
A2.
Portはドメイン(アプリケーションコア)が定義するインターフェースです。ドメインが外部とどのように相互作用するかの契約を定めます。AdapterはPortを実装する外部技術です。例えばOrderRepository(Port)をPostgresOrderRepository(Adapter)が実装します。Portはドメインレイヤーに、Adapterはインフラレイヤーに位置します。
Q3. Clean Architecture、Hexagonal Architecture、Onion Architectureの共通点は何ですか?
A3. 3つのアーキテクチャはすべて (1) ドメインを中心に配置、(2) 依存性が内側(ドメイン)にのみ向く、(3) 外部技術(DB、フレームワーク)からドメインを隔離、(4) 依存性逆転原則を適用という共通目標を持ちます。違いは主に用語(レイヤー名)と詳細なレイヤー区分にあります。
Q4. Clean Architectureを使うべきでない場合はいつですか?
A4. Clean Architectureにはオーバーヘッドがあるため、(1) シンプルなCRUDアプリ(ビジネスロジックがほとんどない)、(2) プロトタイプ/MVP(素早い検証が目的)、(3) 短期プロジェクト(保守の必要なし)、(4) 1人の小規模プロジェクト(チーム間の境界不要)には不必要です。このような場合、伝統的な3層アーキテクチャがより効率的です。
Q5. フレームワーク独立性はなぜ重要ですか?実際にフレームワークを交換することはあるのですか?
A5. フレームワーク独立性の真の価値は交換そのものよりもテスト容易性とドメインへの集中にあります。ドメインがフレームワークに依存しなければ、(1) 純粋なユニットテストが可能になり、(2) ドメインロジックが明確になり、(3) フレームワークのアップグレードが容易になります。実際にExpressからFastify、RESTからGraphQLへの移行は頻繁に発生し、その際にドメインコードを触らなくて済むことは大きな利点です。
12. 参考資料(さんこうしりょう)
- Robert C. Martin - "Clean Architecture: A Craftsman's Guide to Software Structure and Design" (2017)
- Alistair Cockburn - "Hexagonal Architecture"(オリジナルブログ記事, 2005)
- Jeffrey Palermo - "The Onion Architecture" (2008)
- Robert C. Martin - "The Clean Architecture"(ブログ記事, 2012)
- Vaughn Vernon - "Implementing Domain-Driven Design"(アーキテクチャパターン章)
- Martin Fowler - "InversionOfControl"(ブログ記事)
- Mark Seemann - "Dependency Injection in .NET"(DIパターン)
- Herberto Graca - "DDD, Hexagonal, Onion, Clean, CQRS"(統合分析ブログ)
- Tom Hombergs - "Get Your Hands Dirty on Clean Architecture"(実践ガイド)
- Netflix Tech Blog - "Ready for Changes with Hexagonal Architecture"
- Uncle Bob - "Architecture the Lost Years"(プレゼンテーション)
- ThoughtWorks Technology Radar - Ports and Adapters Architecture
- Microsoft - "Clean Architecture with ASP.NET Core"
- Steve Smith - "Clean Architecture Template"(GitHub)