Skip to content

Split View: 클린 아키텍처와 헥사고날 아키텍처 실전 구현: 포트와 어댑터 패턴으로 도메인 독립성 확보

✨ Learn with Quiz
|

클린 아키텍처와 헥사고날 아키텍처 실전 구현: 포트와 어댑터 패턴으로 도메인 독립성 확보

Clean Architecture and Hexagonal Architecture

들어가며

소프트웨어가 성장하면서 가장 먼저 무너지는 것은 대개 아키텍처다. 처음에는 간단한 레이어드 아키텍처로 시작했지만, 비즈니스 로직이 컨트롤러에 침투하고, 데이터베이스 스키마 변경이 도메인 로직에 영향을 미치며, 프레임워크 업그레이드가 전체 코드베이스를 흔들어놓는 상황을 많은 개발자가 경험한다.

클린 아키텍처(Clean Architecture)와 헥사고날 아키텍처(Hexagonal Architecture, Ports and Adapters)는 이 문제를 해결하기 위한 대표적인 접근법이다. 두 아키텍처 모두 핵심 비즈니스 로직을 외부 관심사(데이터베이스, 프레임워크, UI)로부터 격리하는 것을 목표로 한다.

이 글에서는 두 아키텍처의 이론적 배경을 비교한 뒤, TypeScript와 Python으로 실제 구현하는 방법을 코드와 함께 설명한다.

아키텍처 비교: 헥사고날 vs 클린 vs 어니언 vs 레이어드

핵심 개념 비교 표

항목헥사고날 아키텍처클린 아키텍처어니언 아키텍처레이어드 아키텍처
제안자Alistair Cockburn (2005)Robert C. Martin (2012)Jeffrey Palermo (2008)전통적 패턴
핵심 원칙포트와 어댑터의존성 규칙도메인 중심 레이어계층 간 단방향 의존
레이어 구조코어 + 포트 + 어댑터엔티티/유스케이스/인터페이스 어댑터/프레임워크도메인/서비스/인프라프레젠테이션/비즈니스/데이터
의존성 방향외부 -> 내부외부 -> 내부외부 -> 내부위 -> 아래
테스트 용이성높음높음높음보통
인프라 교체어댑터 교체로 간단외부 레이어 교체외부 레이어 교체전체 레이어 수정 필요
학습 곡선보통높음보통낮음
적합한 규모중~대규모대규모중~대규모소~중규모

클린 아키텍처의 의존성 규칙

클린 아키텍처의 핵심은 **의존성 규칙(Dependency Rule)**이다. 의존성은 반드시 바깥쪽에서 안쪽으로만 향해야 한다.

안쪽 레이어부터 바깥쪽 레이어까지 4계층으로 구성된다:

  1. Entities (엔티티): 엔터프라이즈 비즈니스 규칙. 외부 의존성이 전혀 없다.
  2. Use Cases (유스케이스): 애플리케이션 비즈니스 규칙. 엔티티만 의존한다.
  3. Interface Adapters (인터페이스 어댑터): 유스케이스와 외부 세계를 연결. 컨트롤러, 프레젠터, 게이트웨이가 여기 위치한다.
  4. Frameworks & Drivers (프레임워크): 웹 프레임워크, DB, UI 등 외부 도구.

헥사고날 아키텍처의 포트와 어댑터

헥사고날 아키텍처는 애플리케이션을 육각형(Hexagon)으로 표현하며, 두 종류의 포트를 정의한다:

  • 인바운드 포트 (Driving Port): 외부에서 애플리케이션을 호출하기 위한 인터페이스 (예: 유스케이스 인터페이스)
  • 아웃바운드 포트 (Driven Port): 애플리케이션이 외부 시스템에 접근하기 위한 인터페이스 (예: 리포지토리 인터페이스)

각 포트에는 대응하는 어댑터가 있다:

  • 인바운드 어댑터 (Driving Adapter): HTTP 컨트롤러, CLI, 메시지 리스너 등
  • 아웃바운드 어댑터 (Driven Adapter): DB 리포지토리, 외부 API 클라이언트, 메시지 발행자 등

TypeScript 실전 구현

프로젝트 구조

src/
  domain/           # 도메인 레이어 (엔티티, 밸류 오브젝트)
    entities/
      Order.ts
      Product.ts
    value-objects/
      Money.ts
      OrderStatus.ts
  application/      # 애플리케이션 레이어 (유스케이스, 포트)
    ports/
      inbound/
        CreateOrderUseCase.ts
        GetOrderUseCase.ts
      outbound/
        OrderRepository.ts
        PaymentGateway.ts
        EventPublisher.ts
    services/
      CreateOrderService.ts
      GetOrderService.ts
  infrastructure/   # 인프라스트럭처 레이어 (어댑터)
    adapters/
      inbound/
        http/
          OrderController.ts
        messaging/
          OrderEventListener.ts
      outbound/
        persistence/
          TypeOrmOrderRepository.ts
        payment/
          StripePaymentAdapter.ts
        messaging/
          KafkaEventPublisher.ts
    config/
      DependencyContainer.ts

도메인 레이어: 엔티티와 밸류 오브젝트

// domain/value-objects/Money.ts
export class Money {
  private constructor(
    private readonly amount: number,
    private readonly currency: string
  ) {
    if (amount < 0) {
      throw new Error('Amount cannot be negative')
    }
  }

  static of(amount: number, currency: string = 'KRW'): Money {
    return new Money(amount, currency)
  }

  add(other: Money): Money {
    this.ensureSameCurrency(other)
    return Money.of(this.amount + other.amount, this.currency)
  }

  multiply(factor: number): Money {
    return Money.of(this.amount * factor, this.currency)
  }

  isGreaterThan(other: Money): boolean {
    this.ensureSameCurrency(other)
    return this.amount > other.amount
  }

  getAmount(): number {
    return this.amount
  }

  private ensureSameCurrency(other: Money): void {
    if (this.currency !== other.currency) {
      throw new Error('Currency mismatch')
    }
  }
}

// domain/value-objects/OrderStatus.ts
export enum OrderStatus {
  PENDING = 'PENDING',
  CONFIRMED = 'CONFIRMED',
  PAID = 'PAID',
  SHIPPED = 'SHIPPED',
  DELIVERED = 'DELIVERED',
  CANCELLED = 'CANCELLED',
}
// domain/entities/Order.ts
import { Money } from '../value-objects/Money'
import { OrderStatus } from '../value-objects/OrderStatus'

export interface OrderItem {
  productId: string
  productName: string
  quantity: number
  unitPrice: Money
}

export class Order {
  private constructor(
    private readonly id: string,
    private readonly customerId: string,
    private items: OrderItem[],
    private status: OrderStatus,
    private readonly createdAt: Date
  ) {}

