← Blog
tutorials

Architettura Esagonale | Guida Pratica per Applicazioni Moderne

Scopri cos'e' l'architettura esagonale, perche' conta e come implementarla. Una guida completa con esempi reali per costruire software manutenibile e testabile.

Ryveris Team ·
Architettura Esagonale | Guida Pratica per Applicazioni Moderne

L’architettura esagonale e’ un pattern di design software che isola la tua logica di business principale dai sistemi esterni come database, API e interfacce utente. L’idea e’ semplice: il tuo codice di dominio non dovrebbe mai dipendere dall’infrastruttura. L’infrastruttura dipende dal dominio.

Il pattern e’ stato introdotto da Alistair Cockburn nel 2005. Potresti anche sentirlo chiamare “Ports and Adapters”. Entrambi i nomi descrivono lo stesso concetto.

Perche’ Esiste

La maggior parte delle applicazioni inizia allo stesso modo. La logica di business si intreccia con query al database, handler HTTP e chiamate a servizi di terze parti. All’inizio funziona. Poi il codebase cresce e succedono tre cose:

  1. Il testing diventa doloroso. Non puoi testare le regole di business senza avviare un database o fare mock di meta’ del framework.
  2. Cambiare l’infrastruttura e’ rischioso. Sostituire un provider di pagamento o migrare da REST a GraphQL significa riscrivere la logica di business.
  3. Il codice e’ difficile da capire. Nessuno riesce a dire dove finisce il dominio e dove inizia l’infrastruttura.

L’architettura esagonale risolve tutti e tre i problemi imponendo una singola regola: le dipendenze puntano sempre verso l’interno.

I Concetti Fondamentali

Pensa alla tua applicazione come tre livelli concentrici.

Il Dominio (Centro)

Questa e’ la tua logica di business. Funzioni pure, entita’, value objects e servizi di dominio. Ha zero dipendenze da framework, database o librerie esterne. Parla il proprio linguaggio.

Per un sistema e-commerce, questo livello contiene concetti come Order, Product, PricingRule e InventoryPolicy. Questi oggetti non sanno nulla di SQL, HTTP o JSON.

Porte (Livello Intermedio)

Le porte sono interfacce che definiscono come il mondo esterno interagisce con il dominio. Sono contratti, non implementazioni.

Ci sono due tipi:

  • Porte in entrata (driving). Definiscono cosa l’applicazione puo’ fare. Pensa a loro come casi d’uso. “Effettuare un ordine”. “Cancellare un abbonamento”. “Generare una fattura”.
  • Porte in uscita (driven). Definiscono di cosa l’applicazione ha bisogno dal mondo esterno. “Salvare un ordine”. “Inviare una notifica”. “Recuperare il tasso di cambio”.

Le porte fanno parte del livello di dominio. Sono scritte nel linguaggio del dominio, non in quello dell’infrastruttura. Scrivi OrderRepository, non PostgresOrderDAO.

Adattatori (Livello Esterno)

Gli adattatori sono le implementazioni concrete che collegano il mondo reale alle tue porte.

  • Adattatori in entrata traducono le richieste esterne in chiamate al dominio. Un controller REST, un resolver GraphQL, un comando CLI, un consumer di code di messaggi. Tutti questi sono adattatori in entrata.
  • Adattatori in uscita implementano le interfacce delle porte in uscita. Un repository PostgreSQL, un sender email SMTP, un gateway di pagamento Stripe. Tutti questi sono adattatori in uscita.

L’intuizione chiave: gli adattatori dipendono dalle porte. Le porte non dipendono mai dagli adattatori. Questo e’ cio’ che fa funzionare l’intero pattern.

Un Esempio Concreto

Supponiamo che tu stia costruendo una funzionalita’ di inserimento ordini. Ecco come si suddividono i livelli.

Dominio (entita’ e regole):

// domain/Order.ts
class Order {
  readonly id: string;
  readonly items: OrderItem[];
  readonly status: OrderStatus;

