✍️ 필사 모드: Clean Architecture & Hexagonal Architecture Complete Guide 2025: Dependency Inversion, Ports & Adapters
EnglishTable 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:
| Layer | Role | Examples |
|---|---|---|
| Domain Model | Core business entities | Order, Money, Customer |
| Domain Services | Domain logic across entities | OrderPricingService |
| Application Services | Use case orchestration, txn mgmt | PlaceOrderService |
| Infrastructure | External tech implementations | PostgreSQL, Redis, Email |
5. Architecture Comparison (10 Dimensions)
| Dimension | Layered | Hexagonal | Onion | Clean |
|---|---|---|---|---|
| Dependency direction | Top-down | Outer-inner | Outer-inner | Outer-inner |
| Core principle | Separation of concerns | Ports/Adapters | Domain-centric | Dependency Rule |
| Proposed by | Traditional | Cockburn (2005) | Palermo (2008) | Martin (2012) |
| Number of layers | 3-4 | 2 (inner/outer) | 4 | 4 |
| Domain isolation | Weak | Strong | Strong | Strong |
| Testability | Medium | High | High | High |
| Framework independence | Low | High | High | High |
| Learning curve | Low | Medium | Medium | High |
| Suitable scale | Small-Medium | Medium-Large | Medium-Large | Large |
| Overhead | Low | Medium | Medium | High |
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
- Robert C. Martin - "Clean Architecture: A Craftsman's Guide to Software Structure and Design" (2017)
- Alistair Cockburn - "Hexagonal Architecture" (original blog post, 2005)
- Jeffrey Palermo - "The Onion Architecture" (2008)
- Robert C. Martin - "The Clean Architecture" (blog post, 2012)
- Vaughn Vernon - "Implementing Domain-Driven Design" (architecture patterns chapter)
- Martin Fowler - "InversionOfControl" (blog post)
- Mark Seemann - "Dependency Injection in .NET" (DI patterns)
- Herberto Graca - "DDD, Hexagonal, Onion, Clean, CQRS" (unified analysis blog)
- Tom Hombergs - "Get Your Hands Dirty on Clean Architecture" (practical guide)
- Netflix Tech Blog - "Ready for Changes with Hexagonal Architecture"
- Uncle Bob - "Architecture the Lost Years" (presentation)
- ThoughtWorks Technology Radar - Ports and Adapters Architecture
- Microsoft - "Clean Architecture with ASP.NET Core"
- Steve Smith - "Clean Architecture Template" (GitHub)
현재 단락 (1/1102)
Software architecture has evolved over decades. Each stage emerged to solve the problems of its pred...