  static create(id: string, customerId: string, items: OrderItem[]): Order {
    if (items.length === 0) {
      throw new Error('Order must have at least one item')
    }
    return new Order(id, customerId, items, OrderStatus.PENDING, new Date())
  }

  confirm(): void {
    if (this.status !== OrderStatus.PENDING) {
      throw new Error('Only pending orders can be confirmed')
    }
    this.status = OrderStatus.CONFIRMED
  }

  pay(): void {
    if (this.status !== OrderStatus.CONFIRMED) {
      throw new Error('Only confirmed orders can be paid')
    }
    this.status = OrderStatus.PAID
  }

  cancel(): void {
    if (this.status === OrderStatus.SHIPPED || this.status === OrderStatus.DELIVERED) {
      throw new Error('Cannot cancel shipped or delivered orders')
    }
    this.status = OrderStatus.CANCELLED
  }

  getTotalAmount(): Money {
    return this.items.reduce(
      (total, item) => total.add(item.unitPrice.multiply(item.quantity)),
      Money.of(0)
    )
  }

  getId(): string {
    return this.id
  }
  getCustomerId(): string {
    return this.customerId
  }
  getStatus(): OrderStatus {
    return this.status
  }
  getItems(): ReadonlyArray<OrderItem> {
    return [...this.items]
  }
}

애플리케이션 레이어: 포트와 유스케이스

// application/ports/inbound/CreateOrderUseCase.ts
export interface CreateOrderCommand {
  customerId: string
  items: Array<{
    productId: string
    productName: string
    quantity: number
    unitPrice: number
  }>
}

export interface CreateOrderResult {
  orderId: string
  totalAmount: number
  status: string
}

export interface CreateOrderUseCase {
  execute(command: CreateOrderCommand): Promise<CreateOrderResult>
}

// application/ports/outbound/OrderRepository.ts
import { Order } from '../../domain/entities/Order'

export interface OrderRepository {
  save(order: Order): Promise<void>
  findById(id: string): Promise<Order | null>
  findByCustomerId(customerId: string): Promise<Order[]>
}

// application/ports/outbound/PaymentGateway.ts
export interface PaymentRequest {
  orderId: string
  amount: number
  currency: string
}

export interface PaymentResult {
  transactionId: string
  success: boolean
  errorMessage?: string
}

export interface PaymentGateway {
  processPayment(request: PaymentRequest): Promise<PaymentResult>
}

// application/ports/outbound/EventPublisher.ts
export interface DomainEvent {
  eventType: string
  aggregateId: string
  payload: Record<string, unknown>
  occurredAt: Date
}

export interface EventPublisher {
  publish(event: DomainEvent): Promise<void>
}
// application/services/CreateOrderService.ts
import {
  CreateOrderUseCase,
  CreateOrderCommand,
  CreateOrderResult,
} from '../ports/inbound/CreateOrderUseCase'
import { OrderRepository } from '../ports/outbound/OrderRepository'
import { PaymentGateway } from '../ports/outbound/PaymentGateway'
import { EventPublisher } from '../ports/outbound/EventPublisher'
import { Order, OrderItem } from '../../domain/entities/Order'
import { Money } from '../../domain/value-objects/Money'
import { v4 as uuidv4 } from 'uuid'

export class CreateOrderService implements CreateOrderUseCase {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly paymentGateway: PaymentGateway,
    private readonly eventPublisher: EventPublisher
  ) {}

  async execute(command: CreateOrderCommand): Promise<CreateOrderResult> {
    // 1. 도메인 객체 생성
    const orderItems: OrderItem[] = command.items.map((item) => ({
      productId: item.productId,
      productName: item.productName,
      quantity: item.quantity,
      unitPrice: Money.of(item.unitPrice),
    }))

    const order = Order.create(uuidv4(), command.customerId, orderItems)

    // 2. 주문 확인
    order.confirm()

    // 3. 결제 처리
    const paymentResult = await this.paymentGateway.processPayment({
      orderId: order.getId(),
      amount: order.getTotalAmount().getAmount(),
      currency: 'KRW',
    })

    if (!paymentResult.success) {
      order.cancel()
      await this.orderRepository.save(order)
      throw new Error('Payment failed: ' + (paymentResult.errorMessage || 'Unknown error'))
    }

    // 4. 결제 완료 처리
    order.pay()

    // 5. 저장
    await this.orderRepository.save(order)

    // 6. 도메인 이벤트 발행
    await this.eventPublisher.publish({
      eventType: 'OrderCreated',
      aggregateId: order.getId(),
      payload: {
        customerId: order.getCustomerId(),
        totalAmount: order.getTotalAmount().getAmount(),
        itemCount: order.getItems().length,
      },
      occurredAt: new Date(),
    })

    return {
      orderId: order.getId(),
      totalAmount: order.getTotalAmount().getAmount(),
      status: order.getStatus(),
    }
  }
}

인프라스트럭처 레이어: 어댑터 구현

// infrastructure/adapters/outbound/persistence/TypeOrmOrderRepository.ts
import { OrderRepository } from '../../../application/ports/outbound/OrderRepository'
import { Order, OrderItem } from '../../../domain/entities/Order'
import { Repository, DataSource } from 'typeorm'

// TypeORM 엔티티 (인프라 관심사)
import { OrderEntity } from './entities/OrderEntity'
import { OrderItemEntity } from './entities/OrderItemEntity'

export class TypeOrmOrderRepository implements OrderRepository {
  private readonly repository: Repository<OrderEntity>

  constructor(dataSource: DataSource) {
    this.repository = dataSource.getRepository(OrderEntity)
  }

  async save(order: Order): Promise<void> {
    const entity = this.toEntity(order)
    await this.repository.save(entity)
  }

  async findById(id: string): Promise<Order | null> {
    const entity = await this.repository.findOne({
      where: { id },
      relations: ['items'],
    })
    return entity ? this.toDomain(entity) : null
  }

  async findByCustomerId(customerId: string): Promise<Order[]> {
    const entities = await this.repository.find({
      where: { customerId },
      relations: ['items'],
    })
    return entities.map((e) => this.toDomain(e))
  }

  private toEntity(order: Order): OrderEntity {
    // 도메인 -> 영속성 변환 로직
    const entity = new OrderEntity()
    entity.id = order.getId()
    entity.customerId = order.getCustomerId()
    entity.status = order.getStatus()
    entity.totalAmount = order.getTotalAmount().getAmount()
    return entity
  }

  private toDomain(entity: OrderEntity): Order {
    // 영속성 -> 도메인 변환 로직
    // Order.reconstitute() 같은 팩토리 메서드를 사용
    return Order.create(entity.id, entity.customerId, /* items mapping */ [])
  }
}
// infrastructure/adapters/inbound/http/OrderController.ts
import { Router, Request, Response } from 'express'
import { CreateOrderUseCase } from '../../../application/ports/inbound/CreateOrderUseCase'

