Skip to content

Split View: Clean Architecture & Hexagonal 아키텍처 완전 가이드 2025: 의존성 역전, Ports & Adapters

✨ Learn with Quiz
|

Clean Architecture & Hexagonal 아키텍처 완전 가이드 2025: 의존성 역전, Ports & Adapters

목차

1. 아키텍처의 진화: Big Ball of Mud에서 Clean Architecture까지

소프트웨어 아키텍처는 수십 년에 걸쳐 진화해왔습니다. 각 단계는 이전의 문제를 해결하기 위해 등장했습니다.

1.1 아키텍처 진화의 여정

Big Ball of MudLayeredHexagonalOnionCleanModular
    (혼돈)       (계층화)    (포트/어댑터) (양파)   (깨끗)   (모듈화)
    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  │  │      │  │       │  │
          │  └──┬───┘  └──┬───┘  └──┬────┘  │
          │     │         │         │       │
          │     ▼         ▼         ▼       │
          │  ┌─────────────────────────┐    │
          │  │     Input Ports         │    │
    (Use Cases)          │    │
          │  ├─────────────────────────┤    │
          │  │                         │    │
          │  │    Application Core     │    │
    (Domain Logic)       │    │
          │  │                         │    │
          │  ├─────────────────────────┤    │
          │  │    Output Ports         │    │
   (Interfaces)          │    │
          │  └─────────────────────────┘    │
          │     │         │         │       │
          │     ▼         ▼         ▼       │
          │  ┌──────┐ ┌───────┐ ┌───────┐  │
          │  │ DB   │ │ Email │ │ Queue │  │
          │  │Adapter│ │Adapter│ │Adapter│  │
          │  └──────┘ └───────┘ └───────┘  │
          │                                 │
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 Command
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 Notification
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 Notification (같은 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 (Enterprise Business Rules)

가장 안쪽 레이어. 비즈니스의 핵심 규칙을 담습니다. 어떤 외부 변화에도 변경되지 않아야 합니다.

// 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 (Application Business Rules)

애플리케이션 고유의 비즈니스 규칙. 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;
  }
}

// Use Case: Input/Output DTO
class PlaceOrderCommand {
  constructor(
    readonly customerId: string,
    readonly items: OrderItemDTO[],
    readonly shippingAddress: AddressDTO
  ) {}
}

class OrderItemDTO {
  constructor(
    readonly productId: string,
    readonly productName: string,
    readonly price: number,
    readonly quantity: number
  ) {}
}

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(),
    };
  }
}

// Interface Adapter: Gateway (Output Port 구현)
class PostgresOrderGateway implements OrderRepository {
  constructor(private dataSource: DataSource) {}

  async findById(id: OrderId): Promise<Order | null> {
    const entity = await this.dataSource
      .getRepository(OrderEntity)
      .findOne({ where: { id: id.value }, relations: ['items'] });

    if (!entity) return null;
    return OrderMapper.toDomain(entity);
  }

  async save(order: Order): Promise<void> {
    const entity = OrderMapper.toPersistence(order);
    await this.dataSource.getRepository(OrderEntity).save(entity);
  }

  nextId(): OrderId {
    return OrderId.generate();
  }
}

Layer 4: Frameworks & Drivers

가장 바깥 레이어. 웹 프레임워크, 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가지 차원)

차원LayeredHexagonalOnionClean
의존성 방향위→아래바깥→안쪽바깥→안쪽바깥→안쪽
핵심 원칙관심사 분리포트/어댑터도메인 중심의존성 규칙
제안자전통적Cockburn (2005)Palermo (2008)Martin (2012)
레이어 수3~42 (내부/외부)44
도메인 격리약함강함강함강함
테스트 용이성보통높음높음높음
프레임워크 독립성낮음높음높음높음
학습 곡선낮음중간중간높음
적합한 규모소~중중~대중~대대규모
오버헤드적음중간중간많음

핵심 공통점:

세 아키텍처(Hexagonal, Onion, Clean) 모두 같은 목표를 가집니다:

  • 도메인을 중심에 놓는다
  • 의존성이 안쪽(도메인)으로만 향한다
  • 외부 기술(DB, 프레임워크)에 의존하지 않는다

