244 lines
6.7 KiB
Markdown
244 lines
6.7 KiB
Markdown
---
|
|
id: arch-hexagonal-clean
|
|
title: Hexagonal / Clean Architecture — Port / Adapter / 의존 방향
|
|
category: Coding
|
|
status: draft
|
|
source_trust_level: B
|
|
verification_status: conceptual
|
|
created_at: 2026-05-09
|
|
updated_at: 2026-05-09
|
|
tags: [architecture, hexagonal, clean, ddd, vibe-coding]
|
|
tech_stack: { language: "TS", applicable_to: ["Backend"] }
|
|
applied_in: []
|
|
aliases: [hexagonal, ports and adapters, clean architecture, onion, dependency inversion]
|
|
---
|
|
|
|
# Hexagonal / Clean Architecture
|
|
|
|
> **도메인 = 중심, infrastructure = 가장자리**. Port (interface) + Adapter (구현). 의존 방향 = 항상 안쪽 (도메인). 테스트 = adapter mock 으로 도메인만.
|
|
|
|
## 📖 핵심 개념
|
|
- Domain: 비즈니스 로직 + entity. 외부 의존 없음.
|
|
- Application: use case (orchestration).
|
|
- Adapter: HTTP / DB / queue / 외부 API.
|
|
- Port: domain ↔ adapter 사이 interface.
|
|
|
|
## 💻 코드 패턴
|
|
|
|
### Folder
|
|
```
|
|
src/
|
|
domain/ (pure)
|
|
user.ts
|
|
order.ts
|
|
application/ (use case)
|
|
createOrder.ts
|
|
ports/ (interface)
|
|
orderRepository.ts
|
|
paymentGateway.ts
|
|
adapters/ (구현)
|
|
in/ (driving — HTTP, queue 받음)
|
|
httpController.ts
|
|
out/ (driven — DB, API 호출)
|
|
pgOrderRepository.ts
|
|
stripePaymentGateway.ts
|
|
config.ts
|
|
```
|
|
|
|
### Domain (pure)
|
|
```ts
|
|
// domain/order.ts
|
|
export class Order {
|
|
constructor(public readonly id: string, public items: Item[], public status: OrderStatus) {}
|
|
|
|
addItem(item: Item) {
|
|
if (this.status !== 'open') throw new Error('order closed');
|
|
this.items.push(item);
|
|
}
|
|
|
|
total(): Decimal {
|
|
return this.items.reduce((sum, i) => sum.add(i.price.mul(i.qty)), new Decimal(0));
|
|
}
|
|
}
|
|
```
|
|
|
|
→ DB / HTTP / 시간 의존 X. 순수.
|
|
|
|
### Port
|
|
```ts
|
|
// ports/orderRepository.ts
|
|
export interface OrderRepository {
|
|
find(id: string): Promise<Order | null>;
|
|
save(order: Order): Promise<void>;
|
|
}
|
|
|
|
// ports/paymentGateway.ts
|
|
export interface PaymentGateway {
|
|
charge(amount: Decimal, currency: string): Promise<{ id: string }>;
|
|
}
|
|
```
|
|
|
|
### Application (use case)
|
|
```ts
|
|
// application/createOrder.ts
|
|
export class CreateOrderUseCase {
|
|
constructor(
|
|
private orders: OrderRepository,
|
|
private payment: PaymentGateway,
|
|
) {}
|
|
|
|
async execute(input: CreateOrderInput): Promise<Order> {
|
|
const order = new Order(uuid(), input.items, 'open');
|
|
const total = order.total();
|
|
|
|
await this.payment.charge(total, 'USD');
|
|
order.markPaid();
|
|
|
|
await this.orders.save(order);
|
|
return order;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Adapter — out (DB)
|
|
```ts
|
|
// adapters/out/pgOrderRepository.ts
|
|
export class PgOrderRepository implements OrderRepository {
|
|
constructor(private db: Drizzle) {}
|
|
|
|
async find(id: string): Promise<Order | null> {
|
|
const row = await this.db.select().from(ordersTable).where(eq(ordersTable.id, id)).limit(1);
|
|
if (!row[0]) return null;
|
|
return toDomain(row[0]); // mapper
|
|
}
|
|
|
|
async save(order: Order): Promise<void> {
|
|
await this.db.insert(ordersTable).values(toRow(order)).onConflictDoUpdate(...);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Adapter — out (External API)
|
|
```ts
|
|
// adapters/out/stripePaymentGateway.ts
|
|
export class StripePaymentGateway implements PaymentGateway {
|
|
constructor(private stripe: Stripe) {}
|
|
|
|
async charge(amount: Decimal, currency: string): Promise<{ id: string }> {
|
|
const intent = await this.stripe.paymentIntents.create({
|
|
amount: amount.mul(100).toNumber(),
|
|
currency,
|
|
});
|
|
return { id: intent.id };
|
|
}
|
|
}
|
|
```
|
|
|
|
### Adapter — in (HTTP)
|
|
```ts
|
|
// adapters/in/httpController.ts
|
|
export function makeOrderRouter(useCase: CreateOrderUseCase) {
|
|
const router = express.Router();
|
|
router.post('/orders', async (req, res) => {
|
|
const input = CreateOrderSchema.parse(req.body);
|
|
const order = await useCase.execute(input);
|
|
res.json({ id: order.id, total: order.total().toString() });
|
|
});
|
|
return router;
|
|
}
|
|
```
|
|
|
|
### Composition root (config)
|
|
```ts
|
|
// config.ts — 한 곳에서 의존 주입
|
|
const db = drizzle(new Pool(...));
|
|
const stripe = new Stripe(...);
|
|
|
|
const orderRepo = new PgOrderRepository(db);
|
|
const payment = new StripePaymentGateway(stripe);
|
|
const createOrder = new CreateOrderUseCase(orderRepo, payment);
|
|
|
|
const app = express();
|
|
app.use('/api', makeOrderRouter(createOrder));
|
|
```
|
|
|
|
### Test (mock adapter)
|
|
```ts
|
|
class FakeOrderRepository implements OrderRepository {
|
|
private store = new Map<string, Order>();
|
|
async find(id: string) { return this.store.get(id) ?? null; }
|
|
async save(o: Order) { this.store.set(o.id, o); }
|
|
}
|
|
|
|
class FakePaymentGateway implements PaymentGateway {
|
|
charges: { amount: Decimal }[] = [];
|
|
async charge(amount: Decimal) {
|
|
this.charges.push({ amount });
|
|
return { id: 'fake-' + this.charges.length };
|
|
}
|
|
}
|
|
|
|
test('createOrder', async () => {
|
|
const orders = new FakeOrderRepository();
|
|
const payment = new FakePaymentGateway();
|
|
const uc = new CreateOrderUseCase(orders, payment);
|
|
|
|
const order = await uc.execute({ items: [...] });
|
|
expect(payment.charges).toHaveLength(1);
|
|
expect(await orders.find(order.id)).not.toBeNull();
|
|
});
|
|
```
|
|
|
|
→ DB / Stripe 없이 빠른 unit test.
|
|
|
|
### 의존 방향 강제 (lint)
|
|
```js
|
|
// .eslintrc — dependency-cruiser 또는 import 규칙
|
|
// domain 가 ports/adapters import 금지
|
|
// application 이 adapters import 금지
|
|
{
|
|
"import/no-restricted-paths": ["error", {
|
|
"zones": [
|
|
{ "target": "src/domain", "from": "src/(application|adapters|ports)" },
|
|
{ "target": "src/application", "from": "src/adapters" },
|
|
]
|
|
}]
|
|
}
|
|
```
|
|
|
|
### Onion (변형)
|
|
```
|
|
Domain → Domain Services → Application → Infrastructure
|
|
```
|
|
|
|
비슷한 의존 방향 — 이름만 다름.
|
|
|
|
## 🤔 의사결정 기준
|
|
| 상황 | 추천 |
|
|
|---|---|
|
|
| 큰 팀 / 복잡 도메인 | Hexagonal / Clean |
|
|
| 작은 CRUD | overkill — 일반 layered |
|
|
| 단순 script | 직접 |
|
|
| 마이크로서비스 (작은 service) | service 안 단순 layered |
|
|
| 큰 monolith | hexagonal 가 분리 |
|
|
| Multiple delivery (HTTP + queue + CLI) | 매우 적합 |
|
|
|
|
## ❌ 안티패턴
|
|
- **Domain 안 ORM annotation**: 의존 누설.
|
|
- **Port = repo 1:1 매핑 모든 method**: god interface. UseCase 별 작은 port.
|
|
- **Adapter 가 도메인 직접 변형**: domain mapper 따로.
|
|
- **DTO = Domain entity**: serialization 변경 = 도메인 변경.
|
|
- **모든 API 가 use case**: 단순 read 도 use case — overhead. Query / Command 분리.
|
|
- **Layer 가 너무 많음**: 5 layer cross 매번. 2-3 layer.
|
|
- **Composition root 분산**: DI container 또는 한 곳.
|
|
|
|
## 🤖 LLM 활용 힌트
|
|
- Domain (pure) → Application → Adapters 3 layer.
|
|
- Port (interface) + Adapter 가 키워드.
|
|
- Test = mock adapter, real domain.
|
|
|
|
## 🔗 관련 문서
|
|
- [[Arch_DDD_Bounded_Context]]
|
|
- [[Arch_Aggregate_Design]]
|
|
- [[Backend_Multi_Tenant_Architecture]]
|