export class OrderController {
  private readonly router: Router

  constructor(private readonly createOrderUseCase: CreateOrderUseCase) {
    this.router = Router()
    this.setupRoutes()
  }

  private setupRoutes(): void {
    this.router.post('/orders', this.createOrder.bind(this))
  }

  private async createOrder(req: Request, res: Response): Promise<void> {
    try {
      const result = await this.createOrderUseCase.execute({
        customerId: req.body.customerId,
        items: req.body.items,
      })
      res.status(201).json(result)
    } catch (error) {
      if (error instanceof Error) {
        res.status(400).json({ error: error.message })
      } else {
        res.status(500).json({ error: 'Internal server error' })
      }
    }
  }

  getRouter(): Router {
    return this.router
  }
}

DI 컨테이너 설정

// infrastructure/config/DependencyContainer.ts
import { DataSource } from 'typeorm'
import { CreateOrderService } from '../../application/services/CreateOrderService'
import { TypeOrmOrderRepository } from '../adapters/outbound/persistence/TypeOrmOrderRepository'
import { StripePaymentAdapter } from '../adapters/outbound/payment/StripePaymentAdapter'
import { KafkaEventPublisher } from '../adapters/outbound/messaging/KafkaEventPublisher'
import { OrderController } from '../adapters/inbound/http/OrderController'

export class DependencyContainer {
  private static instance: DependencyContainer

  private constructor(private readonly dataSource: DataSource) {}

  static async create(dataSource: DataSource): Promise<DependencyContainer> {
    if (!DependencyContainer.instance) {
      DependencyContainer.instance = new DependencyContainer(dataSource)
    }
    return DependencyContainer.instance
  }

  createOrderController(): OrderController {
    // 아웃바운드 어댑터 생성
    const orderRepository = new TypeOrmOrderRepository(this.dataSource)
    const paymentGateway = new StripePaymentAdapter()
    const eventPublisher = new KafkaEventPublisher()

    // 유스케이스 생성 (포트 구현체 주입)
    const createOrderService = new CreateOrderService(
      orderRepository,
      paymentGateway,
      eventPublisher
    )

    // 인바운드 어댑터 생성
    return new OrderController(createOrderService)
  }
}

Python 실전 구현

프로젝트 구조와 도메인 레이어

# domain/entities/order.py
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
from typing import List
from domain.value_objects.money import Money


class OrderStatus(Enum):
    PENDING = "PENDING"
    CONFIRMED = "CONFIRMED"
    PAID = "PAID"
    SHIPPED = "SHIPPED"
    CANCELLED = "CANCELLED"


@dataclass
class OrderItem:
    product_id: str
    product_name: str
    quantity: int
    unit_price: Money


class Order:
    def __init__(
        self,
        order_id: str,
        customer_id: str,
        items: List[OrderItem],
        status: OrderStatus = OrderStatus.PENDING,
        created_at: datetime = None,
    ):
        if not items:
            raise ValueError("Order must have at least one item")
        self._id = order_id
        self._customer_id = customer_id
        self._items = items
        self._status = status
        self._created_at = created_at or datetime.utcnow()

    def confirm(self) -> None:
        if self._status != OrderStatus.PENDING:
            raise ValueError("Only pending orders can be confirmed")
        self._status = OrderStatus.CONFIRMED

    def pay(self) -> None:
        if self._status != OrderStatus.CONFIRMED:
            raise ValueError("Only confirmed orders can be paid")
        self._status = OrderStatus.PAID

    def cancel(self) -> None:
        if self._status in (OrderStatus.SHIPPED,):
            raise ValueError("Cannot cancel shipped orders")
        self._status = OrderStatus.CANCELLED

    @property
    def total_amount(self) -> Money:
        total = Money(0)
        for item in self._items:
            total = total.add(item.unit_price.multiply(item.quantity))
        return total

    @property
    def id(self) -> str:
        return self._id

    @property
    def customer_id(self) -> str:
        return self._customer_id

    @property
    def status(self) -> OrderStatus:
        return self._status

포트 정의 (추상 클래스)

# application/ports/outbound/order_repository.py
from abc import ABC, abstractmethod
from typing import Optional, List
from domain.entities.order import Order


class OrderRepository(ABC):
    @abstractmethod
    async def save(self, order: Order) -> None:
        pass

    @abstractmethod
    async def find_by_id(self, order_id: str) -> Optional[Order]:
        pass

    @abstractmethod
    async def find_by_customer_id(self, customer_id: str) -> List[Order]:
        pass


# application/ports/outbound/payment_gateway.py
from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass
class PaymentRequest:
    order_id: str
    amount: float
    currency: str


@dataclass
class PaymentResult:
    transaction_id: str
    success: bool
    error_message: str = ""


class PaymentGateway(ABC):
    @abstractmethod
    async def process_payment(self, request: PaymentRequest) -> PaymentResult:
        pass

유스케이스 구현

# application/services/create_order_service.py
import uuid
from dataclasses import dataclass
from typing import List
from domain.entities.order import Order, OrderItem
from domain.value_objects.money import Money
from application.ports.outbound.order_repository import OrderRepository
from application.ports.outbound.payment_gateway import PaymentGateway, PaymentRequest


@dataclass
class CreateOrderCommand:
    customer_id: str
    items: List[dict]


@dataclass
class CreateOrderResult:
    order_id: str
    total_amount: float
    status: str


class CreateOrderService:
    def __init__(
        self,
        order_repository: OrderRepository,
        payment_gateway: PaymentGateway,
    ):
        self._order_repository = order_repository
        self._payment_gateway = payment_gateway

    async def execute(self, command: CreateOrderCommand) -> CreateOrderResult:
        # 도메인 객체 생성
        items = [
            OrderItem(
                product_id=item["product_id"],
                product_name=item["product_name"],
                quantity=item["quantity"],
                unit_price=Money(item["unit_price"]),
            )
            for item in command.items
        ]

        order = Order(
            order_id=str(uuid.uuid4()),
            customer_id=command.customer_id,
            items=items,
        )

        order.confirm()

        # 결제 처리
        payment_result = await self._payment_gateway.process_payment(
            PaymentRequest(
                order_id=order.id,
                amount=order.total_amount.amount,
                currency="KRW",
            )
        )

        if not payment_result.success:
            order.cancel()
            await self._order_repository.save(order)
            raise Exception(f"Payment failed: {payment_result.error_message}")

        order.pay()
        await self._order_repository.save(order)

        return CreateOrderResult(
            order_id=order.id,
            total_amount=order.total_amount.amount,
            status=order.status.value,
        )

테스트 전략

도메인 유닛 테스트

도메인 레이어는 외부 의존성이 없으므로 순수 유닛 테스트가 가능하다.

// tests/domain/entities/Order.test.ts
import { Order, OrderItem } from '../../../domain/entities/Order'
import { Money } from '../../../domain/value-objects/Money'
import { OrderStatus } from '../../../domain/value-objects/OrderStatus'

describe('Order', () => {
  const sampleItems: OrderItem[] = [
    {
      productId: 'prod-1',
      productName: 'Widget',
      quantity: 2,
      unitPrice: Money.of(10000),
    },
  ]

  it('should create an order with PENDING status', () => {
    const order = Order.create('order-1', 'customer-1', sampleItems)
    expect(order.getStatus()).toBe(OrderStatus.PENDING)
  })

  it('should throw when creating order with empty items', () => {
    expect(() => Order.create('order-1', 'customer-1', [])).toThrow(
      'Order must have at least one item'
    )
  })

  it('should calculate total amount correctly', () => {
    const order = Order.create('order-1', 'customer-1', sampleItems)
    expect(order.getTotalAmount().getAmount()).toBe(20000)
  })

  it('should transition from PENDING to CONFIRMED', () => {
    const order = Order.create('order-1', 'customer-1', sampleItems)
    order.confirm()
    expect(order.getStatus()).toBe(OrderStatus.CONFIRMED)
  })

  it('should not allow paying before confirming', () => {
    const order = Order.create('order-1', 'customer-1', sampleItems)
    expect(() => order.pay()).toThrow('Only confirmed orders can be paid')
  })
})

유스케이스 통합 테스트 (어댑터 모킹)

// tests/application/services/CreateOrderService.test.ts
import { CreateOrderService } from '../../../application/services/CreateOrderService'
import { OrderRepository } from '../../../application/ports/outbound/OrderRepository'
import { PaymentGateway } from '../../../application/ports/outbound/PaymentGateway'
import { EventPublisher } from '../../../application/ports/outbound/EventPublisher'

describe('CreateOrderService', () => {
  let service: CreateOrderService
  let mockOrderRepo: jest.Mocked<OrderRepository>
  let mockPayment: jest.Mocked<PaymentGateway>
  let mockEvents: jest.Mocked<EventPublisher>

  beforeEach(() => {
    mockOrderRepo = {
      save: jest.fn().mockResolvedValue(undefined),
      findById: jest.fn(),
      findByCustomerId: jest.fn(),
    }
    mockPayment = {
      processPayment: jest.fn().mockResolvedValue({
        transactionId: 'tx-1',
        success: true,
      }),
    }
    mockEvents = {
      publish: jest.fn().mockResolvedValue(undefined),
    }

    service = new CreateOrderService(mockOrderRepo, mockPayment, mockEvents)
  })

  it('should create and pay for an order successfully', async () => {
    const result = await service.execute({
      customerId: 'cust-1',
      items: [
        {
          productId: 'prod-1',
          productName: 'Widget',
          quantity: 1,
          unitPrice: 10000,
        },
      ],
    })

    expect(result.status).toBe('PAID')
    expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
    expect(mockPayment.processPayment).toHaveBeenCalledTimes(1)
    expect(mockEvents.publish).toHaveBeenCalledTimes(1)
  })

  it('should cancel order when payment fails', async () => {
    mockPayment.processPayment.mockResolvedValue({
      transactionId: '',
      success: false,
      errorMessage: 'Insufficient funds',
    })

    await expect(
      service.execute({
        customerId: 'cust-1',
        items: [
          {
            productId: 'prod-1',
            productName: 'Widget',
            quantity: 1,
            unitPrice: 10000,
          },
        ],
      })
    ).rejects.toThrow('Payment failed')

    expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
  })
})

안티패턴과 주의사항

안티패턴 1: 도메인이 인프라에 의존

// 나쁜 예: 도메인 엔티티가 TypeORM 데코레이터에 의존
import { Entity, Column, PrimaryColumn } from 'typeorm'

@Entity() // 인프라 프레임워크 의존!
export class Order {
  @PrimaryColumn()
  id: string

  @Column()
  status: string
  // ...
}

// 좋은 예: 도메인 엔티티는 순수 클래스로 유지
// 영속성 매핑은 인프라 레이어에서 별도의 Entity 클래스로 처리
export class Order {
  private readonly id: string
  private status: OrderStatus
  // 순수 비즈니스 로직만 포함
}

안티패턴 2: 빈약한 도메인 모델 (Anemic Domain Model)

// 나쁜 예: 도메인 엔티티가 데이터 구조에 불과
export class Order {
  id: string
  status: string
  items: OrderItem[]
  totalAmount: number
  // getter/setter만 존재, 비즈니스 로직 없음
}

// 비즈니스 로직이 서비스에 모두 집중
export class OrderService {
  confirmOrder(order: Order): void {
    if (order.status !== 'PENDING') throw new Error('...')
    order.status = 'CONFIRMED'
    order.totalAmount = this.calculateTotal(order.items)
  }
}

// 좋은 예: 도메인 엔티티 안에 비즈니스 로직 캡슐화
export class Order {
  confirm(): void {
    if (this.status !== OrderStatus.PENDING) {
      throw new Error('Only pending orders can be confirmed')
    }
    this.status = OrderStatus.CONFIRMED
  }

  getTotalAmount(): Money {
    return this.items.reduce(
      (total, item) => total.add(item.unitPrice.multiply(item.quantity)),
      Money.of(0)
    )
  }
}

안티패턴 3: 과도한 엔지니어링 (Over-engineering)

작은 CRUD 애플리케이션에 헥사고날 아키텍처를 적용하면 보일러플레이트 코드만 증가하고 생산성이 저하된다. 아키텍처 선택은 프로젝트의 복잡도와 수명에 맞춰야 한다.

레이어드 아키텍처에서 마이그레이션

단계별 마이그레이션 전략

기존 레이어드 아키텍처에서 헥사고날/클린 아키텍처로 전환할 때는 점진적으로 접근해야 한다.

  1. 1단계: 도메인 레이어 분리 -- 비즈니스 로직을 서비스에서 엔티티로 이동
  2. 2단계: 포트(인터페이스) 정의 -- 리포지토리와 외부 서비스에 대한 인터페이스 추출
  3. 3단계: 어댑터 구현 -- 기존 구현체를 어댑터로 래핑
  4. 4단계: DI 컨테이너 설정 -- 의존성 주입으로 포트와 어댑터 연결
  5. 5단계: 테스트 추가 -- 도메인 유닛 테스트와 포트 모킹 테스트 작성

경계 결정의 실패 사례

