Architettura Monolitica Modulare: il ritorno al monolite (ma fatto bene)

Nel panorama dello sviluppo software, le architetture applicative hanno attraversato diverse ere, ciascuna con le proprie promesse e i propri compromessi. Oggi assistiamo a un fenomeno interessante: dopo anni di corsa verso i microservizi, molte organizzazioni stanno riscoprendo il valore di un'architettura più semplice ma strutturata. Benvenuti nell'era del Monolite Modulare.


Un viaggio nella storia delle architetture

L'era del Monolite Tradizionale

Agli albori dello sviluppo enterprise, il monolite era l'unica opzione praticabile. Un'unica applicazione, un unico deployment, un unico database. La semplicità era il suo punto di forza: tutto il codice risiedeva in un solo progetto, il debugging era lineare, e le transazioni ACID garantivano consistenza dei dati senza sforzi particolari.

Ma il monolite tradizionale portava con sé problemi significativi. Man mano che l'applicazione cresceva, il codice diventava un intricato groviglio di dipendenze. La famosa Big Ball of Mud non era un'eccezione, ma la norma. Modificare una funzionalità significava rischiare di romperne altre dieci. Il deploy richiedeva il rilascio dell'intera applicazione, anche per una singola correzione. E scalare significava replicare tutto, anche i componenti che non ne avevano bisogno.

Pro del monolite tradizionale:

Contro del monolite tradizionale:

L'avvento di SOA

A cavallo del millennio, la Service-Oriented Architecture prometteva di risolvere i problemi del monolite attraverso la suddivisione in servizi riutilizzabili. L'idea era elegante: componenti indipendenti che comunicano attraverso protocolli standard, orchestrati da un Enterprise Service Bus (ESB).

SOA introdusse concetti importanti come i contratti di servizio, la riusabilità e l'interoperabilità. Ma nella pratica, molte implementazioni SOA si rivelarono monoliti distribuiti mascherati. L'ESB diventava spesso un single point of failure e un collo di bottiglia. La complessità si era semplicemente spostata, non eliminata.

Pro di SOA:

Contro di SOA:

La rivoluzione dei Microservizi

Intorno al 2014, Netflix, Amazon e altre tech company iniziarono a evangelizzare i microservizi. Servizi piccoli, indipendenti, ciascuno con il proprio database, deployabili autonomamente. Era la promessa della vera agilità: team autonomi, tecnologie eterogenee, scalabilità granulare.

I microservizi hanno funzionato brillantemente per organizzazioni con centinaia di sviluppatori e requisiti di scala estremi. Ma per la maggior parte delle aziende, hanno introdotto una complessità operativa sproporzionata. Gestire decine o centinaia di servizi richiede infrastrutture sofisticate: service discovery, circuit breaker, distributed tracing, orchestrazione di container. La consistenza dei dati, prima garantita dalle transazioni ACID, diventa un problema architetturale da risolvere con pattern come Saga o eventual consistency.

Pro dei microservizi:

Contro dei microservizi:

Il presente: Monolite Modulare

Il Monolite Modulare rappresenta una sintesi pragmatica. Mantiene i vantaggi del monolite (semplicità operativa, transazioni ACID, assenza di latenza di rete) incorporando i principi architetturali che hanno reso i microservizi efficaci: boundaries chiari, basso accoppiamento, alta coesione.

L'idea fondamentale è strutturare l'applicazione in moduli con confini ben definiti, ciascuno responsabile di un bounded context specifico. I moduli comunicano attraverso interfacce esplicite, non attraverso dipendenze dirette sul codice interno degli altri moduli.

Pro del monolite modulare:

Contro del monolite modulare:

Struttura tipica di un progetto

Come si traduce concretamente l'architettura modulare nella struttura di un progetto? Ecco uno schema rappresentativo:

