Hexagonal Architecture: Ports and Adapters
Hexagonal Architecture, also known as Ports and Adapters, was formulated by Alistair Cockburn in the early 2000s with a clear goal: to separate the application core from the surrounding infrastructure. This architectural pattern allows you to develop and test business logic in complete isolation from databases, frameworks, external APIs, and any other technical detail.
In this article, we will explore the core concepts of hexagonal architecture in depth, see how to implement it in TypeScript and Angular, and compare it with Clean Architecture and Onion Architecture to understand when to prefer it.
What You Will Learn
- Core concepts: hexagon, ports, and adapters
- Difference between primary (driving) and secondary (driven) ports
- Practical implementation in TypeScript with Dependency Injection
- Detailed comparison with Clean Architecture and Onion Architecture
- Complete example: a notification system
- Testing advantages and maintainability benefits
The Hexagon: The Heart of the Architecture
The hexagon represents the application domain, meaning all pure business logic free from external dependencies. The hexagonal shape is purely metaphorical: it conveys the idea that the application has many faces through which it communicates with the outside world, not just the two traditional ones (UI and database) from the layered model.
Inside the hexagon we find:
- Domain entities: Objects representing the fundamental business concepts
- Domain services: Logic that operates on entities and implements business rules
- Ports: Interfaces that define how the domain communicates with the outside
Ports: The Domain's Interfaces
Ports are interfaces defined in the domain that establish a contract. They are divided into two fundamental categories:
-
Primary Ports (Driving Ports): Define the application's use cases.
These are the interfaces through which the outside world drives the application.
Example: an
OrderServiceinterface exposed to a REST API or a CLI. -
Secondary Ports (Driven Ports): Define the application's dependencies
on the outside. These are the interfaces that the domain uses but does not
implement. Example: an
OrderRepositoryinterface for persistence, orNotificationSenderfor sending notifications.
Adapters: The Concrete Implementations
Adapters are concrete implementations of ports. They translate calls between the external world's format and the domain's format:
- Primary Adapters (Driving Adapters): Receive input from the outside and invoke primary ports. Examples: REST controllers, GraphQL handlers, message consumers, CLI interfaces.
- Secondary Adapters (Driven Adapters): Implement secondary ports. Examples: SQL repositories, HTTP clients for external APIs, email service adapters, filesystem adapters.
Implementation in TypeScript
Let's see how to implement hexagonal architecture in TypeScript, starting from port definitions up to adapter composition.
Defining Domain Entities
export type NotificationChannel = 'email' | 'sms' | 'push';
export type NotificationStatus = 'pending' | 'sent' | 'failed';
export interface Notification {
readonly id: string;
readonly recipientId: string;
readonly channel: NotificationChannel;
readonly subject: string;
readonly body: string;
readonly status: NotificationStatus;
readonly createdAt: Date;
readonly sentAt?: Date;
}
export interface NotificationRequest {
recipientId: string;
channel: NotificationChannel;
subject: string;
body: string;
}
Defining the Ports
// Primary Port: defines the use cases
export interface NotificationUseCases {
send(request: NotificationRequest): Promise<Notification>;
getById(id: string): Promise<Notification | null>;
getByRecipient(recipientId: string): Promise<Notification[]>;
retry(id: string): Promise<Notification>;
}
// Secondary Port: the domain depends on this interface
export interface NotificationRepository {
save(notification: Notification): Promise<Notification>;
findById(id: string): Promise<Notification | null>;
findByRecipientId(recipientId: string): Promise<Notification[]>;
update(notification: Notification): Promise<Notification>;
}
// Secondary Port: abstracts the actual sending
export interface NotificationSender {
send(
channel: NotificationChannel,
recipient: string,
subject: string,
body: string
): Promise<boolean>;
}
Domain Service: The Core Logic
import { v4 as uuidv4 } from 'uuid';
export class NotificationDomainService implements NotificationUseCases {
constructor(
private readonly repository: NotificationRepository,
private readonly sender: NotificationSender,
private readonly recipientResolver: RecipientResolver
) {}
async send(request: NotificationRequest): Promise<Notification> {
// Pure domain logic: no infrastructure dependencies
const recipient = await this.recipientResolver.resolve(request.recipientId);
if (!recipient) {
throw new DomainError('Recipient not found');
}
const notification: Notification = {
id: uuidv4(),
recipientId: request.recipientId,
channel: request.channel,
subject: request.subject,
body: request.body,
status: 'pending',
createdAt: new Date()
};
// Save as pending
const saved = await this.repository.save(notification);
// Attempt to send
try {
const success = await this.sender.send(
request.channel,
recipient.contactInfo[request.channel],
request.subject,
request.body
);
const updated = {
...saved,
status: success ? 'sent' as const : 'failed' as const,
sentAt: success ? new Date() : undefined
};
return this.repository.update(updated);
} catch (error) {
const failed = { ...saved, status: 'failed' as const };
return this.repository.update(failed);
}
}
async getById(id: string): Promise<Notification | null> {
return this.repository.findById(id);
}
async getByRecipient(recipientId: string): Promise<Notification[]> {
return this.repository.findByRecipientId(recipientId);
}
async retry(id: string): Promise<Notification> {
const notification = await this.repository.findById(id);
if (!notification) {
throw new DomainError('Notification not found');
}
if (notification.status !== 'failed') {
throw new DomainError('Only failed notifications can be retried');
}
return this.send({
recipientId: notification.recipientId,
channel: notification.channel,
subject: notification.subject,
body: notification.body
});
}
}
Secondary Adapters: Concrete Implementations
// Secondary Adapter: implements the repository port
export class FirestoreNotificationRepository implements NotificationRepository {
constructor(private readonly firestore: Firestore) {}
async save(notification: Notification): Promise<Notification> {
const docRef = doc(this.firestore, 'notifications', notification.id);
await setDoc(docRef, {
...notification,
createdAt: Timestamp.fromDate(notification.createdAt)
});
return notification;
}
async findById(id: string): Promise<Notification | null> {
const docRef = doc(this.firestore, 'notifications', id);
const snapshot = await getDoc(docRef);
return snapshot.exists() ? this.toDomain(snapshot.data()) : null;
}
async findByRecipientId(recipientId: string): Promise<Notification[]> {
const q = query(
collection(this.firestore, 'notifications'),
where('recipientId', '==', recipientId),
orderBy('createdAt', 'desc')
);
const snapshot = await getDocs(q);
return snapshot.docs.map(d => this.toDomain(d.data()));
}
async update(notification: Notification): Promise<Notification> {
const docRef = doc(this.firestore, 'notifications', notification.id);
await updateDoc(docRef, { ...notification });
return notification;
}
private toDomain(data: any): Notification {
return {
...data,
createdAt: data.createdAt.toDate(),
sentAt: data.sentAt?.toDate()
} as Notification;
}
}
// Secondary Adapter: handles multichannel sending
export class MultiChannelNotificationSender implements NotificationSender {
private readonly channels: Map<NotificationChannel, ChannelProvider>;
constructor(
emailProvider: EmailProvider,
smsProvider: SmsProvider,
pushProvider: PushProvider
) {
this.channels = new Map([
['email', emailProvider],
['sms', smsProvider],
['push', pushProvider]
]);
}
async send(
channel: NotificationChannel,
recipient: string,
subject: string,
body: string
): Promise<boolean> {
const provider = this.channels.get(channel);
if (!provider) {
throw new Error('Unsupported channel: ' + channel);
}
return provider.deliver(recipient, subject, body);
}
}
Primary Adapter: REST Controller
// Primary Adapter: translates HTTP into domain calls
@Controller('/api/notifications')
export class NotificationController {
constructor(
private readonly notificationUseCases: NotificationUseCases
) {}
@Post('/')
async sendNotification(
@Body() dto: SendNotificationDto
): Promise<NotificationResponseDto> {
const notification = await this.notificationUseCases.send({
recipientId: dto.recipientId,
channel: dto.channel,
subject: dto.subject,
body: dto.body
});
return NotificationMapper.toDto(notification);
}
@Get('/:id')
async getNotification(
@Param('id') id: string
): Promise<NotificationResponseDto> {
const notification = await this.notificationUseCases.getById(id);
if (!notification) {
throw new NotFoundException('Notification not found');
}
return NotificationMapper.toDto(notification);
}
@Post('/:id/retry')
async retryNotification(
@Param('id') id: string
): Promise<NotificationResponseDto> {
const notification = await this.notificationUseCases.retry(id);
return NotificationMapper.toDto(notification);
}
}
Composition with Dependency Injection in Angular
// Injection tokens for secondary ports
export const NOTIFICATION_REPOSITORY =
new InjectionToken<NotificationRepository>('NotificationRepository');
export const NOTIFICATION_SENDER =
new InjectionToken<NotificationSender>('NotificationSender');
export const RECIPIENT_RESOLVER =
new InjectionToken<RecipientResolver>('RecipientResolver');
// Provider configuration in app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
// Secondary adapters: easily replaceable
{
provide: NOTIFICATION_REPOSITORY,
useClass: FirestoreNotificationRepository
},
{
provide: NOTIFICATION_SENDER,
useClass: MultiChannelNotificationSender
},
{
provide: RECIPIENT_RESOLVER,
useClass: FirestoreRecipientResolver
},
// Domain service composed with ports
{
provide: NotificationDomainService,
useFactory: (repo, sender, resolver) =>
new NotificationDomainService(repo, sender, resolver),
deps: [
NOTIFICATION_REPOSITORY,
NOTIFICATION_SENDER,
RECIPIENT_RESOLVER
]
}
]
};
Comparison: Hexagonal vs Clean vs Onion
Hexagonal architecture shares fundamental principles with Robert C. Martin's Clean Architecture and Jeffrey Palermo's Onion Architecture. All three aim to isolate the domain, but with different nuances.
Comparison Table
| Aspect | Hexagonal | Clean Architecture | Onion Architecture |
|---|---|---|---|
| Author | Alistair Cockburn | Robert C. Martin | Jeffrey Palermo |
| Metaphor | Hexagon with ports | Concentric circles | Onion layers |
| Focus | Inside/outside interactions | Dependency rule | Domain layering |
| Interfaces | Ports (driving/driven) | Use Cases + Gateways | Domain Services |
| Implementations | Adapters | Interface Adapters + Frameworks | Infrastructure Layer |
| Testability | Excellent: swap adapters | Excellent: mock gateways | Excellent: mock outer layers |
| Complexity | Medium | High (more layers) | Medium-high |
| Ideal for | Systems with many integrations | Enterprise applications | Complex domains with DDD |
The main difference lies in the perspective: hexagonal architecture reasons in terms of inside and outside (the domain and the periphery), Clean Architecture in terms of concentric layers with a dependency rule, and Onion Architecture emphasizes the domain layering with model, domain services, and application services.
Testing Advantages
The most significant advantage of hexagonal architecture is ease of testing. Since the domain depends only on interfaces (ports), we can replace adapters with test implementations without any modification to the business code.
describe('NotificationDomainService', () => {
let service: NotificationDomainService;
let mockRepository: jest.Mocked<NotificationRepository>;
let mockSender: jest.Mocked<NotificationSender>;
let mockResolver: jest.Mocked<RecipientResolver>;
beforeEach(() => {
// Mock adapters: implement the same ports
mockRepository = {
save: jest.fn().mockImplementation(n => Promise.resolve(n)),
findById: jest.fn(),
findByRecipientId: jest.fn(),
update: jest.fn().mockImplementation(n => Promise.resolve(n))
};
mockSender = {
send: jest.fn().mockResolvedValue(true)
};
mockResolver = {
resolve: jest.fn().mockResolvedValue({
id: 'user-1',
contactInfo: { email: 'user@example.com' }
})
};
service = new NotificationDomainService(
mockRepository, mockSender, mockResolver
);
});
it('should send a notification successfully', async () => {
const request: NotificationRequest = {
recipientId: 'user-1',
channel: 'email',
subject: 'Test',
body: 'Test message'
};
const result = await service.send(request);
expect(result.status).toBe('sent');
expect(mockSender.send).toHaveBeenCalledWith(
'email', 'user@example.com', 'Test', 'Test message'
);
expect(mockRepository.save).toHaveBeenCalled();
expect(mockRepository.update).toHaveBeenCalled();
});
it('should handle send failure', async () => {
mockSender.send.mockResolvedValue(false);
const result = await service.send({
recipientId: 'user-1',
channel: 'email',
subject: 'Test',
body: 'Body'
});
expect(result.status).toBe('failed');
});
it('should not retry non-failed notifications', async () => {
mockRepository.findById.mockResolvedValue({
id: 'notif-1',
recipientId: 'user-1',
channel: 'email',
subject: 'Test',
body: 'Body',
status: 'sent',
createdAt: new Date()
});
await expect(service.retry('notif-1'))
.rejects.toThrow('Only failed notifications can be retried');
});
});
Why Tests Are Simpler
Notice how we don't need Firestore, an SMTP server, or any external service to test the business logic. Each port is replaced with a mock that implements the same interface. This makes tests:
- Fast: No network connections or I/O
- Deterministic: No dependency on external state
- Isolated: Each test verifies only domain logic
- Maintainable: Changing the database doesn't require modifying tests
When to Use Hexagonal Architecture
Ideal Scenarios
- Multiple integrations: The application communicates with many external systems (DB, APIs, message brokers, email services)
- Replaceability required: Service providers may change over time (migrating from AWS to Azure)
- Critical testability: Business logic is complex and requires thorough testing
- Distributed teams: Different teams work on different adapters in parallel
- Project longevity: The project will last for years and the framework might change
When to Avoid It
- Simple CRUD: Applications with little business logic don't justify the added complexity
- Prototypes and MVPs: Delivery speed matters more than architecture
- Small, inexperienced teams: The learning curve could slow down development
- Minimal microservices: Very small services with a single integration
Recommended Folder Structure
src/
domain/
entities/
notification.ts
recipient.ts
ports/
primary/
notification-use-cases.port.ts
secondary/
notification-repository.port.ts
notification-sender.port.ts
recipient-resolver.port.ts
services/
notification-domain.service.ts
errors/
domain-error.ts
adapters/
primary/
rest/
notification.controller.ts
dto/
send-notification.dto.ts
graphql/
notification.resolver.ts
secondary/
persistence/
firestore-notification.repository.ts
in-memory-notification.repository.ts
messaging/
multi-channel-notification.sender.ts
email/
sendgrid-email.provider.ts
sms/
twilio-sms.provider.ts
config/
dependency-injection.ts
Conclusion
Hexagonal architecture is a powerful pattern that puts the domain at the center and protects it from infrastructure dependencies. Through the ports and adapters mechanism, we achieve a highly testable, flexible, and maintainable application over time.
The key takeaways are:
Related deep dive: 2 - Domain-Driven Design per Applicazioni Web.
Related deep dive: 3 - CQRS e Event Sourcing con TypeScript.
- Primary ports define what the application can do (use cases)
- Secondary ports define what the application needs (dependencies)
- Adapters are interchangeable and can be swapped without touching the domain
- Dependency Injection is the mechanism that wires everything together
- Tests become naturally simple and fast
Key Takeaways
- Domain at the center: Business logic never depends on infrastructure
- Ports as contracts: Interfaces define the boundaries
- Replaceable adapters: Change database, framework, or provider without touching the domain
- Isolated tests: Mock secondary ports for fast and deterministic tests
- DI as glue: Angular's injection token makes composition explicit
Frequently asked questions
What is the main goal of Hexagonal Architecture?
The main goal of Hexagonal Architecture is to separate the application core from the surrounding infrastructure.
What are ports in the context of Hexagonal Architecture?
Ports are interfaces defined in the domain that establish a contract and are divided into two fundamental categories.
How does Hexagonal Architecture make testing easier?
Since the domain depends only on interfaces (ports), we can replace adapters with test implementations without any modification to the business code, making tests more efficient.
What is the key advantage of using Hexagonal Architecture in application development?
The key advantage of using Hexagonal Architecture is that it allows for a highly testable, flexible, and maintainable application over time.
Commenti
Caricamento commenti...
Sign in to comment