차이는 주로 용어와 세부 레이어 구분에 있습니다.


6. 의존성 역전 실전 적용

6.1 전통적 의존 vs 역전된 의존

전통적 (Layered):
ControllerServiceRepository(PostgreSQL)
           ↓                    ↓
       비즈니스 로직이        DB 기술에
       DB에 의존             직접 결합

역전된 (Clean/Hexagonal):
ControllerUseCaseRepository(Interface)
                    PostgresRepository(구현)

  UseCase는 Interface만 알고, 구현체는 모른다

6.2 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 호출
        # stripe.Charge.create(...)
        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 Implementation
│   │   ├── 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. Use Case 패턴 상세

8.1 Input Port, Output Port, Interactor

// Input Port (인터페이스)
interface PlaceOrderInputPort {
  execute(command: PlaceOrderCommand): Promise<PlaceOrderResult>;
}

// Output Port (인터페이스)
interface PlaceOrderOutputPort {
  orderCreated(orderId: OrderId, totalAmount: Money): void;
  paymentFailed(reason: string): void;
  validationFailed(errors: ValidationError[]): void;
}

// Interactor (Use Case 구현체)
class PlaceOrderInteractor implements PlaceOrderInputPort {
  constructor(
    private orderRepo: OrderRepository,
    private paymentPort: PaymentPort,
    private presenter: PlaceOrderOutputPort
  ) {}

  async execute(
    command: PlaceOrderCommand
  ): Promise<PlaceOrderResult> {
    // Validation
    const errors = this.validate(command);
    if (errors.length > 0) {
      this.presenter.validationFailed(errors);
      return PlaceOrderResult.failure('VALIDATION_ERROR');
    }

    // Domain logic
    const orderId = this.orderRepo.nextId();
    const order = new Order(
      orderId,
      new CustomerId(command.customerId)
    );

    for (const item of command.items) {
      order.addItem(
        new ProductId(item.productId),
        item.productName,
        Money.of(item.price, 'KRW'),
        new Quantity(item.quantity)
      );
    }

    order.place();

    // Payment
    const paymentResult = await this.paymentPort.processPayment(
      orderId,
      order.totalAmount
    );

    if (!paymentResult.isSuccess) {
      this.presenter.paymentFailed(paymentResult.reason);
      return PlaceOrderResult.failure('PAYMENT_FAILED');
    }

    await this.orderRepo.save(order);

    this.presenter.orderCreated(orderId, order.totalAmount);
    return PlaceOrderResult.success(orderId);
  }

  private validate(command: PlaceOrderCommand): ValidationError[] {
    const errors: ValidationError[] = [];
    if (!command.customerId) {
      errors.push(new ValidationError('customerId', 'Required'));
    }
    if (!command.items || command.items.length === 0) {
      errors.push(new ValidationError('items', 'At least one item'));
    }
    return errors;
  }
}

9. 테스트 전략

9.1 레이어별 테스트

Clean Architecture의 가장 큰 장점 중 하나는 테스트 용이성입니다.

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);
  });

  it('should not add items to placed order', () => {
    const order = createPlacedOrder();

    expect(() =>
      order.addItem(
        new ProductId('prod-2'),
        'Gadget',
        Money.of(5000, 'KRW'),
        new Quantity(1)
      )
    ).toThrow(InvalidOrderStateError);
  });
});

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;
  }
}

Integration 테스트 (실제 어댑터)

describe('PostgresOrderRepository', () => {
  let repo: PostgresOrderRepository;
  let pool: Pool;

  beforeAll(async () => {
    pool = new Pool({
      connectionString: process.env.TEST_DATABASE_URL,
    });
    repo = new PostgresOrderRepository(pool);
  });

  afterAll(async () => {
    await pool.end();
  });

  it('should save and retrieve order', async () => {
    const order = createSampleOrder();
    await repo.save(order);

    const retrieved = await repo.findById(order.id);

    expect(retrieved).not.toBeNull();
    expect(retrieved!.id).toEqual(order.id);
    expect(retrieved!.status).toBe(OrderStatus.PLACED);
    expect(retrieved!.totalAmount).toEqual(order.totalAmount);
  });
});