잘못된 바운더리(경계) 설정은 아키텍처의 이점을 무효화한다. 예를 들어 하나의 유스케이스가 너무 많은 아웃바운드 포트에 의존하면, 해당 유스케이스의 책임이 너무 크다는 신호다. 유스케이스를 더 작은 단위로 분리하는 것이 좋다.

반대로 너무 세밀하게 포트를 나누면 포트의 수가 폭발적으로 증가하여 관리가 어려워진다. 일반적으로 하나의 Aggregate Root당 하나의 Repository 포트, 하나의 외부 시스템당 하나의 Gateway 포트가 적절하다.

프로덕션 체크리스트

아키텍처 원칙

  • 의존성이 항상 바깥쪽에서 안쪽으로만 향하는지 확인
  • 도메인 레이어에 프레임워크 import가 없는지 확인
  • 도메인 엔티티가 충분한 비즈니스 로직을 캡슐화하는지 확인
  • 포트 인터페이스가 도메인 용어로 정의되어 있는지 확인 (기술 용어가 아닌)
  • DI 컨테이너가 컴포지션 루트(Composition Root)에서만 사용되는지 확인

테스트

  • 도메인 레이어 유닛 테스트 커버리지 90% 이상
  • 유스케이스 테스트에서 아웃바운드 포트가 모킹되는지 확인
  • 어댑터 통합 테스트가 존재하는지 확인
  • E2E 테스트에서 전체 흐름이 검증되는지 확인

코드 품질

  • 패키지 간 순환 의존성이 없는지 확인
  • 유스케이스당 책임이 단일한지 확인 (SRP)
  • 포트 인터페이스가 최소한의 메서드만 정의하는지 확인 (ISP)
  • ArchUnit 또는 유사 도구로 아키텍처 규칙 자동 검증

참고자료

Clean Architecture and Hexagonal Architecture in Practice: Achieving Domain Independence with Ports and Adapters

Clean Architecture and Hexagonal Architecture

Introduction

As software grows, the first thing to collapse is usually the architecture. It often starts with a simple layered architecture, but many developers experience situations where business logic leaks into controllers, database schema changes affect domain logic, and framework upgrades shake the entire codebase.

Clean Architecture and Hexagonal Architecture (Ports and Adapters) are representative approaches to solving these problems. Both architectures aim to isolate core business logic from external concerns (databases, frameworks, UI).

This article compares the theoretical backgrounds of both architectures and then explains how to implement them in practice with TypeScript and Python code examples.

Architecture Comparison: Hexagonal vs Clean vs Onion vs Layered

Core Concept Comparison Table

ItemHexagonal ArchitectureClean ArchitectureOnion ArchitectureLayered Architecture
AuthorAlistair Cockburn (2005)Robert C. Martin (2012)Jeffrey Palermo (2008)Traditional pattern
Core PrinciplePorts and AdaptersDependency RuleDomain-centric layersUnidirectional layer deps
Layer StructureCore + Ports + AdaptersEntities/Use Cases/Interface Adapters/FrameworksDomain/Services/InfrastructurePresentation/Business/Data
Dependency DirectionOutside -> InsideOutside -> InsideOutside -> InsideTop -> Bottom
TestabilityHighHighHighModerate
Infra SwapSimple adapter replacementOuter layer replacementOuter layer replacementFull layer modification
Learning CurveModerateHighModerateLow
Suitable ScaleMedium to LargeLargeMedium to LargeSmall to Medium

Clean Architecture's Dependency Rule

The core of Clean Architecture is the Dependency Rule. Dependencies must always point inward -- from the outer layers toward the inner layers.

It consists of four layers from innermost to outermost:

  1. Entities: Enterprise business rules. No external dependencies whatsoever.
  2. Use Cases: Application business rules. Depend only on entities.
  3. Interface Adapters: Connect use cases to the outside world. Controllers, presenters, and gateways reside here.
  4. Frameworks & Drivers: Web frameworks, databases, UI, and other external tools.

Hexagonal Architecture's Ports and Adapters

Hexagonal Architecture represents the application as a hexagon and defines two types of ports:

  • Inbound Ports (Driving Ports): Interfaces for the outside world to invoke the application (e.g., use case interfaces)
  • Outbound Ports (Driven Ports): Interfaces for the application to access external systems (e.g., repository interfaces)

Each port has a corresponding adapter:

  • Inbound Adapters (Driving Adapters): HTTP controllers, CLI, message listeners, etc.
  • Outbound Adapters (Driven Adapters): DB repositories, external API clients, message publishers, etc.

TypeScript Practical Implementation

Project Structure

src/
  domain/           # Domain layer (entities, value objects)
    entities/
      Order.ts
      Product.ts
    value-objects/
      Money.ts
      OrderStatus.ts
  application/      # Application layer (use cases, ports)
    ports/
      inbound/
        CreateOrderUseCase.ts
        GetOrderUseCase.ts
      outbound/
        OrderRepository.ts
        PaymentGateway.ts
        EventPublisher.ts
    services/
      CreateOrderService.ts
      GetOrderService.ts
  infrastructure/   # Infrastructure layer (adapters)
    adapters/
      inbound/
        http/
          OrderController.ts
        messaging/
          OrderEventListener.ts
      outbound/
        persistence/
          TypeOrmOrderRepository.ts
        payment/
          StripePaymentAdapter.ts
        messaging/
          KafkaEventPublisher.ts
    config/
      DependencyContainer.ts

Domain Layer: Entities and Value Objects

// domain/value-objects/Money.ts
export class Money {
  private constructor(
    private readonly amount: number,
    private readonly currency: string
  ) {
    if (amount < 0) {
      throw new Error('Amount cannot be negative')
    }
  }

  static of(amount: number, currency: string = 'KRW'): Money {
    return new Money(amount, currency)
  }

  add(other: Money): Money {
    this.ensureSameCurrency(other)
    return Money.of(this.amount + other.amount, this.currency)
  }

  multiply(factor: number): Money {
    return Money.of(this.amount * factor, this.currency)
  }

  isGreaterThan(other: Money): boolean {
    this.ensureSameCurrency(other)
    return this.amount > other.amount
  }

  getAmount(): number {
    return this.amount
  }

  private ensureSameCurrency(other: Money): void {
    if (this.currency !== other.currency) {
      throw new Error('Currency mismatch')
    }
  }
}

// domain/value-objects/OrderStatus.ts
export enum OrderStatus {
  PENDING = 'PENDING',
  CONFIRMED = 'CONFIRMED',
  PAID = 'PAID',
  SHIPPED = 'SHIPPED',
  DELIVERED = 'DELIVERED',
  CANCELLED = 'CANCELLED',
}
// domain/entities/Order.ts
import { Money } from '../value-objects/Money'
import { OrderStatus } from '../value-objects/OrderStatus'

export interface OrderItem {
  productId: string
  productName: string
  quantity: number
  unitPrice: Money
}