📁 src/
│
├── 📁 Modules/
│   │
│   ├── 📁 Orders/                          # MODULO ORDERS
│   │   ├── 📁 Application/                 # Casi d'uso e logica applicativa
│   │   │   ├── 📁 Commands/
│   │   │   │   ├── CreateOrder/
│   │   │   │   │   ├── CreateOrderCommand.cs
│   │   │   │   │   └── CreateOrderHandler.cs
│   │   │   │   └── CompleteOrder/
│   │   │   │       ├── CompleteOrderCommand.cs
│   │   │   │       └── CompleteOrderHandler.cs
│   │   │   ├── 📁 Queries/
│   │   │   │   └── GetOrderById/
│   │   │   │       ├── GetOrderByIdQuery.cs
│   │   │   │       └── GetOrderByIdHandler.cs
│   │   │   └── 📁 EventHandlers/           # Handler per eventi da ALTRI moduli
│   │   │       └── CustomerUpdatedHandler.cs
│   │   │
│   │   ├── 📁 Domain/                      # Entità e logica di dominio
│   │   │   ├── 📁 Entities/
│   │   │   │   ├── Order.cs
│   │   │   │   └── OrderLine.cs
│   │   │   ├── 📁 ValueObjects/
│   │   │   │   └── Money.cs
│   │   │   ├── 📁 Events/                  # Eventi di dominio PUBBLICATI da questo modulo
│   │   │   │   ├── OrderCreatedEvent.cs
│   │   │   │   └── OrderCompletedEvent.cs
│   │   │   └── 📁 Exceptions/
│   │   │       └── OrderNotFoundException.cs
│   │   │
│   │   ├── 📁 Infrastructure/              # Persistenza e servizi esterni
│   │   │   ├── 📁 Persistence/
│   │   │   │   ├── OrdersDbContext.cs
│   │   │   │   ├── 📁 Configurations/      # Mapping EF Core
│   │   │   │   │   └── OrderConfiguration.cs
│   │   │   │   ├── 📁 Repositories/
│   │   │   │   │   └── OrderRepository.cs
│   │   │   │   └── 📁 Migrations/
│   │   │   │       └── 20240115_InitialCreate.cs
│   │   │   └── 📁 Outbox/                  # Transactional Outbox
│   │   │       └── OrdersOutboxProcessor.cs
│   │   │
│   │   ├── 📁 Contracts/                   # INTERFACCIA PUBBLICA DEL MODULO
│   │   │   ├── 📁 IntegrationEvents/       # Eventi per altri moduli
│   │   │   │   ├── OrderCompletedIntegrationEvent.cs
│   │   │   │   └── OrderCancelledIntegrationEvent.cs
│   │   │   └── 📁 Services/                # API pubblica (interfacce)
│   │   │       └── IOrderService.cs
│   │   │
│   │   └── OrdersModule.cs                 # Registrazione DI del modulo
│   │
│   ├── 📁 Customers/                       # MODULO CUSTOMERS
│   │   ├── 📁 Application/
│   │   ├── 📁 Domain/
│   │   ├── 📁 Infrastructure/
│   │   ├── 📁 Contracts/
│   │   │   ├── 📁 IntegrationEvents/
│   │   │   │   └── CustomerUpdatedIntegrationEvent.cs
│   │   │   └── 📁 Services/
│   │   │       └── ICustomerService.cs     # Usato da Orders per ottenere dati cliente
│   │   └── CustomersModule.cs
│   │
│   ├── 📁 Inventory/                       # MODULO INVENTORY
│   │   ├── 📁 Application/
│   │   │   └── 📁 EventHandlers/
│   │   │       └── OrderCompletedHandler.cs  # Reagisce a eventi di Orders
│   │   ├── 📁 Domain/
│   │   ├── 📁 Infrastructure/
│   │   │   └── 📁 Idempotency/             # Gestione idempotenza
│   │   │       └── ProcessedEventsRepository.cs
│   │   ├── 📁 Contracts/
│   │   └── InventoryModule.cs
│   │
│   └── 📁 Notifications/                   # MODULO NOTIFICATIONS
│       ├── 📁 Application/
│       ├── 📁 Domain/
│       ├── 📁 Infrastructure/
│       ├── 📁 Contracts/
│       └── NotificationsModule.cs
│
├── 📁 Shared/                              # CODICE CONDIVISO (KERNEL)
│   ├── 📁 Domain/
│   │   ├── Entity.cs                       # Classe base per entità
│   │   ├── AggregateRoot.cs
│   │   ├── IDomainEvent.cs
│   │   └── IIntegrationEvent.cs
│   ├── 📁 Infrastructure/
│   │   ├── 📁 EventBus/
│   │   │   ├── IEventBus.cs
│   │   │   └── InMemoryEventBus.cs
│   │   ├── 📁 Outbox/
│   │   │   ├── OutboxMessage.cs
│   │   │   └── IOutboxProcessor.cs
│   │   └── 📁 Idempotency/
│   │       ├── ProcessedEvent.cs
│   │       └── IIdempotencyService.cs
│   └── 📁 Application/
│       └── 📁 Behaviors/                   # Pipeline MediatR
│           ├── LoggingBehavior.cs
│           └── TransactionBehavior.cs
│
├── 📁 API/                                 # ENTRY POINT (Host)
│   ├── 📁 Controllers/
│   │   ├── OrdersController.cs             # Delega al modulo Orders
│   │   ├── CustomersController.cs
│   │   └── InventoryController.cs
│   ├── Program.cs                          # Composizione dei moduli
│   └── appsettings.json
│
└── 📁 Database/
    └── 📁 Schemas/                         # Script DDL per schema separation
        ├── orders_schema.sql
        ├── customers_schema.sql
        ├── inventory_schema.sql
        └── notifications_schema.sql