10. 프레임워크 독립성

10.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)
);

10.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'));

11. 흔한 실수와 주의사항

11.1 오버엔지니어링

단순 CRUD 앱에 Clean Architecture를 적용하면:

EntityUse CaseInput PortController
                     Output PortRepository
                     Output PortNotification
                     Output PortPayment

파일 수: 20+

그냥 이렇게 하면 됩니다:
ControllerServiceRepository

파일 수: 3

원칙: 복잡한 도메인 로직이 있을 때만 Clean Architecture를 적용하세요.

11.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를 통해 외부와 통신 */
  }
}

11.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>;
}

11.4 언제 사용하고 언제 사용하지 말 것인가

사용해야 하는 경우:

  • 복잡한 비즈니스 도메인 (금융, 이커머스, 의료)
  • 장기 유지보수가 필요한 프로젝트
  • 외부 시스템 교체 가능성이 있는 경우
  • 팀 규모가 크고 경계가 명확해야 하는 경우
  • 높은 테스트 커버리지가 요구되는 경우

사용하지 말아야 하는 경우:

  • 단순 CRUD 애플리케이션
  • 프로토타입/MVP
  • 단기 프로젝트
  • 1인 개발 소규모 프로젝트
  • 비즈니스 로직이 거의 없는 경우

12. 실전 체크리스트

12.1 아키텍처 선택 체크리스트

  • 도메인 복잡성이 높은가?
  • 장기 유지보수가 필요한가?
  • 외부 기술 교체 가능성이 있는가?
  • 팀 규모가 3명 이상인가?
  • 테스트가 중요한가?

3개 이상 "예"라면 Clean/Hexagonal Architecture를 고려하세요.

12.2 구현 체크리스트

  • 의존성이 항상 안쪽으로만 향하는가?
  • 도메인 레이어에 외부 의존성이 없는가?
  • Port(인터페이스)가 도메인 레이어에 정의되어 있는가?
  • Adapter가 Port를 구현하고 있는가?
  • Use Case가 하나의 유스케이스만 담당하는가?
  • Composition Root에서 의존성을 조립하는가?
  • 도메인 테스트에 Mock이 필요 없는가?

13. 퀴즈

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. 세 아키텍처 모두 (1) 도메인을 중심에 배치, (2) 의존성이 안쪽(도메인)으로만 향함, (3) 외부 기술(DB, 프레임워크)으로부터 도메인 격리, (4) 의존성 역전 원칙 적용이라는 공통 목표를 가집니다. 차이는 주로 용어(Layer 이름)와 세부 레이어 구분에 있습니다.

Q4. Clean Architecture를 사용하면 안 되는 경우는 언제인가요?

A4. Clean Architecture는 오버헤드가 있으므로 (1) 단순 CRUD 앱 (비즈니스 로직이 거의 없음), (2) 프로토타입/MVP (빠른 검증이 목적), (3) 단기 프로젝트 (유지보수 필요 없음), (4) 1인 소규모 프로젝트 (팀 간 경계 불필요)에서는 불필요합니다. 이런 경우 전통적인 3계층 아키텍처가 더 효율적입니다.

Q5. 프레임워크 독립성이 왜 중요한가요? 실제로 프레임워크를 교체하는 경우가 있나요?

A5. 프레임워크 독립성의 진정한 가치는 교체 자체보다 테스트 용이성도메인 집중에 있습니다. 도메인이 프레임워크에 의존하지 않으면 (1) 순수 단위 테스트가 가능하고, (2) 도메인 로직이 명확해지며, (3) 프레임워크 업그레이드가 쉬워집니다. 실제로 Express에서 Fastify, REST에서 GraphQL 전환 등은 빈번하게 발생하며, 이때 도메인 코드를 건드리지 않을 수 있다는 것은 큰 장점입니다.


