Files
2nd/10_Wiki/Topics/Coding/Arch_Hexagonal_Clean.md
T
2026-05-09 21:08:02 +09:00

6.7 KiB

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
arch-hexagonal-clean Hexagonal / Clean Architecture — Port / Adapter / 의존 방향 Coding draft B conceptual 2026-05-09 2026-05-09
architecture
hexagonal
clean
ddd
vibe-coding
language applicable_to
TS
Backend
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)

// 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

// 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)

// 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)

// 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)

// 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)

// 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)

// 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)

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)

// .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.

🔗 관련 문서