Punti chiave della struttura:

  1. Ogni modulo è autocontenuto: contiene la propria logica applicativa, dominio, infrastruttura e contratti. Non esistono dipendenze dirette tra le cartelle interne di moduli diversi.

  2. I Contracts definiscono l'interfaccia pubblica: solo ciò che è nella cartella Contracts può essere referenziato da altri moduli. Questo include gli Integration Events e le interfacce dei servizi pubblici.

  3. Lo Shared Kernel è minimale: contiene solo astrazioni di base (Entity, AggregateRoot, interfacce per eventi) e infrastruttura comune (EventBus, Outbox). Non contiene logica di business.

  4. L'API Host compone i moduli: il progetto API non contiene logica di business, si limita a registrare i moduli e esporre gli endpoint HTTP.

  5. Database con schema separation: ogni modulo ha il proprio schema SQL, garantendo isolamento logico anche su un database fisico condiviso.


Il design del database nel Monolite Modulare

Una delle sfide più sottovalutate nel design di un monolite modulare riguarda il database. Come conciliare l'autonomia dei moduli con l'esistenza di un database condiviso?

Il principio fondamentale: ownership esclusiva

Ogni modulo deve avere ownership esclusiva sulle proprie tabelle. Nessun altro modulo può accedere direttamente a queste tabelle, né in lettura né in scrittura. Questo principio, apparentemente rigido, è ciò che permette ai moduli di evolvere indipendentemente.

Nella pratica, questo si traduce in:

Normalizzazione vs Denormalizzazione: il dilemma dei riferimenti cross-module

Quando un modulo ha bisogno di dati gestiti da un altro modulo, si presenta un dilemma architetturale. Prendiamo un esempio concreto: il modulo Orders deve visualizzare informazioni sul cliente, gestito dal modulo Customers.

Approccio 1: Soft Link (riferimento tramite ID)

Il soft link mantiene solo l'identificativo dell'entità esterna.

-- Tabella nel modulo Orders
CREATE TABLE orders (
    id UUID PRIMARY KEY,
    customer_id UUID NOT NULL,  -- Solo l'ID, nessuna FK verso customers
    order_date TIMESTAMP,
    total_amount DECIMAL(10,2)
);

Con questo approccio, ogni volta che il modulo Orders necessita di informazioni sul cliente (nome, email, indirizzo di spedizione), deve invocare l'API del modulo Customers.

Vantaggi del Soft Link:

Svantaggi del Soft Link:

Approccio 2: Snapshot (copia locale dei dati)

Lo snapshot mantiene una copia locale dei dati necessari al momento della creazione dell'ordine.

-- Tabella nel modulo Orders
CREATE TABLE orders (
    id UUID PRIMARY KEY,
    customer_id UUID NOT NULL,
    -- Snapshot dei dati del cliente al momento dell'ordine
    customer_name VARCHAR(255) NOT NULL,
    customer_email VARCHAR(255) NOT NULL,
    shipping_address_snapshot JSONB NOT NULL,
    order_date TIMESTAMP,
    total_amount DECIMAL(10,2)
);

Vantaggi dello Snapshot:

Svantaggi dello Snapshot:

Quale approccio scegliere?

La scelta dipende dal contesto di business:

Usa Soft Link quando:

Usa Snapshot quando:

Nel caso degli ordini, lo snapshot è spesso la scelta corretta: vogliamo sapere a quale indirizzo è stato spedito l'ordine, non l'indirizzo attuale del cliente.


Comunicazione asincrona fra moduli

In un monolite modulare ben progettato, la comunicazione tra moduli avviene principalmente in modo asincrono attraverso eventi. Questo pattern, mutuato dal mondo dei microservizi, porta benefici significativi anche all'interno di un singolo processo.

Perché asincrono?