14. 참고 자료

  1. Robert C. Martin - "Clean Architecture: A Craftsman's Guide to Software Structure and Design" (2017)
  2. Alistair Cockburn - "Hexagonal Architecture" (원본 블로그 글, 2005)
  3. Jeffrey Palermo - "The Onion Architecture" (2008)
  4. Robert C. Martin - "The Clean Architecture" (블로그 게시물, 2012)
  5. Vaughn Vernon - "Implementing Domain-Driven Design" (아키텍처 패턴 장)
  6. Martin Fowler - "InversionOfControl" (블로그 게시물)
  7. Mark Seemann - "Dependency Injection in .NET" (의존성 주입 패턴)
  8. Herberto Graca - "DDD, Hexagonal, Onion, Clean, CQRS" (통합 분석 블로그)
  9. Tom Hombergs - "Get Your Hands Dirty on Clean Architecture" (실전 가이드)
  10. Netflix Tech Blog - "Ready for Changes with Hexagonal Architecture"
  11. Uncle Bob - "Architecture the Lost Years" (발표 영상)
  12. ThoughtWorks Technology Radar - Ports and Adapters Architecture
  13. Microsoft - "Clean Architecture with ASP.NET Core"
  14. Steve Smith - "Clean Architecture Template" (GitHub)

Clean Architecture & Hexagonal Architecture Complete Guide 2025: Dependency Inversion, Ports & Adapters

Table of Contents

1. Architecture Evolution: From Big Ball of Mud to Clean Architecture

Software architecture has evolved over decades. Each stage emerged to solve the problems of its predecessor.

1.1 The Architecture Evolution Journey

Big Ball of Mud -> Layered -> Hexagonal -> Onion -> Clean -> Modular
    (chaos)      (layered)   (ports/adapters) (onion) (clean)  (modular)
    1990s         2000s         2005          2008    2012      2020s

Big Ball of Mud (The Age of Chaos)

All code is tangled together. UI code directly calls the DB, and business logic is scattered everywhere.

Layered Architecture

The most traditional approach. Divides into 3 layers: Presentation - Business Logic - Data Access.

+---------------------------+
|   Presentation Layer      | <- UI, Controllers
+---------------------------+
|   Business Logic Layer    | <- Services, Rules
+---------------------------+
|   Data Access Layer       | <- Repositories, ORM
+---------------------------+
|      Database             |
+---------------------------+
     Dependencies flow top to bottom

Problems:

  • All dependencies point downward, so business logic depends on the DB
  • Difficult to swap frameworks or databases
  • Tests require database connections

Why Dependency Inversion Is Needed

The key question: Should business logic know about the DB?

In traditional layered architecture, OrderService -> OrderRepository(PostgreSQL) means the business layer directly depends on the infrastructure layer. Switching to MongoDB requires modifying business logic.

With dependency inversion: OrderService -> OrderRepository(Interface) <- PostgresOrderRepository

The business layer only knows the interface, and the infrastructure layer implements it.


2. Hexagonal Architecture (Ports & Adapters)

2.1 Alistair Cockburn's Idea (2005)

The core idea of Hexagonal Architecture is simple: Isolate the application from the outside world.

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

A Port is an interface defined by the domain.

Input Port (Driving Port)

Defines requests coming into the application from outside.

// Input Port: Use Case interface
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)

Defines requests the application makes to external systems.

// Output Port: Repository interface
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
  nextId(): OrderId;
}

// Output Port: Notification service interface
interface NotificationPort {
  sendOrderConfirmation(
    customerId: CustomerId,
    orderId: OrderId
  ): Promise<void>;
}

// Output Port: Payment service interface
interface PaymentPort {
  processPayment(
    orderId: OrderId,
    amount: Money
  ): Promise<PaymentResult>;
}

2.3 Adapters

An Adapter is external technology that implements a Port.

Primary Adapter (Driving Adapter)

Forwards external requests to Input Ports.

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

Implements Output Ports to communicate with external systems.

// 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 (same Port, different implementation)
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 Notification
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 Notification (same Port, different implementation)
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 Concentric Layers and the Dependency Rule

The core of Clean Architecture is the Dependency Rule: dependencies must always point inward only, from outer to inner layers.

+-----------------------------------------------+
|              Frameworks & Drivers              |
|  +---------------------------------------+    |
|  |          Interface Adapters           |    |
|  |  +-------------------------------+    |    |
|  |  |       Application Business    |    |    |
|  |  |           Rules               |    |    |
|  |  |  +-----------------------+    |    |    |
|  |  |  |   Enterprise Business |    |    |    |
|  |  |  |       Rules           |    |    |    |
|  |  |  |     (Entities)        |    |    |    |
|  |  |  +-----------------------+    |    |    |
|  |  |       (Use Cases)             |    |    |
|  |  +-------------------------------+    |    |
|  |    (Controllers, Gateways, Presenters)|    |
|  +---------------------------------------+    |
|   (Web, DB, UI, Devices, External Interfaces) |
+-----------------------------------------------+

Dependency direction: outer -> inner (always)

3.2 The Four Layers

Layer 1: Entities (Enterprise Business Rules)

The innermost layer. Contains the core business rules. Should remain unchanged regardless of external changes.

// 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 (Application Business Rules)

Application-specific business rules. Composes Entities to implement user scenarios.

// 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. Create Aggregate
    const orderId = this.orderRepo.nextId();
    const order = new Order(
      orderId,
      new CustomerId(command.customerId)
    );

    // 2. Add items
    for (const item of command.items) {
      order.addItem(
        new ProductId(item.productId),
        item.productName,
        Money.of(item.price, 'KRW'),
        new Quantity(item.quantity)
      );
    }

    // 3. Place order
    order.place();

    // 4. Process payment
    const paymentResult = await this.paymentPort.processPayment(
      orderId,
      order.totalAmount
    );

    if (!paymentResult.isSuccess) {
      throw new PaymentFailedError(paymentResult.reason);
    }

    // 5. Persist
    await this.orderRepo.save(order);

    // 6. Notify
    await this.notificationPort.sendOrderConfirmation(
      order.customerId,
      orderId
    );

    return orderId;
  }
}

// Use Case: Input/Output DTO
class PlaceOrderCommand {
  constructor(
    readonly customerId: string,
    readonly items: OrderItemDTO[],
    readonly shippingAddress: AddressDTO
  ) {}
}

Layer 3: Interface Adapters

Adapters connecting the outside to the inside. Controllers, Presenters, and Gateways belong here.

// 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(),
    };
  }
}

// Interface Adapter: Gateway (Output Port implementation)
class PostgresOrderGateway implements OrderRepository {
  constructor(private dataSource: DataSource) {}

  async findById(id: OrderId): Promise<Order | null> {
    const entity = await this.dataSource
      .getRepository(OrderEntity)
      .findOne({ where: { id: id.value }, relations: ['items'] });

    if (!entity) return null;
    return OrderMapper.toDomain(entity);
  }

  async save(order: Order): Promise<void> {
    const entity = OrderMapper.toPersistence(order);
    await this.dataSource.getRepository(OrderEntity).save(entity);
  }

  nextId(): OrderId {
    return OrderId.generate();
  }
}

Layer 4: Frameworks & Drivers

The outermost layer. Web frameworks, DB drivers, UI frameworks, etc.

// 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 The Onion Layer Structure

Onion Architecture is a predecessor to Clean Architecture. The concentric structure is similar but layer names differ.

+---------------------------------------------+
|              Infrastructure                  |
|  +---------------------------------------+  |
|  |        Application Services           |  |
|  |  +-------------------------------+    |  |
|  |  |      Domain Services          |    |  |
|  |  |  +-----------------------+    |    |  |
|  |  |  |    Domain Model       |    |    |  |
|  |  |  |  (Entities + VOs)     |    |    |  |
|  |  |  +-----------------------+    |    |  |
|  |  +-------------------------------+    |  |
|  +---------------------------------------+  |
+---------------------------------------------+

Layer descriptions:

LayerRoleExamples
Domain ModelCore business entitiesOrder, Money, Customer
Domain ServicesDomain logic across entitiesOrderPricingService
Application ServicesUse case orchestration, txn mgmtPlaceOrderService
InfrastructureExternal tech implementationsPostgreSQL, Redis, Email