export class Order {
  private constructor(
    private readonly id: string,
    private readonly customerId: string,
    private items: OrderItem[],
    private status: OrderStatus,
    private readonly createdAt: Date
  ) {}

  static create(id: string, customerId: string, items: OrderItem[]): Order {
    if (items.length === 0) {
      throw new Error('Order must have at least one item')
    }
    return new Order(id, customerId, items, OrderStatus.PENDING, new Date())
  }

  confirm(): void {
    if (this.status !== OrderStatus.PENDING) {
      throw new Error('Only pending orders can be confirmed')
    }
    this.status = OrderStatus.CONFIRMED
  }

  pay(): void {
    if (this.status !== OrderStatus.CONFIRMED) {
      throw new Error('Only confirmed orders can be paid')
    }
    this.status = OrderStatus.PAID
  }

  cancel(): void {
    if (this.status === OrderStatus.SHIPPED || this.status === OrderStatus.DELIVERED) {
      throw new Error('Cannot cancel shipped or delivered orders')
    }
    this.status = OrderStatus.CANCELLED
  }

  getTotalAmount(): Money {
    return this.items.reduce(
      (total, item) => total.add(item.unitPrice.multiply(item.quantity)),
      Money.of(0)
    )
  }

  getId(): string {
    return this.id
  }
  getCustomerId(): string {
    return this.customerId
  }
  getStatus(): OrderStatus {
    return this.status
  }
  getItems(): ReadonlyArray<OrderItem> {
    return [...this.items]
  }
}

Application Layer: Ports and Use Cases

// application/ports/inbound/CreateOrderUseCase.ts
export interface CreateOrderCommand {
  customerId: string
  items: Array<{
    productId: string
    productName: string
    quantity: number
    unitPrice: number
  }>
}

export interface CreateOrderResult {
  orderId: string
  totalAmount: number
  status: string
}

export interface CreateOrderUseCase {
  execute(command: CreateOrderCommand): Promise<CreateOrderResult>
}

// application/ports/outbound/OrderRepository.ts
import { Order } from '../../domain/entities/Order'

export interface OrderRepository {
  save(order: Order): Promise<void>
  findById(id: string): Promise<Order | null>
  findByCustomerId(customerId: string): Promise<Order[]>
}

// application/ports/outbound/PaymentGateway.ts
export interface PaymentRequest {
  orderId: string
  amount: number
  currency: string
}

export interface PaymentResult {
  transactionId: string
  success: boolean
  errorMessage?: string
}

export interface PaymentGateway {
  processPayment(request: PaymentRequest): Promise<PaymentResult>
}

// application/ports/outbound/EventPublisher.ts
export interface DomainEvent {
  eventType: string
  aggregateId: string
  payload: Record<string, unknown>
  occurredAt: Date
}

export interface EventPublisher {
  publish(event: DomainEvent): Promise<void>
}
// application/services/CreateOrderService.ts
import {
  CreateOrderUseCase,
  CreateOrderCommand,
  CreateOrderResult,
} from '../ports/inbound/CreateOrderUseCase'
import { OrderRepository } from '../ports/outbound/OrderRepository'
import { PaymentGateway } from '../ports/outbound/PaymentGateway'
import { EventPublisher } from '../ports/outbound/EventPublisher'
import { Order, OrderItem } from '../../domain/entities/Order'
import { Money } from '../../domain/value-objects/Money'
import { v4 as uuidv4 } from 'uuid'

export class CreateOrderService implements CreateOrderUseCase {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly paymentGateway: PaymentGateway,
    private readonly eventPublisher: EventPublisher
  ) {}

  async execute(command: CreateOrderCommand): Promise<CreateOrderResult> {
    // 1. Create domain objects
    const orderItems: OrderItem[] = command.items.map((item) => ({
      productId: item.productId,
      productName: item.productName,
      quantity: item.quantity,
      unitPrice: Money.of(item.unitPrice),
    }))

    const order = Order.create(uuidv4(), command.customerId, orderItems)

    // 2. Confirm order
    order.confirm()

    // 3. Process payment
    const paymentResult = await this.paymentGateway.processPayment({
      orderId: order.getId(),
      amount: order.getTotalAmount().getAmount(),
      currency: 'KRW',
    })

    if (!paymentResult.success) {
      order.cancel()
      await this.orderRepository.save(order)
      throw new Error('Payment failed: ' + (paymentResult.errorMessage || 'Unknown error'))
    }

    // 4. Mark as paid
    order.pay()

    // 5. Persist
    await this.orderRepository.save(order)

    // 6. Publish domain event
    await this.eventPublisher.publish({
      eventType: 'OrderCreated',
      aggregateId: order.getId(),
      payload: {
        customerId: order.getCustomerId(),
        totalAmount: order.getTotalAmount().getAmount(),
        itemCount: order.getItems().length,
      },
      occurredAt: new Date(),
    })

    return {
      orderId: order.getId(),
      totalAmount: order.getTotalAmount().getAmount(),
      status: order.getStatus(),
    }
  }
}

Infrastructure Layer: Adapter Implementations

// infrastructure/adapters/outbound/persistence/TypeOrmOrderRepository.ts
import { OrderRepository } from '../../../application/ports/outbound/OrderRepository'
import { Order, OrderItem } from '../../../domain/entities/Order'
import { Repository, DataSource } from 'typeorm'

import { OrderEntity } from './entities/OrderEntity'
import { OrderItemEntity } from './entities/OrderItemEntity'

export class TypeOrmOrderRepository implements OrderRepository {
  private readonly repository: Repository<OrderEntity>

  constructor(dataSource: DataSource) {
    this.repository = dataSource.getRepository(OrderEntity)
  }

  async save(order: Order): Promise<void> {
    const entity = this.toEntity(order)
    await this.repository.save(entity)
  }

  async findById(id: string): Promise<Order | null> {
    const entity = await this.repository.findOne({
      where: { id },
      relations: ['items'],
    })
    return entity ? this.toDomain(entity) : null
  }

  async findByCustomerId(customerId: string): Promise<Order[]> {
    const entities = await this.repository.find({
      where: { customerId },
      relations: ['items'],
    })
    return entities.map((e) => this.toDomain(e))
  }

  private toEntity(order: Order): OrderEntity {
    const entity = new OrderEntity()
    entity.id = order.getId()
    entity.customerId = order.getCustomerId()
    entity.status = order.getStatus()
    entity.totalAmount = order.getTotalAmount().getAmount()
    return entity
  }

  private toDomain(entity: OrderEntity): Order {
    return Order.create(entity.id, entity.customerId, /* items mapping */ [])
  }
}
// infrastructure/adapters/inbound/http/OrderController.ts
import { Router, Request, Response } from 'express'
import { CreateOrderUseCase } from '../../../application/ports/inbound/CreateOrderUseCase'