  calculateTotal(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.price.multiply(item.quantity)),
      Money.zero()
    );
  }

  confirm(): Order {
    if (this.items.length === 0) {
      throw new EmptyOrderError();
    }
    return new Order({ ...this, status: "confirmed" });
  }
}

Porta in entrata (interfaccia del caso d’uso):

// ports/inbound/PlaceOrder.ts
interface PlaceOrder {
  execute(command: PlaceOrderCommand): Promise<OrderConfirmation>;
}

Porte in uscita (di cosa ha bisogno il caso d’uso):

// ports/outbound/OrderRepository.ts
interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
}

// ports/outbound/PaymentGateway.ts
interface PaymentGateway {
  charge(amount: Money, method: PaymentMethod): Promise<PaymentResult>;
}

// ports/outbound/NotificationSender.ts
interface NotificationSender {
  sendOrderConfirmation(order: Order): Promise<void>;
}

Servizio applicativo (implementa la porta in entrata):

// application/PlaceOrderService.ts
class PlaceOrderService implements PlaceOrder {
  constructor(
    private orders: OrderRepository,
    private payments: PaymentGateway,
    private notifications: NotificationSender
  ) {}

  async execute(command: PlaceOrderCommand): Promise<OrderConfirmation> {
    const order = Order.create(command.items);
    const confirmed = order.confirm();

    const payment = await this.payments.charge(
      confirmed.calculateTotal(),
      command.paymentMethod
    );

    if (!payment.successful) {
      throw new PaymentFailedError(payment.reason);
    }

    await this.orders.save(confirmed);
    await this.notifications.sendOrderConfirmation(confirmed);

    return OrderConfirmation.from(confirmed, payment);
  }
}

Adattatori (infrastruttura):

// adapters/inbound/OrderController.ts
class OrderController {
  constructor(private placeOrder: PlaceOrder) {}

  async handlePost(req: Request): Promise<Response> {
    const command = PlaceOrderCommand.fromRequest(req.body);
    const confirmation = await this.placeOrder.execute(command);
    return Response.json(confirmation.toResponse(), { status: 201 });
  }
}

// adapters/outbound/PostgresOrderRepository.ts
class PostgresOrderRepository implements OrderRepository {
  async save(order: Order): Promise<void> {
    await this.db.query(
      "INSERT INTO orders (id, items, status) VALUES ($1, $2, $3)",
      [order.id, JSON.stringify(order.items), order.status]
    );
  }

  async findById(id: string): Promise<Order | null> {
    const row = await this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
    return row ? Order.fromPersistence(row) : null;
  }
}

Nota come PlaceOrderService non menziona mai PostgreSQL, HTTP o alcun framework. Lavora puramente con concetti di dominio e interfacce delle porte.

La Regola delle Dipendenze

Questa e’ la regola piu’ importante. Se non ottieni nient’altro da questo articolo, ottieni questo:

Le dipendenze del codice sorgente devono sempre puntare verso l’interno, verso il dominio.

  • Gli adattatori dipendono dalle porte. Mai il contrario.
  • Le porte dipendono dalle entita’ di dominio. Mai dagli adattatori.
  • Il dominio non dipende da nulla di esterno.

Questo viene applicato attraverso la dependency injection. Il tuo composition root (il punto di ingresso dell’applicazione) collega tutto:

// main.ts (composition root)
const orderRepo = new PostgresOrderRepository(db);
const payments = new StripePaymentGateway(stripeClient);
const notifications = new EmailNotificationSender(mailer);

const placeOrder = new PlaceOrderService(orderRepo, payments, notifications);
const controller = new OrderController(placeOrder);

Il livello di dominio non importa mai dal livello adattatori. Se vedi un import da adapters/ dentro domain/ o ports/, qualcosa non va.

Struttura del Progetto

Una struttura di cartelle pulita rende i confini visibili:

src/
  domain/
    Order.ts
    OrderItem.ts
    Money.ts
    errors/
      EmptyOrderError.ts
      PaymentFailedError.ts
  ports/
    inbound/
      PlaceOrder.ts
      CancelOrder.ts
    outbound/
      OrderRepository.ts
      PaymentGateway.ts
      NotificationSender.ts
  application/
    PlaceOrderService.ts
    CancelOrderService.ts
  adapters/
    inbound/
      http/
        OrderController.ts
      cli/
        PlaceOrderCommand.ts
    outbound/
      persistence/
        PostgresOrderRepository.ts
      payment/
        StripePaymentGateway.ts
      notification/
        EmailNotificationSender.ts
  main.ts

Ogni sviluppatore nel team puo’ guardare questo albero e capire dove mettere il nuovo codice.

Benefici per il Testing

Questo e’ dove l’architettura esagonale ripaga davvero.

Unit testing del dominio:

test("order calculates total correctly", () => {
  const order = Order.create([
    { product: "Widget", price: Money.eur(10), quantity: 3 },
    { product: "Gadget", price: Money.eur(25), quantity: 1 },
  ]);

  expect(order.calculateTotal()).toEqual(Money.eur(55));
});

Nessun database. Nessun server HTTP. Nessun framework di mocking. Logica pura, test puri.

Testing dei casi d’uso con adattatori finti:

test("place order charges payment and saves", async () => {
  const orders = new InMemoryOrderRepository();
  const payments = new FakePaymentGateway({ alwaysSucceeds: true });
  const notifications = new FakeNotificationSender();

  const service = new PlaceOrderService(orders, payments, notifications);

  const result = await service.execute({
    items: [{ product: "Widget", price: 10, quantity: 2 }],
    paymentMethod: { type: "card", token: "tok_test" },
  });

  expect(result.status).toBe("confirmed");
  expect(orders.savedOrders).toHaveLength(1);
  expect(payments.charges).toHaveLength(1);
  expect(notifications.sent).toHaveLength(1);
});

Testi l’intero caso d’uso senza toccare infrastruttura reale. Gli adattatori finti implementano le stesse interfacce delle porte, quindi sono intercambiabili con quelli reali.

Test di integrazione solo per gli adattatori:

test("PostgresOrderRepository saves and retrieves", async () => {
  const repo = new PostgresOrderRepository(testDb);
  const order = Order.create([
    { product: "Widget", price: Money.eur(10), quantity: 1 },
  ]);

  await repo.save(order.confirm());
  const found = await repo.findById(order.id);

  expect(found).toEqual(order.confirm());
});

Ogni adattatore ha il suo test di integrazione. Se l’adattatore supera il suo test, e il caso d’uso supera con adattatori finti, l’intero sistema funziona.

Quando Usare l’Architettura Esagonale

L’architettura esagonale aggiunge struttura e indirezione. Questo ha un costo. Ecco quando ne vale la pena:

  • Applicazioni longeve. Se il progetto sara’ mantenuto per anni, l’investimento si ripaga rapidamente.
  • Regole di business complesse. Se il tuo dominio ha logica non banale che deve essere testata approfonditamente.
  • Punti di ingresso multipli. Se la stessa logica viene chiamata da una web API, un CLI, una coda di messaggi e un cron job.
  • L’infrastruttura potrebbe cambiare. Se non sei sicuro di restare con il tuo database, cloud provider o servizi di terze parti attuali.
  • Sviluppatori multipli. Confini chiari riducono i conflitti di merge e rendono le code review piu’ veloci.

Quando Non Usarla

Non tutti i progetti hanno bisogno di questo livello di struttura:

  • Semplici applicazioni CRUD. Se l’app e’ principalmente lettura e scrittura di dati con logica di business minima, l’indirezione aggiunge costo senza beneficio.
  • Prototipi e MVP. La velocita’ conta piu’ dell’architettura quando stai validando un’idea. Ristruttura dopo.
  • Piccoli script e strumenti. Uno strumento CLI di 200 righe non ha bisogno di ports and adapters.

La giusta quantita’ di architettura dipende dalla complessita’ del problema. Inizia semplice. Introduci i confini esagonali quando il dolore di non averli diventa reale.

Errori Comuni

