Skip to content

✍️ 필사 모드: Clean Architecture & Hexagonal Architecture Complete Guide 2025: Dependency Inversion, Ports & Adapters

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

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)

현재 단락 (1/1102)

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

작성 글자: 0원문 글자: 32,803작성 단락: 0/1102