export class OrderController {
  private readonly router: Router

  constructor(private readonly createOrderUseCase: CreateOrderUseCase) {
    this.router = Router()
    this.setupRoutes()
  }

  private setupRoutes(): void {
    this.router.post('/orders', this.createOrder.bind(this))
  }

  private async createOrder(req: Request, res: Response): Promise<void> {
    try {
      const result = await this.createOrderUseCase.execute({
        customerId: req.body.customerId,
        items: req.body.items,
      })
      res.status(201).json(result)
    } catch (error) {
      if (error instanceof Error) {
        res.status(400).json({ error: error.message })
      } else {
        res.status(500).json({ error: 'Internal server error' })
      }
    }
  }

  getRouter(): Router {
    return this.router
  }
}

DI Container Setup

// infrastructure/config/DependencyContainer.ts
import { DataSource } from 'typeorm'
import { CreateOrderService } from '../../application/services/CreateOrderService'
import { TypeOrmOrderRepository } from '../adapters/outbound/persistence/TypeOrmOrderRepository'
import { StripePaymentAdapter } from '../adapters/outbound/payment/StripePaymentAdapter'
import { KafkaEventPublisher } from '../adapters/outbound/messaging/KafkaEventPublisher'
import { OrderController } from '../adapters/inbound/http/OrderController'

export class DependencyContainer {
  private static instance: DependencyContainer

  private constructor(private readonly dataSource: DataSource) {}

  static async create(dataSource: DataSource): Promise<DependencyContainer> {
    if (!DependencyContainer.instance) {
      DependencyContainer.instance = new DependencyContainer(dataSource)
    }
    return DependencyContainer.instance
  }

  createOrderController(): OrderController {
    // Create outbound adapters
    const orderRepository = new TypeOrmOrderRepository(this.dataSource)
    const paymentGateway = new StripePaymentAdapter()
    const eventPublisher = new KafkaEventPublisher()

    // Create use case (inject port implementations)
    const createOrderService = new CreateOrderService(
      orderRepository,
      paymentGateway,
      eventPublisher
    )

    // Create inbound adapter
    return new OrderController(createOrderService)
  }
}

Python Practical Implementation

Project Structure and Domain Layer

# domain/entities/order.py
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
from typing import List
from domain.value_objects.money import Money


class OrderStatus(Enum):
    PENDING = "PENDING"
    CONFIRMED = "CONFIRMED"
    PAID = "PAID"
    SHIPPED = "SHIPPED"
    CANCELLED = "CANCELLED"


@dataclass
class OrderItem:
    product_id: str
    product_name: str
    quantity: int
    unit_price: Money


class Order:
    def __init__(
        self,
        order_id: str,
        customer_id: str,
        items: List[OrderItem],
        status: OrderStatus = OrderStatus.PENDING,
        created_at: datetime = None,
    ):
        if not items:
            raise ValueError("Order must have at least one item")
        self._id = order_id
        self._customer_id = customer_id
        self._items = items
        self._status = status
        self._created_at = created_at or datetime.utcnow()

    def confirm(self) -> None:
        if self._status != OrderStatus.PENDING:
            raise ValueError("Only pending orders can be confirmed")
        self._status = OrderStatus.CONFIRMED

    def pay(self) -> None:
        if self._status != OrderStatus.CONFIRMED:
            raise ValueError("Only confirmed orders can be paid")
        self._status = OrderStatus.PAID

    def cancel(self) -> None:
        if self._status in (OrderStatus.SHIPPED,):
            raise ValueError("Cannot cancel shipped orders")
        self._status = OrderStatus.CANCELLED

    @property
    def total_amount(self) -> Money:
        total = Money(0)
        for item in self._items:
            total = total.add(item.unit_price.multiply(item.quantity))
        return total

    @property
    def id(self) -> str:
        return self._id

    @property
    def customer_id(self) -> str:
        return self._customer_id

    @property
    def status(self) -> OrderStatus:
        return self._status

Port Definitions (Abstract Classes)

# application/ports/outbound/order_repository.py
from abc import ABC, abstractmethod
from typing import Optional, List
from domain.entities.order import Order


class OrderRepository(ABC):
    @abstractmethod
    async def save(self, order: Order) -> None:
        pass

    @abstractmethod
    async def find_by_id(self, order_id: str) -> Optional[Order]:
        pass

    @abstractmethod
    async def find_by_customer_id(self, customer_id: str) -> List[Order]:
        pass


# application/ports/outbound/payment_gateway.py
from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass
class PaymentRequest:
    order_id: str
    amount: float
    currency: str


@dataclass
class PaymentResult:
    transaction_id: str
    success: bool
    error_message: str = ""


class PaymentGateway(ABC):
    @abstractmethod
    async def process_payment(self, request: PaymentRequest) -> PaymentResult:
        pass

Use Case Implementation

# application/services/create_order_service.py
import uuid
from dataclasses import dataclass
from typing import List
from domain.entities.order import Order, OrderItem
from domain.value_objects.money import Money
from application.ports.outbound.order_repository import OrderRepository
from application.ports.outbound.payment_gateway import PaymentGateway, PaymentRequest


@dataclass
class CreateOrderCommand:
    customer_id: str
    items: List[dict]


@dataclass
class CreateOrderResult:
    order_id: str
    total_amount: float
    status: str


class CreateOrderService:
    def __init__(
        self,
        order_repository: OrderRepository,
        payment_gateway: PaymentGateway,
    ):
        self._order_repository = order_repository
        self._payment_gateway = payment_gateway

    async def execute(self, command: CreateOrderCommand) -> CreateOrderResult:
        items = [
            OrderItem(
                product_id=item["product_id"],
                product_name=item["product_name"],
                quantity=item["quantity"],
                unit_price=Money(item["unit_price"]),
            )
            for item in command.items
        ]

        order = Order(
            order_id=str(uuid.uuid4()),
            customer_id=command.customer_id,
            items=items,
        )

        order.confirm()

        payment_result = await self._payment_gateway.process_payment(
            PaymentRequest(
                order_id=order.id,
                amount=order.total_amount.amount,
                currency="KRW",
            )
        )

        if not payment_result.success:
            order.cancel()
            await self._order_repository.save(order)
            raise Exception(f"Payment failed: {payment_result.error_message}")

        order.pay()
        await self._order_repository.save(order)

        return CreateOrderResult(
            order_id=order.id,
            total_amount=order.total_amount.amount,
            status=order.status.value,
        )

Testing Strategy

Domain Unit Tests

The domain layer has no external dependencies, enabling pure unit testing.

// tests/domain/entities/Order.test.ts
import { Order, OrderItem } from '../../../domain/entities/Order'
import { Money } from '../../../domain/value-objects/Money'
import { OrderStatus } from '../../../domain/value-objects/OrderStatus'

