Salta al contenuto principale

4 - Architettura Esagonale (Ports and Adapters)

Architettura Esagonale: Ports and Adapters L' Architettura Esagonale , nota anche come Ports and Adapters , è stata formulata da Alistair Cockburn nei...

Cosa ne pensi di questo articolo?

4 - Architettura Esagonale (Ports and Adapters)
Architettura SoftwareFoto:SiddePexels

In Breve

Architettura Esagonale: Ports and Adapters L' Architettura Esagonale , nota anche come Ports and Adapters , è stata formulata da Alistair Cockburn nei...

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 OrderService esposta 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 OrderRepository per la persistenza, oppure NotificationSender per 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

domain/entities/notification.ts

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

domain/ports/primary/notification-use-cases.port.ts

// 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>;
}
    
domain/ports/secondary/notification-repository.port.ts

// 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>;
}
    
domain/ports/secondary/notification-sender.port.ts

// 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

domain/services/notification.service.ts

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

adapters/secondary/firestore-notification.repository.ts

// 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;
  }
}
    
adapters/secondary/multi-channel-notification.sender.ts

// 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

adapters/primary/notification.controller.ts

// 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

Configurazione DI 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.

Test del servizio di dominio con adattatori mock

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

Struttura progetto con architettura esagonale

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.

Fonti e approfondimenti

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti