Architettura Esagonale: Ports and Adapters
L'Architettura Esagonale, nota anche come Ports and Adapters, è stata formulata da Alistair Cockburn nei primi anni 2000 con un obiettivo chiaro: separare il nucleo applicativo dall'infrastruttura circostante. Questo pattern architetturale consente di sviluppare e testare la logica di business in completo isolamento da database, framework, API esterne e qualsiasi altro dettaglio tecnico.
In questo articolo esploreremo in profondità i concetti fondamentali dell'architettura esagonale, vedremo come implementarla in TypeScript e Angular, e la confronteremo con Clean Architecture e Onion Architecture per capire quando preferirla.
Cosa Imparerai
- I concetti fondamentali: esagono, porte e adattatori
- Differenza tra porte primarie (driving) e secondarie (driven)
- Implementazione pratica in TypeScript con Dependency Injection
- Confronto dettagliato con Clean Architecture e Onion Architecture
- Esempio completo: un sistema di notifiche
- Vantaggi nei test e nella manutenibilità
L'Esagono: Il Cuore dell'Architettura
L'esagono rappresenta il dominio applicativo, cioe tutta la logica di business pura, priva di dipendenze verso l'esterno. La forma esagonale è puramente metaforica: serve a comunicare l'idea che l'applicazione ha molte facce attraverso cui comunica con il mondo esterno, non soltanto le due classiche (UI e database) del modello a layer tradizionale.
All'interno dell'esagono troviamo:
- Entità di dominio: Oggetti che rappresentano i concetti fondamentali del business
- Servizi di dominio: Logica che opera sulle entità e implementa regole di business
- Porte (Ports): Interfacce che definiscono come il dominio comunica con l'esterno
Porte (Ports): Le Interfacce del Dominio
Le porte sono interfacce definite nel dominio che stabiliscono un contratto. Si dividono in due categorie fondamentali:
-
Porte Primarie (Driving Ports): Definiscono i casi d'uso dell'applicazione.
Sono le interfacce attraverso cui il mondo esterno guida l'applicazione. Esempio:
un'interfaccia
OrderServiceesposta a una REST API o a una CLI. -
Porte Secondarie (Driven Ports): Definiscono le dipendenze dell'applicazione
verso l'esterno. Sono le interfacce che il dominio usa ma non implementa. Esempio:
un'interfaccia
OrderRepositoryper la persistenza, oppureNotificationSenderper l'invio di notifiche.
Adattatori (Adapters): Le Implementazioni Concrete
Gli adattatori sono implementazioni concrete delle porte. Traducono le chiamate tra il formato del mondo esterno e quello del dominio:
- Adattatori Primari (Driving Adapters): Ricevono input dall'esterno e invocano le porte primarie. Esempi: controller REST, handler GraphQL, consumer di messaggi, interfaccia CLI.
- Adattatori Secondari (Driven Adapters): Implementano le porte secondarie. Esempi: repository SQL, client HTTP verso API esterne, adapter per servizi email, adapter per il filesystem.
Implementazione in TypeScript
Vediamo come implementare l'architettura esagonale in TypeScript, partendo dalla definizione delle porte fino alla composizione degli adattatori.
Definizione delle Entità di Dominio
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;
}
Definizione delle Porte
// Porta Primaria: definisce i casi d'uso
export interface NotificationUseCases {
send(request: NotificationRequest): Promise<Notification>;
getById(id: string): Promise<Notification | null>;
getByRecipient(recipientId: string): Promise<Notification[]>;
retry(id: string): Promise<Notification>;
}
// Porta Secondaria: il dominio dipende da questa interfaccia
export interface NotificationRepository {
save(notification: Notification): Promise<Notification>;
findById(id: string): Promise<Notification | null>;
findByRecipientId(recipientId: string): Promise<Notification[]>;
update(notification: Notification): Promise<Notification>;
}
// Porta Secondaria: astrae l'invio effettivo
export interface NotificationSender {
send(
channel: NotificationChannel,
recipient: string,
subject: string,
body: string
): Promise<boolean>;
}
Servizio di Dominio: Il Cuore della Logica
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> {
// Logica di dominio pura: nessuna dipendenza da infrastruttura
const recipient = await this.recipientResolver.resolve(request.recipientId);
if (!recipient) {
throw new DomainError('Destinatario non trovato');
}
const notification: Notification = {
id: uuidv4(),
recipientId: request.recipientId,
channel: request.channel,
subject: request.subject,
body: request.body,
status: 'pending',
createdAt: new Date()
};
// Salva come pending
const saved = await this.repository.save(notification);
// Tenta l'invio
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('Notifica non trovata');
}
if (notification.status !== 'failed') {
throw new DomainError('Solo le notifiche fallite possono essere ritentate');
}
return this.send({
recipientId: notification.recipientId,
channel: notification.channel,
subject: notification.subject,
body: notification.body
});
}
}
Adattatori Secondari: Implementazioni Concrete
// Adattatore Secondario: implementa la porta del repository
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;
}
}
// Adattatore Secondario: gestisce l'invio multicanale
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('Canale non supportato: ' + channel);
}
return provider.deliver(recipient, subject, body);
}
}
Adattatore Primario: Controller REST
// Adattatore Primario: traduce HTTP in chiamate al dominio
@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('Notifica non trovata');
}
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);
}
}
Composizione con Dependency Injection in Angular
// Injection tokens per le porte secondarie
export const NOTIFICATION_REPOSITORY =
new InjectionToken<NotificationRepository>('NotificationRepository');
export const NOTIFICATION_SENDER =
new InjectionToken<NotificationSender>('NotificationSender');
export const RECIPIENT_RESOLVER =
new InjectionToken<RecipientResolver>('RecipientResolver');
// Configurazione providers in app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
// Adattatori secondari: facilmente sostituibili
{
provide: NOTIFICATION_REPOSITORY,
useClass: FirestoreNotificationRepository
},
{
provide: NOTIFICATION_SENDER,
useClass: MultiChannelNotificationSender
},
{
provide: RECIPIENT_RESOLVER,
useClass: FirestoreRecipientResolver
},
// Servizio di dominio composto con le porte
{
provide: NotificationDomainService,
useFactory: (repo, sender, resolver) =>
new NotificationDomainService(repo, sender, resolver),
deps: [
NOTIFICATION_REPOSITORY,
NOTIFICATION_SENDER,
RECIPIENT_RESOLVER
]
}
]
};
Confronto: Esagonale vs Clean vs Onion
L'architettura esagonale condivide principi fondamentali con la Clean Architecture di Robert C. Martin e la Onion Architecture di Jeffrey Palermo. Tutte e tre puntano a isolare il dominio, ma con sfumature differenti.
Tabella Comparativa
| Aspetto | Esagonale | Clean Architecture | Onion Architecture |
|---|---|---|---|
| Autore | Alistair Cockburn | Robert C. Martin | Jeffrey Palermo |
| Metafora | Esagono con porte | Cerchi concentrici | Strati di cipolla |
| Focus | Interazioni dentro/fuori | Regola delle dipendenze | Strati del dominio |
| Interfacce | Ports (driving/driven) | Use Cases + Gateways | Domain Services |
| Implementazioni | Adapters | Interface Adapters + Frameworks | Infrastructure Layer |
| Testabilità | Eccellente: sostituisci adattatori | Eccellente: mock di gateway | Eccellente: mock degli strati esterni |
| Complessità | Media | Alta (più strati) | Media-alta |
| Ideale per | Sistemi con molte integrazioni | Applicazioni enterprise | Domini complessi con DDD |
La differenza principale è nel punto di vista: l'architettura esagonale ragiona in termini di dentro e fuori (il dominio e la periferia), la Clean Architecture in termini di strati concentrici con regola delle dipendenze, e la Onion Architecture enfatizza la stratificazione del dominio con model, domain services e application services.
Vantaggi nei Test
Il vantaggio più significativo dell'architettura esagonale e la facilità di testing. Poichè il dominio dipende solo da interfacce (porte), possiamo sostituire gli adattatori con implementazioni di test senza alcuna modifica al codice di business.
describe('NotificationDomainService', () => {
let service: NotificationDomainService;
let mockRepository: jest.Mocked<NotificationRepository>;
let mockSender: jest.Mocked<NotificationSender>;
let mockResolver: jest.Mocked<RecipientResolver>;
beforeEach(() => {
// Adattatori mock: implementano le stesse porte
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('deve inviare una notifica con successo', async () => {
const request: NotificationRequest = {
recipientId: 'user-1',
channel: 'email',
subject: 'Test',
body: 'Messaggio di test'
};
const result = await service.send(request);
expect(result.status).toBe('sent');
expect(mockSender.send).toHaveBeenCalledWith(
'email', 'user@example.com', 'Test', 'Messaggio di test'
);
expect(mockRepository.save).toHaveBeenCalled();
expect(mockRepository.update).toHaveBeenCalled();
});
it('deve gestire il fallimento dell\'invio', 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('non deve ritentare notifiche non fallite', 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('Solo le notifiche fallite possono essere ritentate');
});
});
perchè i Test Sono Più Semplici
Notate come non abbiamo bisogno di Firestore, di un server SMTP o di alcun servizio esterno per testare la logica di business. Ogni porta viene sostituita con un mock che implementa la stessa interfaccia. Questo rende i test:
- Veloci: Nessuna connessione di rete o I/O
- Deterministici: Nessuna dipendenza da stato esterno
- Isolati: Ogni test verifica solo la logica di dominio
- Manutenibili: Cambiare database non richiede di modificare i test
Quando Usare l'Architettura Esagonale
Scenari Ideali
- Multiple integrazioni: L'applicazione comunica con molti sistemi esterni (DB, API, message broker, servizi email)
- Sostituibilita richiesta: I fornitori di servizi possono cambiare nel tempo (migrare da AWS a Azure)
- Testabilità critica: La logica di business è complessa e richiede test approfonditi
- Team distribuiti: Diversi team lavorano su adattatori diversi in parallelo
- Longevita del progetto: Il progetto durerà anni e il framework potrebbe cambiare
Quando Evitarla
- CRUD semplici: Applicazioni con poca logica di business non giustificano la complessità aggiuntiva
- Prototipi e MVP: La velocità di consegna è più importante dell'architettura
- Team piccoli e inesperti: La curva di apprendimento potrebbe rallentare lo sviluppo
- Microservizi minimali: Servizi molto piccoli con una sola integrazione
Struttura delle Cartelle Consigliata
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
Conclusione
L'architettura esagonale è un pattern potente che mette al centro il dominio e lo protegge dalle dipendenze infrastrutturali. Attraverso il meccanismo di porte e adattatori, otteniamo un'applicazione altamente testabile, flessibile e manutenibile nel tempo.
I punti chiave da ricordare sono:
- Le porte primarie definiscono cosa l'applicazione sa fare (casi d'uso)
- Le porte secondarie definiscono di cosa l'applicazione ha bisogno (dipendenze)
- Gli adattatori sono intercambiabili e possono essere sostituiti senza toccare il dominio
- La Dependency Injection è il meccanismo che collega tutto
- I test diventano naturalmente semplici e veloci
Punti Chiave da Ricordare
- Dominio al centro: La logica di business non dipende mai dall'infrastruttura
- Porte come contratti: Le interfacce definiscono i confini
- Adattatori sostituibili: Cambia database, framework o provider senza toccare il dominio
- Test isolati: Mock delle porte secondarie per test veloci e deterministici
- DI come collante: L'injection token di Angular rende la composizione esplicita
Domande frequenti
Qual è l'obiettivo principale dell'Architettura Esagonale?
L'obiettivo principale dell'Architettura Esagonale è separare il nucleo applicativo dall'infrastruttura circostante.
Cosa rappresenta l'esagono nell'architettura esagonale?
L'esagono rappresenta il dominio applicativo, ovvero tutta la logica di business pura, priva di dipendenze verso l'esterno.
Qual è la differenza principale tra l'Architettura Esagonale e la Clean Architecture?
La differenza principale è nel punto di vista: l'architettura esagonale ragiona in termini di dentro e fuori (il dominio e la periferia), mentre la Clean Architecture si basa su strati concentrici con regola delle dipendenze.
Qual è il vantaggio più significativo dell'Architettura Esagonale?
Il vantaggio più significativo dell'architettura esagonale è la facilità di testing, poiché il dominio dipende solo da interfacce (porte), che possono essere sostituite con implementazioni di test senza alcuna modifica al codice di business.

Commenti
Caricamento commenti...
Accedi per commentare