5. Architecture Comparison (10 Dimensions)

DimensionLayeredHexagonalOnionClean
Dependency directionTop-downOuter-innerOuter-innerOuter-inner
Core principleSeparation of concernsPorts/AdaptersDomain-centricDependency Rule
Proposed byTraditionalCockburn (2005)Palermo (2008)Martin (2012)
Number of layers3-42 (inner/outer)44
Domain isolationWeakStrongStrongStrong
TestabilityMediumHighHighHigh
Framework independenceLowHighHighHigh
Learning curveLowMediumMediumHigh
Suitable scaleSmall-MediumMedium-LargeMedium-LargeLarge
OverheadLowMediumMediumHigh

Key commonalities:

All three architectures (Hexagonal, Onion, Clean) share the same goals:

  • Put the domain at the center
  • Dependencies point inward (toward the domain) only
  • No dependency on external tech (DB, frameworks)

Differences are mainly in terminology and detailed layer distinctions.


6. Dependency Inversion in Practice

6.1 Traditional vs Inverted Dependencies

Traditional (Layered):
Controller -> Service -> Repository(PostgreSQL)
             |                    |
        Business logic        Directly coupled
        depends on DB         to DB tech

Inverted (Clean/Hexagonal):
Controller -> UseCase -> Repository(Interface)
                              ^
                    PostgresRepository(impl)

  UseCase knows only the Interface, not the implementation

6.2 Dependency Inversion in Python

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional


# === Domain Layer (innermost) ===

@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"


# === Ports (interfaces defined by the domain) ===

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 (outermost) ===

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:
        # Call 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():
    # Wire dependencies
    order_repo = InMemoryOrderRepository()
    payment = StripePaymentAdapter("sk_test_xxx")
    notification = ConsoleNotificationAdapter()

    use_case = PlaceOrderUseCase(order_repo, payment, notification)

    # Execute
    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. Project Structure

7.1 TypeScript Project Structure

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 Implementation
│   │   ├── 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 Project Structure

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. Use Case Pattern in Detail

8.1 Input Port, Output Port, Interactor

// Input Port (interface)
interface PlaceOrderInputPort {
  execute(command: PlaceOrderCommand): Promise<PlaceOrderResult>;
}

// Output Port (interface)
interface PlaceOrderOutputPort {
  orderCreated(orderId: OrderId, totalAmount: Money): void;
  paymentFailed(reason: string): void;
  validationFailed(errors: ValidationError[]): void;
}

// Interactor (Use Case implementation)
class PlaceOrderInteractor implements PlaceOrderInputPort {
  constructor(
    private orderRepo: OrderRepository,
    private paymentPort: PaymentPort,
    private presenter: PlaceOrderOutputPort
  ) {}

  async execute(
    command: PlaceOrderCommand
  ): Promise<PlaceOrderResult> {
    // Validation
    const errors = this.validate(command);
    if (errors.length > 0) {
      this.presenter.validationFailed(errors);
      return PlaceOrderResult.failure('VALIDATION_ERROR');
    }

    // Domain logic
    const orderId = this.orderRepo.nextId();
    const order = new Order(
      orderId,
      new CustomerId(command.customerId)
    );

    for (const item of command.items) {
      order.addItem(
        new ProductId(item.productId),
        item.productName,
        Money.of(item.price, 'KRW'),
        new Quantity(item.quantity)
      );
    }

    order.place();

    // Payment
    const paymentResult = await this.paymentPort.processPayment(
      orderId,
      order.totalAmount
    );

    if (!paymentResult.isSuccess) {
      this.presenter.paymentFailed(paymentResult.reason);
      return PlaceOrderResult.failure('PAYMENT_FAILED');
    }

    await this.orderRepo.save(order);

    this.presenter.orderCreated(orderId, order.totalAmount);
    return PlaceOrderResult.success(orderId);
  }

  private validate(command: PlaceOrderCommand): ValidationError[] {
    const errors: ValidationError[] = [];
    if (!command.customerId) {
      errors.push(new ValidationError('customerId', 'Required'));
    }
    if (!command.items || command.items.length === 0) {
      errors.push(new ValidationError('items', 'At least one item'));
    }
    return errors;
  }
}

