Clean Architecture vs Hexagonal (Ports & Adapters): The Complete Practical Guide

Clean Architecture vs Hexagonal (Ports & Adapters): The Complete Practical Guide
Table of Contents
- The Problem: Dependency Creep
- The Dependency Inversion Principle (DIP): The Foundation
- Hexagonal Architecture: Driving vs Driven Ports
- Clean Architecture: The Concentric Circles Explained
- Ports vs Adapters: Implementation in TypeScript
- Java Implementation: Spring with Hexagonal Architecture
- Testing Without Infrastructure
- The Directory Structure: How to Organise the Code
- When Hexagonal/Clean Architecture is Overkill
- Frequently Asked Questions
- Key Takeaway
The Problem: Dependency Creep
In most applications grown organically, the business logic is entangled with infrastructure:
// ⌠Typical controller — Business logic coupled to HTTP AND database:
app.post('/orders', async (req, res) => {
const { userId, items } = req.body;
// Business rule hardcoded next to HTTP framework:
if (items.length === 0) {
return res.status(400).json({ error: 'Empty order' });
}
// Database call directly in HTTP handler:
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
const order = await db.query('INSERT INTO orders ...', [...]);
// Email service hardcoded:
await sendgrid.send({ to: user.email, subject: 'Order confirmed' });
res.json({ orderId: order.id });
});The consequences:
- Unit testing the "empty order" rule requires a real HTTP server AND a real database
- Swapping PostgreSQL for MongoDB requires touching business logic code
- Adding a CLI interface for admin orders would duplicate the business rules
The Dependency Inversion Principle (DIP): The Foundation
The DIP states: High-level modules should not depend on low-level modules. Both should depend on abstractions.
Normal dependency direction: Inverted dependency direction:
(correct for Clean/Hexagonal)
BusinessLogic → Database BusinessLogic → RepositoryInterface
DatabaseAdapter → RepositoryInterfaceWhen business logic depends on an interface (not a concrete database), you can swap implementations freely. The database Adapter implements the interface — the domain defines it.
Hexagonal Architecture: Driving vs Driven Ports
Alistair Cockburn's Hexagonal Architecture divides adapters into two categories:
Driving Ports (left side): Interfaces that the outside world uses to call your application (e.g., PlaceOrderUseCase). Driving Adapters implement the callers (REST controllers, CLI commands).
Driven Ports (right side): Interfaces that your application calls to reach infrastructure (e.g., OrderRepository, EmailService). Driven Adapters implement these (PostgreSQL implementation, SendGrid implementation).
Clean Architecture: The Concentric Circles Explained
Uncle Bob's Clean Architecture adds more specificity to the layers inside the hexagon:
┌─────────────────────────────────────────────â”
│ Frameworks & Drivers │
│ (Spring Boot, Express, PostgreSQL driver) │
│ ┌─────────────────────────────────────────┠│
│ │ Interface Adapters │ │
│ │ (Controllers, Presenters, Gateways) │ │
│ │ ┌───────────────────────────────────┠│ │
│ │ │ Application (Use Cases) │ │ │
│ │ │ (PlaceOrderUseCase, etc.) │ │ │
│ │ │ ┌────────────────────────────┠│ │ │
│ │ │ │ Domain / Entities │ │ │ │
│ │ │ │ (Order, Customer, Money) │ │ │ │
│ │ │ └────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘Dependency Rule: Source code dependencies can only point inward. The inner circle knows nothing about the outer circles.
| Layer | Contains | Knows About |
|---|---|---|
| Domain/Entities | Business objects, domain logic | Nothing (pure business) |
| Use Cases | Application-specific business rules | Only Domain layer |
| Interface Adapters | Controllers, Presenters, Gateways | Use Cases + Domain |
| Frameworks & Drivers | DB, Web, UI frameworks | Everything |
Ports vs Adapters: Implementation in TypeScript
// ===== DOMAIN LAYER: Entities and Business Rules =====
// domain/entities/Order.ts
export class Order {
private readonly items: OrderItem[];
private status: OrderStatus = OrderStatus.DRAFT;
static create(customer: Customer, items: OrderItem[]): Order {
if (items.length === 0) throw new EmptyOrderError();
return new Order(customer, items);
}
place(): void {
if (this.status !== OrderStatus.DRAFT) throw new OrderAlreadyPlacedError();
this.status = OrderStatus.PLACED;
}
get totalAmount(): Money {
return this.items.reduce((sum, item) => sum.add(item.subtotal), Money.ZERO);
}
}
// ===== APPLICATION LAYER: Use Cases + Port Interfaces =====
// application/ports/OrderRepository.ts (DRIVEN PORT — defined by app, implemented outside)
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
findByCustomer(customerId: CustomerId): Promise<Order[]>;
}
// application/ports/EmailService.ts (DRIVEN PORT)
export interface EmailService {
sendOrderConfirmation(order: Order, customer: Customer): Promise<void>;
}
// application/usecases/PlaceOrder.ts (USE CASE — DRIVING PORT implementation target)
export class PlaceOrderUseCase {
constructor(
private readonly orders: OrderRepository, // Interface — no concrete DB
private readonly email: EmailService, // Interface — no concrete API
private readonly customers: CustomerRepository,
) {}
async execute(command: PlaceOrderCommand): Promise<OrderId> {
const customer = await this.customers.findById(command.customerId);
if (!customer) throw new CustomerNotFoundError(command.customerId);
const order = Order.create(customer, command.items);
order.place();
await this.orders.save(order);
await this.email.sendOrderConfirmation(order, customer);
return order.id;
}
}
// ===== ADAPTERS LAYER: Concrete Implementations =====
// adapters/persistence/PostgresOrderRepository.ts (DRIVEN ADAPTER)
import { Pool } from 'pg';
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
async save(order: Order): Promise<void> {
await this.pool.query(
'INSERT INTO orders (id, customer_id, status, total) VALUES ($1,$2,$3,$4)',
[order.id.value, order.customerId.value, order.status, order.totalAmount.value]
);
}
async findById(id: OrderId): Promise<Order | null> {
const result = await this.pool.query('SELECT * FROM orders WHERE id = $1', [id.value]);
return result.rows.length > 0 ? OrderMapper.toDomain(result.rows[0]) : null;
}
}
// adapters/http/OrderController.ts (DRIVING ADAPTER)
export class OrderController {
constructor(private readonly placeOrder: PlaceOrderUseCase) {}
async handlePost(req: Request, res: Response): Promise<void> {
const command = new PlaceOrderCommand(req.body);
const orderId = await this.placeOrder.execute(command);
res.status(201).json({ orderId: orderId.value });
}
}
// ===== COMPOSITION ROOT: Wire Everything Together =====
// infrastructure/container.ts
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const orderRepo = new PostgresOrderRepository(pool);
const emailSvc = new SendGridEmailAdapter(process.env.SENDGRID_KEY!);
const customerRepo = new PostgresCustomerRepository(pool);
const placeOrderUseCase = new PlaceOrderUseCase(orderRepo, emailSvc, customerRepo);
const orderController = new OrderController(placeOrderUseCase);Testing Without Infrastructure
The payoff of this architecture is effortless testing. The use case has zero dependencies on real infrastructure:
// ✅ Test PlaceOrderUseCase WITHOUT a database or email server:
describe('PlaceOrderUseCase', () => {
// In-memory adapters for testing:
const orderRepo = new InMemoryOrderRepository();
const emailSvc = new FakeEmailService();
const customerRepo = new InMemoryCustomerRepository();
const useCase = new PlaceOrderUseCase(orderRepo, emailSvc, customerRepo);
it('places an order and saves it', async () => {
const customer = Customer.create('Alice');
customerRepo.add(customer);
const orderId = await useCase.execute({
customerId: customer.id,
items: [new OrderItem('SKU-123', Money.of(10.00), 2)]
});
const saved = await orderRepo.findById(orderId);
expect(saved?.status).toBe('PLACED');
expect(emailSvc.sentEmails).toHaveLength(1);
});
it('throws EmptyOrderError for empty items', async () => {
await expect(useCase.execute({ customerId: 'c1', items: [] }))
.rejects.toBeInstanceOf(EmptyOrderError);
});
});
// No database running. No HTTP server. Tests complete in milliseconds.The Directory Structure
src/
├── domain/ Layer 1: Entities — framework-free
│ ├── entities/
│ │ ├── Order.ts
│ │ └── Customer.ts
│ └── valueobjects/
│ └── Money.ts
├── application/ Layer 2: Use Cases + Port Interfaces
│ ├── usecases/
│ │ └── PlaceOrder.ts
│ └── ports/ ↠Port interfaces defined HERE (not in adapters!)
│ ├── OrderRepository.ts
│ └── EmailService.ts
├── adapters/ Layer 3: Driving + Driven Adapters
│ ├── http/
│ │ └── OrderController.ts
│ ├── persistence/
│ │ └── PostgresOrderRepository.ts
│ └── email/
│ └── SendGridEmailAdapter.ts
└── infrastructure/ Layer 4: Composition Root + Config
├── container.ts
└── server.tsFrequently Asked Questions
What's the real difference between Clean and Hexagonal Architecture? Hexagonal is focused on the boundary between the application and the outside world — it explicitly categorises Driving vs Driven ports. Clean Architecture is focused on the internal layering — it adds the Domain/Entities and Use Cases distinction inside the hexagon. In practice, most production architectures use both concepts simultaneously: Clean Architecture's layers internally, Hexagonal's Port/Adapter naming externally.
How is this different from standard Layered (N-Tier) Architecture? In standard layered architecture, the Business Layer calls the Data Access Layer — it depends downward onto database code. In Hexagonal/Clean, the Business Layer defines an interface (Port) and the database implements that interface — the dependency is inverted. This means the business layer has zero knowledge of the database.
Key Takeaway
Clean and Hexagonal Architecture solve the same problem differently (circles vs hexagon), but produce the same outcome: business logic that is completely isolated from frameworks and databases. This isolation makes testing trivial, infrastructure swapping straightforward, and long-term maintainability dramatically better. The cost is real — more interfaces, more adapters, more code. Accept that cost gladly for systems with complex business rules that will live for 5+ years. Skip it for simple CRUD applications where the database is the business logic.
Read next: Microkernel Architecture: Building a Plugin-Based System →
Part of the Software Architecture Hub — comprehensive guides from architectural foundations to advanced distributed systems patterns.
