Theoretical Foundations
Welcome to the curriculum workspace. Here you will find long-form technical guidelines outlining core architectural blueprints and implementation mechanics.
Module 3: Clean & Hexagonal Architecture Patterns
PHASE 1 ā MICRO-ARCHITECTURE: This module teaches you to organize entire application layers using architectural boundaries that prevent business logic from leaking into infrastructure concerns. The Hexagonal Architecture you learn here is the structural foundation of Domain-Driven Design (Module 8), Event-Driven Architectures (Module 9), and microservice decomposition. Every architecture in Phases 2ā5 is a hexagonal design at scale.
Introduction: The Dependency Problem at Architectural Scale
In Module 1, you applied the Dependency Inversion Principle to individual classes. In Module 3, you apply it to entire application layers.
Consider a typical web application without architectural boundaries:
[HTTP Handlers]
|
[Service Classes] <--> [Prisma ORM] <--> [PostgreSQL]
|
[Stripe SDK] <--> [Redis Client] <--> [SendGrid SDK]
After 18 months of feature additions, the business logic classes are coupled to 7+ infrastructure dependencies. To run a unit test on OrderService.calculateTotal(), you need a running PostgreSQL database, a Redis instance, and valid Stripe and SendGrid API keys.
Infrastructure changes cascade into business logic. A Stripe API version upgrade forces modifications to your domain objects. This is the problem Clean Architecture and Hexagonal Architecture solve.
Section 1: Clean Architecture (Robert C. Martin)
Robert C. Martin formalized Clean Architecture in 2012. It organizes a system into concentric dependency rings where the Dependency Rule is absolute: source code dependencies must point inward only.
+----------------------------------+
| Frameworks & Drivers |
| +------------------------+ |
| | Interface Adapters | |
| | +----------------+ | |
| | | Use Cases | | |
| | | +----------+ | | |
| | | | Entities | | | |
| | | +----------+ | | |
| | +----------------+ | |
| +------------------------+ |
+----------------------------------+
Dependencies flow INWARD only.
Inner rings know nothing of outer rings.
The Four Rings
Ring 1: Entities (Enterprise Business Rules)
The innermost ring. Contains core domain objects and their invariants. Zero dependencies on databases, HTTP, or any framework.
// Entity: pure business logic, zero external imports
export class Order {
private _status: OrderStatus = 'pending';
private _items: OrderItem[];
constructor(
public readonly id: string,
public readonly customerId: string,
items: OrderItem[]
) {
if (!items.length) throw new Error('Order must have at least one item');
this._items = [...items];
}
get total(): number {
return this._items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
confirm(): void {
if (this._status !== 'pending')
throw new Error(`Cannot confirm order in status: ${this._status}`);
this._status = 'confirmed';
}
cancel(): void {
const cancellable: OrderStatus[] = ['pending', 'confirmed'];
if (!cancellable.includes(this._status))
throw new Error(`Cannot cancel order in status: ${this._status}`);
this._status = 'cancelled';
}
get status(): OrderStatus { return this._status; }
get items(): readonly OrderItem[] { return this._items; }
}
No ORM, no HTTP, no SDK. This class can run in a browser, CLI, test, or serverless function ā anywhere TypeScript runs.
Ring 2: Use Cases (Application Business Rules)
Orchestrates the flow of data to and from entities. Contains application-specific business rules. Depends only on Entities and on interface abstractions (Ports) for infrastructure.
class PlaceOrderUseCase {
constructor(
private orderRepository: OrderRepository, // Port (interface)
private paymentService: PaymentService, // Port (interface)
private notificationService: NotificationService // Port (interface)
) {}
async execute(command: PlaceOrderCommand): Promise<PlaceOrderResult> {
const order = new Order(
generateId(),
command.customerId,
command.items.map(i => new OrderItem(i.productId, i.quantity, i.unitPrice))
);
await this.paymentService.charge(order.customerId, order.total);
order.confirm();
await this.orderRepository.save(order);
await this.notificationService.sendOrderConfirmation(order);
return { orderId: order.id, total: order.total };
}
}
Ring 3: Interface Adapters
Converts data formats between use cases and external systems. Contains controllers (HTTP ā Use Case DTOs), presenters (Use Case results ā HTTP responses), and repository implementations (ORM ā Domain Entities).
Ring 4: Frameworks & Drivers
The outermost ring. Contains database drivers (Prisma), web frameworks (Next.js, Express), third-party SDKs (Stripe, SendGrid). This ring knows everything; the inner rings know nothing of this ring.
Section 2: Hexagonal Architecture (Ports & Adapters)
Hexagonal Architecture was conceived by Alistair Cockburn in 2005. The application is a hexagon with ports (interfaces) on its boundary and adapters (implementations) outside.
[ Test Suite ]
|
[ HTTP Controller ]
|
+----------------+----------------+
| |
[ Email ]-+ APPLICATION +--[ PostgreSQL ]
[ Adapter ] | (Domain + Use Cases) | [ Adapter ]
| |
[ Redis ]-+ +--[ Kafka ]
[ Adapter ] | | [ Adapter ]
+----------------+----------------+
|
[ CLI Adapter ]
Ports vs Adapters
A Port is an interface ā a contract defined by the application. The application owns the port. It lives inside the hexagon.
An Adapter is a concrete implementation that wires a specific technology to a port. It lives outside the hexagon.
INSIDE THE HEXAGON: OUTSIDE THE HEXAGON:
---------------------------- ----------------------------
interface OrderRepository <-impl-- PrismaOrderRepository
interface EmailPort <-impl-- SendGridAdapter
interface PaymentPort <-impl-- StripeAdapter
Driving vs Driven Actors
graph LR
subgraph Driving Adapters
HTTP[HTTP Controller]
CLI[CLI Handler]
Test[Test Suite]
MQC[Message Consumer]
end
subgraph Hexagon
IP[Input Ports]
UC[Use Cases]
E[Entities]
OP[Output Ports]
end
subgraph Driven Adapters
DB[(PostgreSQL\nAdapter)]
Cache[(Redis\nAdapter)]
Email[SendGrid\nAdapter]
MQP[Kafka\nAdapter]
end
HTTP --> IP
CLI --> IP
Test --> IP
MQC --> IP
IP --> UC
UC --> E
UC --> OP
OP --> DB
OP --> Cache
OP --> Email
OP --> MQP
Left Side (Driving Actors): Initiate interactions with the application ā HTTP controllers, CLI handlers, test suites, message queue consumers, scheduled jobs.
Right Side (Driven Actors): The application initiates interactions with them ā database repositories, cache clients, email services, message publishers, external APIs.
Section 3: Full TypeScript Implementation
Directory Structure
src/
āāā domain/ # Ring 1: Entities
ā āāā entities/
ā āāā Order.ts
ā
āāā application/ # Ring 2: Use Cases + Ports
ā āāā use-cases/
ā ā āāā PlaceOrderUseCase.ts
ā āāā ports/
ā āāā output/
ā ā āāā OrderRepository.ts # Output Port
ā ā āāā PaymentService.ts # Output Port
ā ā āāā NotificationService.ts # Output Port
ā āāā input/
ā āāā OrderUseCase.ts # Input Port
ā
āāā adapters/ # Ring 3: Interface Adapters
ā āāā inbound/
ā ā āāā http/
ā ā āāā OrderController.ts
ā āāā outbound/
ā āāā PrismaOrderRepository.ts
ā āāā StripePaymentAdapter.ts
ā āāā SendGridNotificationAdapter.ts
ā
āāā infrastructure/ # Ring 4: Wiring
āāā composition-root.ts
Output Ports (Application Layer)
// src/application/ports/output/OrderRepository.ts
// Interface OWNED by the application (inversion of control)
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByCustomerId(customerId: string): Promise<Order[]>;
}
// src/application/ports/output/PaymentService.ts
export interface PaymentService {
charge(customerId: string, amount: number): Promise<PaymentResult>;
refund(transactionId: string, amount: number): Promise<void>;
}
// src/application/ports/output/NotificationService.ts
export interface NotificationService {
sendOrderConfirmation(order: Order): Promise<void>;
sendCancellationNotice(order: Order): Promise<void>;
}
Driven Adapters (Outbound)
// src/adapters/outbound/PrismaOrderRepository.ts
export class PrismaOrderRepository implements OrderRepository {
constructor(private prisma: PrismaClient) {}
async save(order: Order): Promise<void> {
await this.prisma.order.upsert({
where: { id: order.id },
update: { status: order.status, total: order.total },
create: {
id: order.id,
customerId: order.customerId,
status: order.status,
total: order.total,
items: {
create: order.items.map(i => ({
productId: i.productId,
quantity: i.quantity,
unitPrice: i.unitPrice,
}))
}
}
});
}
async findById(id: string): Promise<Order | null> {
const data = await this.prisma.order.findUnique({
where: { id },
include: { items: true }
});
return data ? this.toDomain(data) : null;
}
private toDomain(data: any): Order {
return new Order(
data.id,
data.customerId,
data.items.map((i: any) => new OrderItem(i.productId, i.quantity, i.unitPrice))
);
}
}
// src/adapters/outbound/StripePaymentAdapter.ts
export class StripePaymentAdapter implements PaymentService {
constructor(private stripe: Stripe) {}
async charge(customerId: string, amount: number): Promise<PaymentResult> {
const intent = await this.stripe.paymentIntents.create({
amount: Math.round(amount * 100),
currency: 'usd',
customer: customerId,
confirm: true,
});
return { transactionId: intent.id, status: 'success' };
}
async refund(transactionId: string, amount: number): Promise<void> {
await this.stripe.refunds.create({
payment_intent: transactionId,
amount: Math.round(amount * 100),
});
}
}
Driving Adapter (Inbound HTTP)
// src/adapters/inbound/http/OrderController.ts
export class OrderController {
constructor(private placeOrder: PlaceOrderUseCase) {}
async createOrder(req: Request, res: Response): Promise<void> {
try {
// Translate HTTP ā Use Case DTO
const command: PlaceOrderCommand = {
customerId: req.user.id,
items: req.body.items.map((i: any) => ({
productId: i.product_id,
quantity: i.quantity,
unitPrice: i.unit_price,
})),
};
const result = await this.placeOrder.execute(command);
// Translate Use Case result ā HTTP response
res.status(201).json({
order_id: result.orderId,
total: result.total,
transaction_id: result.transactionId,
});
} catch (err) {
if (err instanceof DomainError) res.status(400).json({ error: err.message });
else res.status(500).json({ error: 'Internal server error' });
}
}
}
Composition Root (Dependency Injection)
// src/infrastructure/composition-root.ts
// The ONLY place where concrete dependencies are instantiated
const prisma = new PrismaClient();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const orderRepository = new PrismaOrderRepository(prisma);
const paymentService = new StripePaymentAdapter(stripe);
const notificationService = new SendGridNotificationAdapter(process.env.SENDGRID_KEY!);
const placeOrderUseCase = new PlaceOrderUseCase(
orderRepository,
paymentService,
notificationService
);
export const orderController = new OrderController(placeOrderUseCase);
Section 4: Testing Without Infrastructure
The most powerful benefit of hexagonal architecture: use cases depend on port interfaces, so tests inject in-memory fakes.
describe('PlaceOrderUseCase', () => {
let useCase: PlaceOrderUseCase;
let mockRepo: OrderRepository;
let mockPayment: PaymentService;
let mockNotifications: NotificationService;
beforeEach(() => {
mockRepo = {
save: jest.fn().mockResolvedValue(undefined),
findById: jest.fn(),
findByCustomerId: jest.fn(),
};
mockPayment = {
charge: jest.fn().mockResolvedValue({ transactionId: 'txn_123', status: 'success' }),
refund: jest.fn(),
};
mockNotifications = {
sendOrderConfirmation: jest.fn().mockResolvedValue(undefined),
sendCancellationNotice: jest.fn(),
};
useCase = new PlaceOrderUseCase(mockRepo, mockPayment, mockNotifications);
});
it('confirms an order after successful payment', async () => {
const result = await useCase.execute({
customerId: 'cust-1',
items: [{ productId: 'prod-1', quantity: 2, unitPrice: 50 }]
});
expect(result.total).toBe(100);
expect(mockPayment.charge).toHaveBeenCalledWith('cust-1', 100);
expect(mockRepo.save).toHaveBeenCalledTimes(1);
expect(mockNotifications.sendOrderConfirmation).toHaveBeenCalledTimes(1);
});
it('does not persist if payment fails', async () => {
mockPayment.charge = jest.fn().mockRejectedValue(new Error('Card declined'));
await expect(
useCase.execute({ customerId: 'cust-1', items: [{ productId: 'p1', quantity: 1, unitPrice: 50 }] })
).rejects.toThrow('Card declined');
expect(mockRepo.save).not.toHaveBeenCalled();
});
});
Test execution: < 50ms. No database migrations, no Docker containers, no API mocking servers.
Section 5: ArchUnit Package Dependency Validation
Enforce hexagonal boundaries at the linter/CI level so no import violation ever merges:
{
"plugins": ["boundaries"],
"settings": {
"boundaries/elements": [
{ "type": "domain", "pattern": "src/domain/**" },
{ "type": "application", "pattern": "src/application/**" },
{ "type": "adapters", "pattern": "src/adapters/**" },
{ "type": "infrastructure", "pattern": "src/infrastructure/**" }
]
},
"rules": {
"boundaries/element-types": ["error", {
"default": "disallow",
"rules": [
{ "from": "infrastructure", "allow": ["adapters", "application", "domain"] },
{ "from": "adapters", "allow": ["application", "domain"] },
{ "from": "application", "allow": ["domain"] },
{ "from": "domain", "allow": [] }
]
}]
}
}
If a developer writes import { PrismaClient } from '@prisma/client' inside src/domain/, the linter immediately fails the build before the PR is merged.
Section 6: Architecture Comparison
| Onion Architecture | Clean Architecture | Hexagonal Architecture | |
|---|---|---|---|
| Author | Jeffrey Palermo (2008) | Robert C. Martin (2012) | Alistair Cockburn (2005) |
| Metaphor | Concentric rings | Concentric circles | Hexagon with ports |
| Core principle | Inward dependencies | Dependency Rule | Ports & Adapters |
| Infrastructure position | Outermost ring | Frameworks layer | Outside adapters |
| Shared invariant | ā Dependencies point inward | ā Dependencies point inward | ā Dependencies point inward |
All three architectures share one invariant: dependencies point inward. The domain model at the center has zero knowledge of the infrastructure at the edge.
Section 7: Hands-On Practice Challenge
The Challenge
Map a full hexagonal boundary for a User Authentication system. Your diagram must include:
- Driving adapters: HTTP Login Controller, CLI Token Reset.
- Input Port:
AuthUseCaseinterface. - Domain core:
Userentity,AuthenticateUserUseCase. - Output Ports:
UserRepository,PasswordHasher,SessionStore. - Driven adapters:
PrismaUserRepository,BcryptHasher,RedisSessionStore.
Solution Model
graph LR
subgraph Driving Adapters
HTTP[HTTP Login\nController]
CLI[CLI Token\nReset]
end
subgraph Application Hexagon
IP[AuthUseCase\nInput Port]
UC[AuthenticateUser\nUseCase]
E[User Entity]
OP1[UserRepository\nOutput Port]
OP2[PasswordHasher\nOutput Port]
OP3[SessionStore\nOutput Port]
end
subgraph Driven Adapters
DB[(Prisma\nUser Repo)]
Hash[Bcrypt\nHasher]
Session[Redis\nSession Store]
end
HTTP --> IP
CLI --> IP
IP --> UC
UC --> E
UC --> OP1
UC --> OP2
UC --> OP3
OP1 --> DB
OP2 --> Hash
OP3 --> Session
Evaluation criteria:
- All dependency arrows inside the hexagon point toward the core entities.
- No arrow exits the hexagon toward infrastructure from the domain or use case layer.
- Driving adapters point into the hexagon; driven adapters are pointed to by the hexagon.
Bridge to Module 4: Phase 1 is complete. You have mastered SOLID principles, GoF patterns, and Hexagonal Architecture ā the three pillars of micro-architecture. Phase 2 begins with Module 4: Network Primitives, where you learn how your services communicate across networks, handle load at scale, and terminate TLS connections before any traffic reaches your hexagonal application boundaries.