9. Testing Strategy

9.1 Testing by Layer

One of the biggest advantages of Clean Architecture is testability.

Domain Layer Tests (No Mocks Needed)

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);
  });

  it('should not add items to placed order', () => {
    const order = createPlacedOrder();

    expect(() =>
      order.addItem(
        new ProductId('prod-2'),
        'Gadget',
        Money.of(5000, 'KRW'),
        new Quantity(1)
      )
    ).toThrow(InvalidOrderStateError);
  });
});

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 Tests (Using Test Doubles)

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;
  }
}

Integration Tests (Real Adapters)

describe('PostgresOrderRepository', () => {
  let repo: PostgresOrderRepository;
  let pool: Pool;

  beforeAll(async () => {
    pool = new Pool({
      connectionString: process.env.TEST_DATABASE_URL,
    });
    repo = new PostgresOrderRepository(pool);
  });

  afterAll(async () => {
    await pool.end();
  });

  it('should save and retrieve order', async () => {
    const order = createSampleOrder();
    await repo.save(order);

    const retrieved = await repo.findById(order.id);

    expect(retrieved).not.toBeNull();
    expect(retrieved!.id).toEqual(order.id);
    expect(retrieved!.status).toBe(OrderStatus.PLACED);
    expect(retrieved!.totalAmount).toEqual(order.totalAmount);
  });
});

10. Framework Independence

10.1 Switching from Express to Fastify

You can swap the framework without changing the domain or use cases at all.

// Before: Express
import express from 'express';
const app = express();
app.post('/orders', (req, res) =>
  controller.placeOrder(req, res)
);

// After: Fastify (zero domain/use case code changes!)
import Fastify from 'fastify';
const fastify = Fastify();

// Only write a new 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)
);

10.2 Switching from PostgreSQL to MongoDB

// Domain code: no changes
// Use Case code: no changes
// Output Port (Interface): no changes

// Only write a new 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(); }
}

// Only change the Composition Root
// Before:
// const orderRepo = new PostgresOrderRepository(pool);
// After:
const mongoClient = new MongoClient(process.env.MONGO_URL);
const orderRepo = new MongoOrderRepository(mongoClient.db('shop'));

11. Common Mistakes and Pitfalls

11.1 Over-Engineering

Applying Clean Architecture to a simple CRUD app:

Entity -> Use Case -> Input Port -> Controller
                      Output Port -> Repository
                      Output Port -> Notification
                      Output Port -> Payment

File count: 20+

You can just do this instead:
Controller -> Service -> Repository

File count: 3

Principle: Apply Clean Architecture only when you have complex domain logic.

11.2 Wrong Layer Placement

// Bad: domain depends on infrastructure
class Order {
  async save(): Promise<void> {
    // Domain should NOT know about DB directly!
    await db.query('INSERT INTO orders ...');
  }
}

// Bad: Use Case depends on framework
class PlaceOrderUseCase {
  async execute(req: express.Request): Promise<void> {
    // Use Case should NOT know about Express!
    const body = req.body;
  }
}

// Good: Each layer does only its job
class Order {
  place(): void { /* Pure domain logic */ }
}

class PlaceOrderUseCase {
  execute(command: PlaceOrderCommand): Promise<OrderId> {
    /* Receives only DTOs, communicates via Ports */
  }
}

11.3 Leaky Abstraction

// Bad: Port tied to specific technology
interface OrderRepository {
  // Exposes SQL directly - tied to PostgreSQL!
  findByQuery(sql: string): Promise<Order[]>;
  // Exposes Mongoose Document - tied to MongoDB!
  findByMongoQuery(filter: FilterQuery<OrderDoc>): Promise<Order[]>;
}

// Good: Technology-neutral Port
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  findByCustomer(customerId: CustomerId): Promise<Order[]>;
  findByStatus(status: OrderStatus): Promise<Order[]>;
  save(order: Order): Promise<void>;
}

11.4 When to Use and When Not To

