--- 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; save(order: Order): Promise; } // 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 { 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 { 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 { 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(); 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]]