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:
- Semplicità di sviluppo iniziale e setup dell'ambiente
- Transazioni ACID native e consistenza immediata
- Debugging e tracing diretti
- Nessun overhead di rete per le chiamate interne
- Deploy semplice (un solo artefatto)
Contro del monolite tradizionale:
- Accoppiamento stretto tra componenti
- Scalabilità solo verticale o replica completa
- Deploy rischioso e tempi di rilascio lunghi
- Difficoltà nel far lavorare team multipli in parallelo
- Tecnologie vincolate a scelte fatte anni prima
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:
- Riusabilità dei servizi tra applicazioni diverse
- Standard di comunicazione ben definiti (SOAP, WS-*)
- Governance centralizzata
- Integrazione tra sistemi eterogenei
Contro di SOA:
- ESB come single point of failure
- Overhead significativo dei protocolli SOAP/XML
- Complessità di configurazione e manutenzione
- Spesso risultava in "distributed monolith"
- Costi di licenza elevati per gli stack enterprise
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:
- Indipendenza di deployment per ogni servizio
- Scalabilità granulare e ottimizzata
- Team autonomi con ownership chiara
- Libertà tecnologica per ogni servizio
- Fault isolation naturale
Contro dei microservizi:
- Complessità operativa elevata (orchestrazione, monitoring, networking)
- Latenza di rete per ogni chiamata tra servizi
- Consistenza dei dati difficile da garantire
- Debugging distribuito complesso
- Overhead di infrastruttura significativo
- Richiede maturità DevOps elevata
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:
- Semplicità operativa del monolite
- Transazioni ACID quando necessario
- Boundaries architetturali chiari e enforced
- Preparazione naturale per un'eventuale migrazione a microservizi
- Costi di infrastruttura contenuti
- Onboarding degli sviluppatori più semplice
Contro del monolite modulare:
- Richiede disciplina per mantenere i confini tra moduli
- Scalabilità ancora limitata rispetto ai microservizi
- Deploy dell'intera applicazione per ogni modifica
- Stack tecnologico condiviso tra tutti i moduli
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:
Ogni modulo è autocontenuto: contiene la propria logica applicativa, dominio, infrastruttura e contratti. Non esistono dipendenze dirette tra le cartelle interne di moduli diversi.
I Contracts definiscono l'interfaccia pubblica: solo ciò che è nella cartella
Contractspuò essere referenziato da altri moduli. Questo include gli Integration Events e le interfacce dei servizi pubblici.Lo Shared Kernel è minimale: contiene solo astrazioni di base (Entity, AggregateRoot, interfacce per eventi) e infrastruttura comune (EventBus, Outbox). Non contiene logica di business.
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.
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:
- Separazione degli schemi (o prefissi nelle tabelle) per modulo
- Accesso ai dati di altri moduli solo attraverso le API pubbliche del modulo proprietario
- Nessuna foreign key tra tabelle di moduli diversi
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:
- Dati sempre aggiornati e consistenti
- Nessuna duplicazione di informazioni
- Il modulo Customers è l'unica source of truth
- Modifiche ai dati del cliente immediatamente visibili
Svantaggi del Soft Link:
- Dipendenza runtime dal modulo Customers
- Latenza aggiuntiva per ogni query che richiede dati del cliente
- Se il modulo Customers è lento o non disponibile, anche Orders ne risente
- Query complesse che richiedono join logici diventano inefficienti
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:
- Autonomia completa del modulo Orders
- Nessuna dipendenza runtime
- Query performanti senza chiamate esterne
- Storicizzazione naturale (l'indirizzo del cliente al momento dell'ordine è preservato)
- Resilienza: Orders funziona anche se Customers è offline
Svantaggi dello Snapshot:
- Duplicazione dei dati
- Dati potenzialmente obsoleti (se il cliente cambia email)
- Maggiore occupazione di storage
- Complessità nel decidere quando e cosa snapshotare
Quale approccio scegliere?
La scelta dipende dal contesto di business:
Usa Soft Link quando:
- I dati devono essere sempre aggiornati in tempo reale
- L'entità referenziata è soggetta a frequenti modifiche rilevanti
- La latenza aggiuntiva è accettabile
- Esiste un requisito di consistenza forte
Usa Snapshot quando:
- Hai bisogno di preservare lo stato storico (come era il dato in quel momento)
- L'autonomia e le performance del modulo sono prioritarie
- I dati snapshotati cambiano raramente o le modifiche non sono rilevanti per il modulo consumer
- Vuoi resilienza in caso di failure del modulo sorgente
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:
- Il modulo produttore pubblica un evento e prosegue immediatamente
- I moduli interessati reagiscono all'evento quando e come preferiscono
- L'accoppiamento è ridotto: il produttore non conosce i consumer
Implementazione pratica
In un monolite modulare, gli eventi possono viaggiare attraverso:
- Un message broker in-process (come MediatR in .NET o un EventBus custom)
- Una coda persistente interna (tabella del database usata come coda)
- Un broker esterno (RabbitMQ, Kafka) per maggiore resilienza
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
- Atomicità garantita: l'evento viene scritto nella stessa transazione dell'operazione di business
- At-least-once delivery: se la pubblicazione fallisce, l'evento rimane nella outbox e verrà riprovato
- Ordinamento preservato: gli eventi vengono processati nell'ordine di creazione
- Resilienza: il sistema tollera downtime temporanei del message broker
- Auditabilità: la tabella outbox funge da log degli eventi pubblicati
Considerazioni implementative
Il processo di pubblicazione può essere implementato come:
- Un job schedulato (polling della tabella ogni N secondi)
- Un background worker che usa LISTEN/NOTIFY (PostgreSQL) o Change Data Capture
- Un processo separato con logica di backoff esponenziale per i retry
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:
- Il consumer processa l'evento ma crasha prima di confermare l'avvenuta elaborazione
- Network partition temporanea causa retry automatici
- Bug nel sistema di messaging
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:
{event_type}:{entity_id}per eventi semplici{event_type}:{entity_id}:{version}se l'entità può generare più eventi dello stesso tipo{event_type}:{correlation_id}per flussi complessi
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:
- Orders Module riceve una richiesta di completamento ordine
- In un'unica transazione:
- Aggiorna lo stato dell'ordine
- Scrive l'evento
OrderCompletednella sua tabella Outbox
- L'Outbox Processor legge l'evento e lo pubblica sull'Event Bus
- 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
- Controlla l'Idempotency Key nella tabella
- 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."