Far trapelare l’infrastruttura nel dominio. Se la tua entita’ Order ha un decoratore @Column o un metodo toJSON(), l’infrastruttura e’ trapelata. Mantieni i tuoi oggetti di dominio puliti.

Creare porte che rispecchiano l’infrastruttura. Una porta chiamata SqlQuery vanifica lo scopo. Le porte dovrebbero descrivere cio’ di cui il dominio ha bisogno nel linguaggio del dominio, non come funziona l’infrastruttura.

Sovra-astrarre. Non tutto ha bisogno di una porta. Se hai una funzione utility che formatta le date, usala direttamente. Riserva le porte per i veri confini dell’infrastruttura.

Saltare il composition root. Senza un posto chiaro dove le dipendenze vengono collegate, il pattern si sgretola. Ogni dipendenza dovrebbe essere iniettata, non importata direttamente.

Esagonale vs. Altri Pattern

Potresti chiederti come l’architettura esagonale si relaziona ad altri pattern ben noti:

  • Clean Architecture (Robert C. Martin): Molto simile. Clean Architecture aggiunge piu’ livelli (entita’, casi d’uso, adattatori di interfaccia, framework) ma l’idea fondamentale e’ la stessa: le dipendenze puntano verso l’interno.
  • Onion Architecture (Jeffrey Palermo): Anche molto simile. Onion Architecture usa anelli concentrici con il dominio al centro. La terminologia differisce, ma la regola delle dipendenze e’ identica.
  • Architettura a Livelli (N-tier): L’approccio tradizionale dove i livelli si impilano verticalmente (presentazione, business, dati). La differenza cruciale: nell’architettura a livelli, il livello business tipicamente dipende dal livello dati. Nell’architettura esagonale, quella dipendenza e’ invertita.

Tutti e tre i pattern moderni (esagonale, clean, onion) condividono lo stesso principio fondamentale. Il dominio non dipende dall’infrastruttura. Differiscono principalmente nelle convenzioni di denominazione e nel numero di livelli che prescrivono.

Come Iniziare

Se vuoi applicare l’architettura esagonale a un progetto esistente, non provare a ristrutturare tutto in una volta. Inizia con un bounded context o una funzionalita’:

  1. Identifica la logica di business principale in quella funzionalita’. Estraila in funzioni pure o classi senza dipendenze dal framework.
  2. Definisci le porte per le dipendenze esterne che quella funzionalita’ usa. Crea interfacce nel linguaggio del dominio.
  3. Sposta il codice di infrastruttura esistente in classi adattatore che implementano quelle interfacce.
  4. Collega tutto in un composition root usando la dependency injection.
  5. Scrivi i test per il dominio e i casi d’uso usando adattatori finti.

Ripeti per la prossima funzionalita’. Nel tempo, i confini esagonali si diffonderanno naturalmente nel codebase.

Considerazioni Finali

L’architettura esagonale non riguarda il seguire un template rigido. Riguarda un’idea semplice: proteggere la tua logica di business dal caos del mondo esterno. I database cambiano. I framework passano di moda. Le API vengono deprecate. Le tue regole di business dovrebbero sopravvivere a tutto cio’.

Il pattern funziona perche’ si allinea con il modo in cui il software evolve realmente. I requisiti cambiano costantemente. L’infrastruttura cambia periodicamente. Ma le regole fondamentali della tua azienda tendono ad essere la parte piu’ stabile del sistema. Costruisci attorno a quella stabilita’.

Se stai costruendo un prodotto che deve durare, l’architettura esagonale e’ uno dei migliori investimenti che puoi fare nel tuo codebase.


Hai bisogno di aiuto per progettare o ristrutturare l’architettura della tua applicazione? Contattaci. Aiutiamo i team a costruire software che resta manutenibile man mano che cresce.

architecturehexagonalports and adaptersclean codesoftware design

Costruiamo il tuo prossimo progetto.

Prenota una call gratuita di 30 minuti. Discuteremo i tuoi obiettivi, le tempistiche e l'approccio migliore. Senza impegno.

Prenota una call discovery hello@ryveris.com