describe('Order', () => {
  const sampleItems: OrderItem[] = [
    {
      productId: 'prod-1',
      productName: 'Widget',
      quantity: 2,
      unitPrice: Money.of(10000),
    },
  ]

  it('should create an order with PENDING status', () => {
    const order = Order.create('order-1', 'customer-1', sampleItems)
    expect(order.getStatus()).toBe(OrderStatus.PENDING)
  })

  it('should throw when creating order with empty items', () => {
    expect(() => Order.create('order-1', 'customer-1', [])).toThrow(
      'Order must have at least one item'
    )
  })

  it('should calculate total amount correctly', () => {
    const order = Order.create('order-1', 'customer-1', sampleItems)
    expect(order.getTotalAmount().getAmount()).toBe(20000)
  })

  it('should transition from PENDING to CONFIRMED', () => {
    const order = Order.create('order-1', 'customer-1', sampleItems)
    order.confirm()
    expect(order.getStatus()).toBe(OrderStatus.CONFIRMED)
  })

  it('should not allow paying before confirming', () => {
    const order = Order.create('order-1', 'customer-1', sampleItems)
    expect(() => order.pay()).toThrow('Only confirmed orders can be paid')
  })
})

Use Case Integration Tests (Mocking Adapters)

// tests/application/services/CreateOrderService.test.ts
import { CreateOrderService } from '../../../application/services/CreateOrderService'
import { OrderRepository } from '../../../application/ports/outbound/OrderRepository'
import { PaymentGateway } from '../../../application/ports/outbound/PaymentGateway'
import { EventPublisher } from '../../../application/ports/outbound/EventPublisher'

describe('CreateOrderService', () => {
  let service: CreateOrderService
  let mockOrderRepo: jest.Mocked<OrderRepository>
  let mockPayment: jest.Mocked<PaymentGateway>
  let mockEvents: jest.Mocked<EventPublisher>

  beforeEach(() => {
    mockOrderRepo = {
      save: jest.fn().mockResolvedValue(undefined),
      findById: jest.fn(),
      findByCustomerId: jest.fn(),
    }
    mockPayment = {
      processPayment: jest.fn().mockResolvedValue({
        transactionId: 'tx-1',
        success: true,
      }),
    }
    mockEvents = {
      publish: jest.fn().mockResolvedValue(undefined),
    }

    service = new CreateOrderService(mockOrderRepo, mockPayment, mockEvents)
  })

  it('should create and pay for an order successfully', async () => {
    const result = await service.execute({
      customerId: 'cust-1',
      items: [
        {
          productId: 'prod-1',
          productName: 'Widget',
          quantity: 1,
          unitPrice: 10000,
        },
      ],
    })

    expect(result.status).toBe('PAID')
    expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
    expect(mockPayment.processPayment).toHaveBeenCalledTimes(1)
    expect(mockEvents.publish).toHaveBeenCalledTimes(1)
  })

  it('should cancel order when payment fails', async () => {
    mockPayment.processPayment.mockResolvedValue({
      transactionId: '',
      success: false,
      errorMessage: 'Insufficient funds',
    })

    await expect(
      service.execute({
        customerId: 'cust-1',
        items: [
          {
            productId: 'prod-1',
            productName: 'Widget',
            quantity: 1,
            unitPrice: 10000,
          },
        ],
      })
    ).rejects.toThrow('Payment failed')

    expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
  })
})

Anti-Patterns and Caveats

Anti-Pattern 1: Domain Depending on Infrastructure

// Bad: Domain entity depends on TypeORM decorators
import { Entity, Column, PrimaryColumn } from 'typeorm'

@Entity() // Infrastructure framework dependency!
export class Order {
  @PrimaryColumn()
  id: string

  @Column()
  status: string
}

// Good: Domain entity remains a pure class
// Persistence mapping handled by separate Entity classes in infrastructure layer
export class Order {
  private readonly id: string
  private status: OrderStatus
  // Contains only pure business logic
}

Anti-Pattern 2: Anemic Domain Model

// Bad: Domain entity is just a data structure
export class Order {
  id: string
  status: string
  items: OrderItem[]
  totalAmount: number
  // Only getters/setters, no business logic
}

// All business logic concentrated in services
export class OrderService {
  confirmOrder(order: Order): void {
    if (order.status !== 'PENDING') throw new Error('...')
    order.status = 'CONFIRMED'
    order.totalAmount = this.calculateTotal(order.items)
  }
}

// Good: Business logic encapsulated within domain entity
export class Order {
  confirm(): void {
    if (this.status !== OrderStatus.PENDING) {
      throw new Error('Only pending orders can be confirmed')
    }
    this.status = OrderStatus.CONFIRMED
  }

  getTotalAmount(): Money {
    return this.items.reduce(
      (total, item) => total.add(item.unitPrice.multiply(item.quantity)),
      Money.of(0)
    )
  }
}

Anti-Pattern 3: Over-Engineering

Applying hexagonal architecture to a small CRUD application only increases boilerplate code and reduces productivity. Architecture choices should match the project's complexity and expected lifespan.

Migration from Layered Architecture

Step-by-Step Migration Strategy

When transitioning from a layered architecture to hexagonal/clean architecture, take an incremental approach:

  1. Phase 1: Separate the domain layer -- Move business logic from services into entities
  2. Phase 2: Define ports (interfaces) -- Extract interfaces for repositories and external services
  3. Phase 3: Implement adapters -- Wrap existing implementations as adapters
  4. Phase 4: Configure DI container -- Connect ports and adapters via dependency injection
  5. Phase 5: Add tests -- Write domain unit tests and port mocking tests

Failure Cases in Boundary Decisions

Incorrect boundary definitions can negate the benefits of the architecture. For example, if a single use case depends on too many outbound ports, it signals that the use case has too many responsibilities. It is better to split the use case into smaller units.

Conversely, splitting ports too granularly causes the number of ports to explode, making management difficult. As a general rule, one Repository port per Aggregate Root and one Gateway port per external system is appropriate.

Production Checklist

Architecture Principles

  • Verify dependencies always point from outside to inside
  • Verify no framework imports exist in the domain layer
  • Verify domain entities encapsulate sufficient business logic
  • Verify port interfaces are defined in domain terminology (not technical terms)
  • Verify DI container is only used at the Composition Root

Testing

  • Domain layer unit test coverage above 90%
  • Verify outbound ports are mocked in use case tests
  • Verify adapter integration tests exist
  • Verify E2E tests validate the complete flow

Code Quality

  • Verify no circular dependencies between packages
  • Verify single responsibility per use case (SRP)
  • Verify port interfaces define minimal methods (ISP)
  • Automate architecture rule verification with ArchUnit or similar tools

References