La comunicazione sincrona (chiamate dirette ai metodi pubblici di un altro modulo) crea accoppiamento temporale: il modulo chiamante deve attendere la risposta, e se il modulo chiamato è lento o fallisce, il problema si propaga.

La comunicazione asincrona tramite eventi inverte questa dinamica:

Implementazione pratica

In un monolite modulare, gli eventi possono viaggiare attraverso:

Un esempio di flusso:

1. Il modulo Orders completa un ordine
2. Orders pubblica l'evento "OrderCompleted" con i dati rilevanti
3. Il modulo Inventory riceve l'evento e decrementa le scorte
4. Il modulo Notifications riceve l'evento e invia l'email di conferma
5. Il modulo Analytics riceve l'evento e aggiorna le statistiche

Ogni modulo reagisce indipendentemente, senza che Orders debba conoscere l'esistenza di Inventory, Notifications o Analytics.


Transactional Outbox: garantire la consegna degli eventi

Uno dei problemi più insidiosi nella comunicazione asincrona è garantire che gli eventi vengano effettivamente pubblicati. Consideriamo questo scenario:

BEGIN TRANSACTION;
    UPDATE orders SET status = 'completed' WHERE id = ?;
    -- Pubblica evento su message broker
COMMIT;

Cosa succede se il commit del database ha successo, ma la pubblicazione dell'evento fallisce (network timeout, broker non disponibile)? L'ordine risulta completato nel database, ma nessun altro modulo ne viene informato. Abbiamo una inconsistenza.

Il problema inverso è altrettanto grave: se pubblichiamo l'evento prima del commit e poi il commit fallisce, abbiamo notificato un evento per un'operazione mai avvenuta.

La soluzione: Transactional Outbox Pattern

Il pattern Transactional Outbox risolve elegantemente questo problema usando il database stesso come buffer per gli eventi.

BEGIN TRANSACTION;
    -- 1. Esegui l'operazione di business
    UPDATE orders SET status = 'completed' WHERE id = ?;
    
    -- 2. Scrivi l'evento nella tabella outbox (stessa transazione!)
    INSERT INTO outbox (id, event_type, payload, created_at, processed)
    VALUES (?, 'OrderCompleted', '{"orderId": "...", ...}', NOW(), false);
COMMIT;

La tabella outbox potrebbe avere questa struttura:

CREATE TABLE outbox (
    id UUID PRIMARY KEY,
    event_type VARCHAR(255) NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMP NOT NULL,
    processed BOOLEAN DEFAULT false,
    processed_at TIMESTAMP NULL,
    retry_count INT DEFAULT 0
);

Un processo separato (Outbox Processor) legge periodicamente la tabella outbox e pubblica gli eventi non ancora processati:

1. SELECT * FROM outbox WHERE processed = false ORDER BY created_at LIMIT 100;
2. Per ogni record:
   a. Pubblica l'evento sul message broker
   b. UPDATE outbox SET processed = true, processed_at = NOW() WHERE id = ?;

Vantaggi del Transactional Outbox

Considerazioni implementative

Il processo di pubblicazione può essere implementato come:


Idempotenza del consumer

Se il Transactional Outbox garantisce at-least-once delivery, dobbiamo prepararci alla possibilità che lo stesso evento venga consegnato più volte. Questo può accadere per vari motivi:

Un consumer idempotente è un consumer che produce lo stesso risultato indipendentemente dal numero di volte che riceve lo stesso messaggio.

Il problema della doppia elaborazione

Immaginiamo che il modulo Inventory riceva due volte l'evento OrderCompleted per lo stesso ordine. Senza protezioni:

Evento 1: OrderCompleted(orderId: 123, items: [{sku: "ABC", qty: 2}])
  → Inventory decrementa ABC di 2 unità (stock: 100 → 98)

Evento 2 (duplicato): OrderCompleted(orderId: 123, items: [{sku: "ABC", qty: 2}])
  → Inventory decrementa ABC di 2 unità (stock: 98 → 96)  // ERRORE!

Il risultato è uno stock errato. In altri contesti (pagamenti, spedizioni), le conseguenze potrebbero essere molto più gravi.

Idempotency Key: la soluzione

La strategia più robusta per garantire idempotenza è utilizzare una Idempotency Key: un identificatore univoco associato a ogni evento che permette di riconoscere i duplicati.

CREATE TABLE processed_events (
    idempotency_key VARCHAR(255) PRIMARY KEY,
    processed_at TIMESTAMP NOT NULL,
    result JSONB NULL  -- Opzionale: memorizza il risultato per rispondere ai duplicati
);

