The Hexagonal Architecture
Introduction
Hexagonal Architecture (also known as Ports and Adapters pattern) organizes code to enforce separation of concerns and dependency inversion, making applications more maintainable, testable, and adaptable.
Core Concepts
1. Three Main Layers
Domain Core: Business logic and entities
Ports: Interface definitions
Adapters: Implementation of interfaces
2. Key Principles
Domain core has no external dependencies
All dependencies point inward
External systems interact through ports
Business logic is technology-agnostic
Implementation Example
Domain Core
// Domain Entity
class Order {
constructor(
private id: string,
private items: OrderItem[],
private status: OrderStatus
) {}
calculateTotal(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
confirm(): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error('Order cannot be confirmed');
}
this.status = OrderStatus.CONFIRMED;
}
}
// Port (Primary/Driving)
interface OrderService {
createOrder(items: OrderItem[]): Promise<Order>;
confirmOrder(orderId: string): Promise<void>;
}
// Port (Secondary/Driven)
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order>;
}
Adapters
// Primary Adapter (REST Controller)
class OrderController {
constructor(private orderService: OrderService) {}
@Post('/orders')
async createOrder(@Body() items: OrderItem[]): Promise<Order> {
return this.orderService.createOrder(items);
}
}
// Secondary Adapter (Database Implementation)
class PostgresOrderRepository implements OrderRepository {
constructor(private db: Database) {}
async save(order: Order): Promise<void> {
await this.db.query('INSERT INTO orders...', [order.toJSON()]);
}
async findById(id: string): Promise<Order> {
const data = await this.db.query('SELECT * FROM orders WHERE id = $1', [id]);
return Order.fromJSON(data);
}
}
Application Service
class OrderApplicationService implements OrderService {
constructor(private orderRepository: OrderRepository) {}
async createOrder(items: OrderItem[]): Promise<Order> {
const order = new Order(uuid(), items, OrderStatus.PENDING);
await this.orderRepository.save(order);
return order;
}
async confirmOrder(orderId: string): Promise<void> {
const order = await this.orderRepository.findById(orderId);
order.confirm();
await this.orderRepository.save(order);
}
}
Testing Approach
describe('OrderService', () => {
let orderService: OrderService;
let mockRepository: MockOrderRepository;
beforeEach(() => {
mockRepository = new MockOrderRepository();
orderService = new OrderApplicationService(mockRepository);
});
it('should create order', async () => {
const items = [{ productId: '1', quantity: 2, price: 10 }];
const order = await orderService.createOrder(items);
expect(order.calculateTotal()).toBe(20);
expect(mockRepository.savedOrder).toBeDefined();
});
});
Common Patterns
Dependency Injection
// Configuration
class AppConfig {
@Inject()
static configureServices(container: Container): void {
container.register<OrderService>(OrderApplicationService);
container.register<OrderRepository>(PostgresOrderRepository);
}
}
Error Handling
// Domain Exception
class DomainException extends Error {
constructor(message: string, public readonly code: string) {
super(message);
}
}
// Adapter Exception Handler
@Catch(DomainException)
class DomainExceptionFilter implements ExceptionFilter {
catch(exception: DomainException, host: ArgumentsHost): void {
const response = host.switchToHttp().getResponse();
response.status(400).json({
code: exception.code,
message: exception.message
});
}
}
Best Practices
Domain Logic Isolation
Keep business rules in domain layer
Use value objects for domain concepts
Implement domain events for side effects
Port Design
Define clear interface contracts
Use dependency injection
Keep ports focused and cohesive
Adapter Implementation
Handle technology-specific concerns
Implement error mapping
Maintain separation between adapters
Anti-patterns to Avoid
Domain logic leaking into adapters
Direct adapter-to-adapter communication
Technology-specific dependencies in domain
Bypassing ports for external communication
Practical Considerations
Performance Optimization
// Caching Adapter
class CachedOrderRepository implements OrderRepository {
constructor(
private repository: OrderRepository,
private cache: Cache
) {}
async findById(id: string): Promise<Order> {
const cached = await this.cache.get(id);
if (cached) return Order.fromJSON(cached);
const order = await this.repository.findById(id);
await this.cache.set(id, order.toJSON());
return order;
}
}
Scalability
// Event-Driven Adapter
class EventDrivenOrderRepository implements OrderRepository {
constructor(private eventBus: EventBus) {}
async save(order: Order): Promise<void> {
await this.eventBus.publish('order.saved', order);
}
}
Migration Strategies
Incremental Approach
Identify domain boundaries
Create ports for existing interfaces
Gradually refactor adapters
Move business logic to domain
Testing Strategy
Write tests against ports
Mock external dependencies
Ensure domain logic coverage
Test adapters in isolation
Conclusion
Hexagonal Architecture provides:
Clear separation of concerns
Technology independence
Improved testability
Flexible adaptation to change
Success depends on careful boundary definition, strict adherence to dependency rules, and consistent refactoring toward the pattern's principles.