- Published on
Clean Architecture and Hexagonal Architecture in Practice: Achieving Domain Independence with Ports and Adapters
- Authors
- Name
- Introduction
- Architecture Comparison: Hexagonal vs Clean vs Onion vs Layered
- TypeScript Practical Implementation
- Python Practical Implementation
- Testing Strategy
- Anti-Patterns and Caveats
- Migration from Layered Architecture
- Production Checklist
- References

Introduction
As software grows, the first thing to collapse is usually the architecture. It often starts with a simple layered architecture, but many developers experience situations where business logic leaks into controllers, database schema changes affect domain logic, and framework upgrades shake the entire codebase.
Clean Architecture and Hexagonal Architecture (Ports and Adapters) are representative approaches to solving these problems. Both architectures aim to isolate core business logic from external concerns (databases, frameworks, UI).
This article compares the theoretical backgrounds of both architectures and then explains how to implement them in practice with TypeScript and Python code examples.
Architecture Comparison: Hexagonal vs Clean vs Onion vs Layered
Core Concept Comparison Table
| Item | Hexagonal Architecture | Clean Architecture | Onion Architecture | Layered Architecture |
|---|---|---|---|---|
| Author | Alistair Cockburn (2005) | Robert C. Martin (2012) | Jeffrey Palermo (2008) | Traditional pattern |
| Core Principle | Ports and Adapters | Dependency Rule | Domain-centric layers | Unidirectional layer deps |
| Layer Structure | Core + Ports + Adapters | Entities/Use Cases/Interface Adapters/Frameworks | Domain/Services/Infrastructure | Presentation/Business/Data |
| Dependency Direction | Outside -> Inside | Outside -> Inside | Outside -> Inside | Top -> Bottom |
| Testability | High | High | High | Moderate |
| Infra Swap | Simple adapter replacement | Outer layer replacement | Outer layer replacement | Full layer modification |
| Learning Curve | Moderate | High | Moderate | Low |
| Suitable Scale | Medium to Large | Large | Medium to Large | Small to Medium |
Clean Architecture's Dependency Rule
The core of Clean Architecture is the Dependency Rule. Dependencies must always point inward -- from the outer layers toward the inner layers.
It consists of four layers from innermost to outermost:
- Entities: Enterprise business rules. No external dependencies whatsoever.
- Use Cases: Application business rules. Depend only on entities.
- Interface Adapters: Connect use cases to the outside world. Controllers, presenters, and gateways reside here.
- Frameworks & Drivers: Web frameworks, databases, UI, and other external tools.
Hexagonal Architecture's Ports and Adapters
Hexagonal Architecture represents the application as a hexagon and defines two types of ports:
- Inbound Ports (Driving Ports): Interfaces for the outside world to invoke the application (e.g., use case interfaces)
- Outbound Ports (Driven Ports): Interfaces for the application to access external systems (e.g., repository interfaces)
Each port has a corresponding adapter:
- Inbound Adapters (Driving Adapters): HTTP controllers, CLI, message listeners, etc.
- Outbound Adapters (Driven Adapters): DB repositories, external API clients, message publishers, etc.
TypeScript Practical Implementation
Project Structure
src/
domain/ # Domain layer (entities, value objects)
entities/
Order.ts
Product.ts
value-objects/
Money.ts
OrderStatus.ts
application/ # Application layer (use cases, ports)
ports/
inbound/
CreateOrderUseCase.ts
GetOrderUseCase.ts
outbound/
OrderRepository.ts
PaymentGateway.ts
EventPublisher.ts
services/
CreateOrderService.ts
GetOrderService.ts
infrastructure/ # Infrastructure layer (adapters)
adapters/
inbound/
http/
OrderController.ts
messaging/
OrderEventListener.ts
outbound/
persistence/
TypeOrmOrderRepository.ts
payment/
StripePaymentAdapter.ts
messaging/
KafkaEventPublisher.ts
config/
DependencyContainer.ts
Domain Layer: Entities and Value Objects
// domain/value-objects/Money.ts
export class Money {
private constructor(
private readonly amount: number,
private readonly currency: string
) {
if (amount < 0) {
throw new Error('Amount cannot be negative')
}
}
static of(amount: number, currency: string = 'KRW'): Money {
return new Money(amount, currency)
}
add(other: Money): Money {
this.ensureSameCurrency(other)
return Money.of(this.amount + other.amount, this.currency)
}
multiply(factor: number): Money {
return Money.of(this.amount * factor, this.currency)
}
isGreaterThan(other: Money): boolean {
this.ensureSameCurrency(other)
return this.amount > other.amount
}
getAmount(): number {
return this.amount
}
private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new Error('Currency mismatch')
}
}
}
// domain/value-objects/OrderStatus.ts
export enum OrderStatus {
PENDING = 'PENDING',
CONFIRMED = 'CONFIRMED',
PAID = 'PAID',
SHIPPED = 'SHIPPED',
DELIVERED = 'DELIVERED',
CANCELLED = 'CANCELLED',
}
// domain/entities/Order.ts
import { Money } from '../value-objects/Money'
import { OrderStatus } from '../value-objects/OrderStatus'
export interface OrderItem {
productId: string
productName: string
quantity: number
unitPrice: Money
}
export class Order {
private constructor(
private readonly id: string,
private readonly customerId: string,
private items: OrderItem[],
private status: OrderStatus,
private readonly createdAt: Date
) {}
static create(id: string, customerId: string, items: OrderItem[]): Order {
if (items.length === 0) {
throw new Error('Order must have at least one item')
}
return new Order(id, customerId, items, OrderStatus.PENDING, new Date())
}
confirm(): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error('Only pending orders can be confirmed')
}
this.status = OrderStatus.CONFIRMED
}
pay(): void {
if (this.status !== OrderStatus.CONFIRMED) {
throw new Error('Only confirmed orders can be paid')
}
this.status = OrderStatus.PAID
}
cancel(): void {
if (this.status === OrderStatus.SHIPPED || this.status === OrderStatus.DELIVERED) {
throw new Error('Cannot cancel shipped or delivered orders')
}
this.status = OrderStatus.CANCELLED
}
getTotalAmount(): Money {
return this.items.reduce(
(total, item) => total.add(item.unitPrice.multiply(item.quantity)),
Money.of(0)
)
}
getId(): string {
return this.id
}
getCustomerId(): string {
return this.customerId
}
getStatus(): OrderStatus {
return this.status
}
getItems(): ReadonlyArray<OrderItem> {
return [...this.items]
}
}
Application Layer: Ports and Use Cases
// application/ports/inbound/CreateOrderUseCase.ts
export interface CreateOrderCommand {
customerId: string
items: Array<{
productId: string
productName: string
quantity: number
unitPrice: number
}>
}
export interface CreateOrderResult {
orderId: string
totalAmount: number
status: string
}
export interface CreateOrderUseCase {
execute(command: CreateOrderCommand): Promise<CreateOrderResult>
}
// application/ports/outbound/OrderRepository.ts
import { Order } from '../../domain/entities/Order'
export interface OrderRepository {
save(order: Order): Promise<void>
findById(id: string): Promise<Order | null>
findByCustomerId(customerId: string): Promise<Order[]>
}
// application/ports/outbound/PaymentGateway.ts
export interface PaymentRequest {
orderId: string
amount: number
currency: string
}
export interface PaymentResult {
transactionId: string
success: boolean
errorMessage?: string
}
export interface PaymentGateway {
processPayment(request: PaymentRequest): Promise<PaymentResult>
}
// application/ports/outbound/EventPublisher.ts
export interface DomainEvent {
eventType: string
aggregateId: string
payload: Record<string, unknown>
occurredAt: Date
}
export interface EventPublisher {
publish(event: DomainEvent): Promise<void>
}
// application/services/CreateOrderService.ts
import {
CreateOrderUseCase,
CreateOrderCommand,
CreateOrderResult,
} from '../ports/inbound/CreateOrderUseCase'
import { OrderRepository } from '../ports/outbound/OrderRepository'
import { PaymentGateway } from '../ports/outbound/PaymentGateway'
import { EventPublisher } from '../ports/outbound/EventPublisher'
import { Order, OrderItem } from '../../domain/entities/Order'
import { Money } from '../../domain/value-objects/Money'
import { v4 as uuidv4 } from 'uuid'
export class CreateOrderService implements CreateOrderUseCase {
constructor(
private readonly orderRepository: OrderRepository,
private readonly paymentGateway: PaymentGateway,
private readonly eventPublisher: EventPublisher
) {}
async execute(command: CreateOrderCommand): Promise<CreateOrderResult> {
// 1. Create domain objects
const orderItems: OrderItem[] = command.items.map((item) => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: Money.of(item.unitPrice),
}))
const order = Order.create(uuidv4(), command.customerId, orderItems)
// 2. Confirm order
order.confirm()
// 3. Process payment
const paymentResult = await this.paymentGateway.processPayment({
orderId: order.getId(),
amount: order.getTotalAmount().getAmount(),
currency: 'KRW',
})
if (!paymentResult.success) {
order.cancel()
await this.orderRepository.save(order)
throw new Error('Payment failed: ' + (paymentResult.errorMessage || 'Unknown error'))
}
// 4. Mark as paid
order.pay()
// 5. Persist
await this.orderRepository.save(order)
// 6. Publish domain event
await this.eventPublisher.publish({
eventType: 'OrderCreated',
aggregateId: order.getId(),
payload: {
customerId: order.getCustomerId(),
totalAmount: order.getTotalAmount().getAmount(),
itemCount: order.getItems().length,
},
occurredAt: new Date(),
})
return {
orderId: order.getId(),
totalAmount: order.getTotalAmount().getAmount(),
status: order.getStatus(),
}
}
}
Infrastructure Layer: Adapter Implementations
// infrastructure/adapters/outbound/persistence/TypeOrmOrderRepository.ts
import { OrderRepository } from '../../../application/ports/outbound/OrderRepository'
import { Order, OrderItem } from '../../../domain/entities/Order'
import { Repository, DataSource } from 'typeorm'
import { OrderEntity } from './entities/OrderEntity'
import { OrderItemEntity } from './entities/OrderItemEntity'
export class TypeOrmOrderRepository implements OrderRepository {
private readonly repository: Repository<OrderEntity>
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(OrderEntity)
}
async save(order: Order): Promise<void> {
const entity = this.toEntity(order)
await this.repository.save(entity)
}
async findById(id: string): Promise<Order | null> {
const entity = await this.repository.findOne({
where: { id },
relations: ['items'],
})
return entity ? this.toDomain(entity) : null
}
async findByCustomerId(customerId: string): Promise<Order[]> {
const entities = await this.repository.find({
where: { customerId },
relations: ['items'],
})
return entities.map((e) => this.toDomain(e))
}
private toEntity(order: Order): OrderEntity {
const entity = new OrderEntity()
entity.id = order.getId()
entity.customerId = order.getCustomerId()
entity.status = order.getStatus()
entity.totalAmount = order.getTotalAmount().getAmount()
return entity
}
private toDomain(entity: OrderEntity): Order {
return Order.create(entity.id, entity.customerId, /* items mapping */ [])
}
}
// infrastructure/adapters/inbound/http/OrderController.ts
import { Router, Request, Response } from 'express'
import { CreateOrderUseCase } from '../../../application/ports/inbound/CreateOrderUseCase'
export class OrderController {
private readonly router: Router
constructor(private readonly createOrderUseCase: CreateOrderUseCase) {
this.router = Router()
this.setupRoutes()
}
private setupRoutes(): void {
this.router.post('/orders', this.createOrder.bind(this))
}
private async createOrder(req: Request, res: Response): Promise<void> {
try {
const result = await this.createOrderUseCase.execute({
customerId: req.body.customerId,
items: req.body.items,
})
res.status(201).json(result)
} catch (error) {
if (error instanceof Error) {
res.status(400).json({ error: error.message })
} else {
res.status(500).json({ error: 'Internal server error' })
}
}
}
getRouter(): Router {
return this.router
}
}
DI Container Setup
// infrastructure/config/DependencyContainer.ts
import { DataSource } from 'typeorm'
import { CreateOrderService } from '../../application/services/CreateOrderService'
import { TypeOrmOrderRepository } from '../adapters/outbound/persistence/TypeOrmOrderRepository'
import { StripePaymentAdapter } from '../adapters/outbound/payment/StripePaymentAdapter'
import { KafkaEventPublisher } from '../adapters/outbound/messaging/KafkaEventPublisher'
import { OrderController } from '../adapters/inbound/http/OrderController'
export class DependencyContainer {
private static instance: DependencyContainer
private constructor(private readonly dataSource: DataSource) {}
static async create(dataSource: DataSource): Promise<DependencyContainer> {
if (!DependencyContainer.instance) {
DependencyContainer.instance = new DependencyContainer(dataSource)
}
return DependencyContainer.instance
}
createOrderController(): OrderController {
// Create outbound adapters
const orderRepository = new TypeOrmOrderRepository(this.dataSource)
const paymentGateway = new StripePaymentAdapter()
const eventPublisher = new KafkaEventPublisher()
// Create use case (inject port implementations)
const createOrderService = new CreateOrderService(
orderRepository,
paymentGateway,
eventPublisher
)
// Create inbound adapter
return new OrderController(createOrderService)
}
}
Python Practical Implementation
Project Structure and Domain Layer
# domain/entities/order.py
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
from typing import List
from domain.value_objects.money import Money
class OrderStatus(Enum):
PENDING = "PENDING"
CONFIRMED = "CONFIRMED"
PAID = "PAID"
SHIPPED = "SHIPPED"
CANCELLED = "CANCELLED"
@dataclass
class OrderItem:
product_id: str
product_name: str
quantity: int
unit_price: Money
class Order:
def __init__(
self,
order_id: str,
customer_id: str,
items: List[OrderItem],
status: OrderStatus = OrderStatus.PENDING,
created_at: datetime = None,
):
if not items:
raise ValueError("Order must have at least one item")
self._id = order_id
self._customer_id = customer_id
self._items = items
self._status = status
self._created_at = created_at or datetime.utcnow()
def confirm(self) -> None:
if self._status != OrderStatus.PENDING:
raise ValueError("Only pending orders can be confirmed")
self._status = OrderStatus.CONFIRMED
def pay(self) -> None:
if self._status != OrderStatus.CONFIRMED:
raise ValueError("Only confirmed orders can be paid")
self._status = OrderStatus.PAID
def cancel(self) -> None:
if self._status in (OrderStatus.SHIPPED,):
raise ValueError("Cannot cancel shipped orders")
self._status = OrderStatus.CANCELLED
@property
def total_amount(self) -> Money:
total = Money(0)
for item in self._items:
total = total.add(item.unit_price.multiply(item.quantity))
return total
@property
def id(self) -> str:
return self._id
@property
def customer_id(self) -> str:
return self._customer_id
@property
def status(self) -> OrderStatus:
return self._status
Port Definitions (Abstract Classes)
# application/ports/outbound/order_repository.py
from abc import ABC, abstractmethod
from typing import Optional, List
from domain.entities.order import Order
class OrderRepository(ABC):
@abstractmethod
async def save(self, order: Order) -> None:
pass
@abstractmethod
async def find_by_id(self, order_id: str) -> Optional[Order]:
pass
@abstractmethod
async def find_by_customer_id(self, customer_id: str) -> List[Order]:
pass
# application/ports/outbound/payment_gateway.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class PaymentRequest:
order_id: str
amount: float
currency: str
@dataclass
class PaymentResult:
transaction_id: str
success: bool
error_message: str = ""
class PaymentGateway(ABC):
@abstractmethod
async def process_payment(self, request: PaymentRequest) -> PaymentResult:
pass
Use Case Implementation
# application/services/create_order_service.py
import uuid
from dataclasses import dataclass
from typing import List
from domain.entities.order import Order, OrderItem
from domain.value_objects.money import Money
from application.ports.outbound.order_repository import OrderRepository
from application.ports.outbound.payment_gateway import PaymentGateway, PaymentRequest
@dataclass
class CreateOrderCommand:
customer_id: str
items: List[dict]
@dataclass
class CreateOrderResult:
order_id: str
total_amount: float
status: str
class CreateOrderService:
def __init__(
self,
order_repository: OrderRepository,
payment_gateway: PaymentGateway,
):
self._order_repository = order_repository
self._payment_gateway = payment_gateway
async def execute(self, command: CreateOrderCommand) -> CreateOrderResult:
items = [
OrderItem(
product_id=item["product_id"],
product_name=item["product_name"],
quantity=item["quantity"],
unit_price=Money(item["unit_price"]),
)
for item in command.items
]
order = Order(
order_id=str(uuid.uuid4()),
customer_id=command.customer_id,
items=items,
)
order.confirm()
payment_result = await self._payment_gateway.process_payment(
PaymentRequest(
order_id=order.id,
amount=order.total_amount.amount,
currency="KRW",
)
)
if not payment_result.success:
order.cancel()
await self._order_repository.save(order)
raise Exception(f"Payment failed: {payment_result.error_message}")
order.pay()
await self._order_repository.save(order)
return CreateOrderResult(
order_id=order.id,
total_amount=order.total_amount.amount,
status=order.status.value,
)
Testing Strategy
Domain Unit Tests
The domain layer has no external dependencies, enabling pure unit testing.
// tests/domain/entities/Order.test.ts
import { Order, OrderItem } from '../../../domain/entities/Order'
import { Money } from '../../../domain/value-objects/Money'
import { OrderStatus } from '../../../domain/value-objects/OrderStatus'
describe('Order', () => {
const sampleItems: OrderItem[] = [
{
productId: 'prod-1',
productName: 'Widget',
quantity: 2,
unitPrice: Money.of(10000),
},
]
it('should create an order with PENDING status', () => {
const order = Order.create('order-1', 'customer-1', sampleItems)
expect(order.getStatus()).toBe(OrderStatus.PENDING)
})
it('should throw when creating order with empty items', () => {
expect(() => Order.create('order-1', 'customer-1', [])).toThrow(
'Order must have at least one item'
)
})
it('should calculate total amount correctly', () => {
const order = Order.create('order-1', 'customer-1', sampleItems)
expect(order.getTotalAmount().getAmount()).toBe(20000)
})
it('should transition from PENDING to CONFIRMED', () => {
const order = Order.create('order-1', 'customer-1', sampleItems)
order.confirm()
expect(order.getStatus()).toBe(OrderStatus.CONFIRMED)
})
it('should not allow paying before confirming', () => {
const order = Order.create('order-1', 'customer-1', sampleItems)
expect(() => order.pay()).toThrow('Only confirmed orders can be paid')
})
})
Use Case Integration Tests (Mocking Adapters)
// tests/application/services/CreateOrderService.test.ts
import { CreateOrderService } from '../../../application/services/CreateOrderService'
import { OrderRepository } from '../../../application/ports/outbound/OrderRepository'
import { PaymentGateway } from '../../../application/ports/outbound/PaymentGateway'
import { EventPublisher } from '../../../application/ports/outbound/EventPublisher'
describe('CreateOrderService', () => {
let service: CreateOrderService
let mockOrderRepo: jest.Mocked<OrderRepository>
let mockPayment: jest.Mocked<PaymentGateway>
let mockEvents: jest.Mocked<EventPublisher>
beforeEach(() => {
mockOrderRepo = {
save: jest.fn().mockResolvedValue(undefined),
findById: jest.fn(),
findByCustomerId: jest.fn(),
}
mockPayment = {
processPayment: jest.fn().mockResolvedValue({
transactionId: 'tx-1',
success: true,
}),
}
mockEvents = {
publish: jest.fn().mockResolvedValue(undefined),
}
service = new CreateOrderService(mockOrderRepo, mockPayment, mockEvents)
})
it('should create and pay for an order successfully', async () => {
const result = await service.execute({
customerId: 'cust-1',
items: [
{
productId: 'prod-1',
productName: 'Widget',
quantity: 1,
unitPrice: 10000,
},
],
})
expect(result.status).toBe('PAID')
expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
expect(mockPayment.processPayment).toHaveBeenCalledTimes(1)
expect(mockEvents.publish).toHaveBeenCalledTimes(1)
})
it('should cancel order when payment fails', async () => {
mockPayment.processPayment.mockResolvedValue({
transactionId: '',
success: false,
errorMessage: 'Insufficient funds',
})
await expect(
service.execute({
customerId: 'cust-1',
items: [
{
productId: 'prod-1',
productName: 'Widget',
quantity: 1,
unitPrice: 10000,
},
],
})
).rejects.toThrow('Payment failed')
expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
})
})
Anti-Patterns and Caveats
Anti-Pattern 1: Domain Depending on Infrastructure
// Bad: Domain entity depends on TypeORM decorators
import { Entity, Column, PrimaryColumn } from 'typeorm'
@Entity() // Infrastructure framework dependency!
export class Order {
@PrimaryColumn()
id: string
@Column()
status: string
}
// Good: Domain entity remains a pure class
// Persistence mapping handled by separate Entity classes in infrastructure layer
export class Order {
private readonly id: string
private status: OrderStatus
// Contains only pure business logic
}
Anti-Pattern 2: Anemic Domain Model
// Bad: Domain entity is just a data structure
export class Order {
id: string
status: string
items: OrderItem[]
totalAmount: number
// Only getters/setters, no business logic
}
// All business logic concentrated in services
export class OrderService {
confirmOrder(order: Order): void {
if (order.status !== 'PENDING') throw new Error('...')
order.status = 'CONFIRMED'
order.totalAmount = this.calculateTotal(order.items)
}
}
// Good: Business logic encapsulated within domain entity
export class Order {
confirm(): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error('Only pending orders can be confirmed')
}
this.status = OrderStatus.CONFIRMED
}
getTotalAmount(): Money {
return this.items.reduce(
(total, item) => total.add(item.unitPrice.multiply(item.quantity)),
Money.of(0)
)
}
}
Anti-Pattern 3: Over-Engineering
Applying hexagonal architecture to a small CRUD application only increases boilerplate code and reduces productivity. Architecture choices should match the project's complexity and expected lifespan.
Migration from Layered Architecture
Step-by-Step Migration Strategy
When transitioning from a layered architecture to hexagonal/clean architecture, take an incremental approach:
- Phase 1: Separate the domain layer -- Move business logic from services into entities
- Phase 2: Define ports (interfaces) -- Extract interfaces for repositories and external services
- Phase 3: Implement adapters -- Wrap existing implementations as adapters
- Phase 4: Configure DI container -- Connect ports and adapters via dependency injection
- Phase 5: Add tests -- Write domain unit tests and port mocking tests
Failure Cases in Boundary Decisions
Incorrect boundary definitions can negate the benefits of the architecture. For example, if a single use case depends on too many outbound ports, it signals that the use case has too many responsibilities. It is better to split the use case into smaller units.
Conversely, splitting ports too granularly causes the number of ports to explode, making management difficult. As a general rule, one Repository port per Aggregate Root and one Gateway port per external system is appropriate.
Production Checklist
Architecture Principles
- Verify dependencies always point from outside to inside
- Verify no framework imports exist in the domain layer
- Verify domain entities encapsulate sufficient business logic
- Verify port interfaces are defined in domain terminology (not technical terms)
- Verify DI container is only used at the Composition Root
Testing
- Domain layer unit test coverage above 90%
- Verify outbound ports are mocked in use case tests
- Verify adapter integration tests exist
- Verify E2E tests validate the complete flow
Code Quality
- Verify no circular dependencies between packages
- Verify single responsibility per use case (SRP)
- Verify port interfaces define minimal methods (ISP)
- Automate architecture rule verification with ArchUnit or similar tools