Use when:

  • Complex business domain (finance, e-commerce, healthcare)
  • Project needs long-term maintenance
  • External system replacement is possible
  • Large team needing clear boundaries
  • High test coverage is required

Do not use when:

  • Simple CRUD application
  • Prototype/MVP
  • Short-term project
  • Solo developer small project
  • Little to no business logic

12. Practical Checklist

12.1 Architecture Selection Checklist

  • Is domain complexity high?
  • Is long-term maintenance needed?
  • Is external tech replacement possible?
  • Is the team 3+ people?
  • Is testing important?

If you answer "yes" to 3 or more, consider Clean/Hexagonal Architecture.

12.2 Implementation Checklist

  • Do dependencies always point inward?
  • Is the domain layer free of external dependencies?
  • Are Ports (interfaces) defined in the domain layer?
  • Do Adapters implement the Ports?
  • Does each Use Case handle only one use case?
  • Are dependencies wired at the Composition Root?
  • Can domain tests run without mocks?

13. Quiz

Q1. What is the Dependency Rule in Clean Architecture?

A1. The Dependency Rule states that source code dependencies must always point inward (toward higher-level policies). Outer layers (frameworks, DB) depending on inner layers (Use Cases, Entities) is allowed, but inner layers directly depending on outer layers is forbidden. This ensures the domain is unaffected by framework or database changes.

Q2. What is the difference between Port and Adapter in Hexagonal Architecture?

A2. A Port is an interface defined by the domain (application core). It defines the contract for how the domain interacts with the outside world. An Adapter is external technology that implements a Port. For example, OrderRepository (Port) is implemented by PostgresOrderRepository (Adapter). Ports live in the domain layer; Adapters live in the infrastructure layer.

Q3. What do Clean Architecture, Hexagonal Architecture, and Onion Architecture have in common?

A3. All three architectures share these goals: (1) Domain at the center, (2) Dependencies point inward (toward the domain) only, (3) Domain isolated from external tech (DB, frameworks), (4) Apply Dependency Inversion Principle. The differences are mainly in terminology (layer names) and detailed layer distinctions.

Q4. When should you NOT use Clean Architecture?

A4. Clean Architecture has overhead, so it is unnecessary for (1) simple CRUD apps (little to no business logic), (2) prototypes/MVPs (goal is fast validation), (3) short-term projects (no maintenance needed), (4) solo small projects (no need for team boundaries). In these cases, a traditional 3-layer architecture is more efficient.

Q5. Why is framework independence important? Do people actually swap frameworks in practice?

A5. The true value of framework independence lies in testability and domain focus rather than swapping itself. When the domain does not depend on the framework: (1) pure unit tests become possible, (2) domain logic becomes clearer, (3) framework upgrades become easier. In practice, transitions like Express to Fastify or REST to GraphQL happen frequently, and being able to leave domain code untouched is a significant advantage.


14. References

  1. Robert C. Martin - "Clean Architecture: A Craftsman's Guide to Software Structure and Design" (2017)
  2. Alistair Cockburn - "Hexagonal Architecture" (original blog post, 2005)
  3. Jeffrey Palermo - "The Onion Architecture" (2008)
  4. Robert C. Martin - "The Clean Architecture" (blog post, 2012)
  5. Vaughn Vernon - "Implementing Domain-Driven Design" (architecture patterns chapter)
  6. Martin Fowler - "InversionOfControl" (blog post)
  7. Mark Seemann - "Dependency Injection in .NET" (DI patterns)
  8. Herberto Graca - "DDD, Hexagonal, Onion, Clean, CQRS" (unified analysis blog)
  9. Tom Hombergs - "Get Your Hands Dirty on Clean Architecture" (practical guide)
  10. Netflix Tech Blog - "Ready for Changes with Hexagonal Architecture"
  11. Uncle Bob - "Architecture the Lost Years" (presentation)
  12. ThoughtWorks Technology Radar - Ports and Adapters Architecture
  13. Microsoft - "Clean Architecture with ASP.NET Core"
  14. Steve Smith - "Clean Architecture Template" (GitHub)