Il consumer, prima di elaborare un evento, verifica se è già stato processato:

def handle_order_completed(event):
    idempotency_key = f"OrderCompleted:{event.order_id}"
    
    # Verifica se già processato
    if exists_in_processed_events(idempotency_key):
        logger.info(f"Evento già processato, ignoro: {idempotency_key}")
        return
    
    # Elabora l'evento
    with transaction():
        decrement_inventory(event.items)
        
        # Registra l'elaborazione (stessa transazione!)
        insert_processed_event(idempotency_key)

Best practice per l'Idempotency Key

Composizione della chiave: l'idempotency key dovrebbe identificare univocamente l'operazione, non solo il messaggio. Una buona chiave potrebbe essere:

Storage della chiave: la registrazione dell'idempotency key deve avvenire nella stessa transazione dell'elaborazione. Altrimenti, se il sistema crasha tra l'elaborazione e la registrazione, il duplicato verrà processato nuovamente.

Pulizia periodica: la tabella processed_events crescerà nel tempo. Implementa una politica di retention (es. elimina record più vecchi di 30 giorni) basata sul tuo SLA di ridelivery.

Risposta ai duplicati: in alcuni casi, potresti voler restituire lo stesso risultato di un'operazione già completata. Memorizzare il risultato insieme all'idempotency key permette di farlo.


Mettere tutto insieme: un'architettura coerente

Ricapitoliamo come questi pattern si combinano in un monolite modulare ben progettato:

┌─────────────────────────────────────────────────────────────────┐
│                     MONOLITE MODULARE                           │
│                                                                 │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐      │
│  │   Orders     │    │  Inventory   │    │Notifications │      │
│  │   Module     │    │   Module     │    │   Module     │      │
│  │              │    │              │    │              │      │
│  │ ┌──────────┐ │    │ ┌──────────┐ │    │ ┌──────────┐ │      │
│  │ │  Orders  │ │    │ │Inventory │ │    │ │Templates │ │      │
│  │ │  Table   │ │    │ │  Table   │ │    │ │  Table   │ │      │
│  │ └──────────┘ │    │ └──────────┘ │    │ └──────────┘ │      │
│  │ ┌──────────┐ │    │ ┌──────────┐ │    │ ┌──────────┐ │      │
│  │ │  Outbox  │ │    │ │Processed │ │    │ │Processed │ │      │
│  │ │  Table   │ │    │ │ Events   │ │    │ │ Events   │ │      │
│  │ └──────────┘ │    │ └──────────┘ │    │ └──────────┘ │      │
│  └──────┬───────┘    └──────▲───────┘    └──────▲───────┘      │
│         │                   │                   │               │
│         │    ┌──────────────┴───────────────────┘               │
│         │    │                                                  │
│         ▼    │                                                  │
│  ┌───────────┴────┐                                             │
│  │  Event Bus /   │                                             │
│  │ Message Broker │                                             │
│  └────────────────┘                                             │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Il flusso tipico:

  1. Orders Module riceve una richiesta di completamento ordine
  2. In un'unica transazione:
    • Aggiorna lo stato dell'ordine
    • Scrive l'evento OrderCompleted nella sua tabella Outbox
  3. L'Outbox Processor legge l'evento e lo pubblica sull'Event Bus
  4. Inventory Module riceve l'evento:
    • Controlla l'Idempotency Key nella tabella processed_events
    • Se nuovo, elabora l'evento e registra la chiave (stessa transazione)
    • Se duplicato, ignora
  5. Notifications Module segue lo stesso pattern

Conclusioni

L'architettura monolitica modulare non è un passo indietro rispetto ai microservizi, ma un riconoscimento pragmatico che la complessità architetturale ha un costo. Per molte organizzazioni, questo costo supera i benefici.

I pattern che abbiamo esplorato (Transactional Outbox, Idempotency Key, comunicazione asincrona, strategie di riferimento cross-module) non sono esclusivi dei microservizi. Applicati a un monolite modulare, permettono di ottenere molti dei benefici architetturali dei sistemi distribuiti mantenendo la semplicità operativa di un'applicazione monolitica.

La vera maturità architetturale non sta nel seguire le mode, ma nel comprendere i trade-off e scegliere la soluzione più adatta al proprio contesto. E per molti contesti, quel contesto è un monolite ben strutturato.


"Make it work, make it right, make it fast" — Kent Beck

Nel caso dell'architettura software, potremmo aggiungere: "...and keep it simple until you have a reason not to."