Introduzione: Il Database nel Modular Monolith
Il design del database e una delle decisioni architetturali più critiche in un modular monolith. A differenza dei microservizi, dove ogni servizio ha il proprio database, nel modular monolith i moduli condividono lo stesso processo e potenzialmente lo stesso database fisico. La sfida e mantenere la data ownership per modulo pur sfruttando i vantaggi di un database condiviso, come le transazioni ACID cross-module.
In questo articolo esploreremo i due approcci principali: shared schema e schema per module, con i rispettivi trade-off. Vedremo pattern per la gestione delle transazioni, la consistenza eventuale, e strategie per migrare gradualmente da uno schema condiviso a schemi separati.
Cosa Imparerai in Questo Articolo
- Shared schema vs schema per modulo: vantaggi e svantaggi
- Data ownership: regole per mantenere l'integrita dei confini
- Transazioni ACID all'interno del processo vs eventual consistency
- Saga Pattern per transazioni long-running
- Event Sourcing come modello alternativo per la consistenza
- Change Data Capture per sincronizzare dati tra moduli
- Strategie di migrazione: da shared a separated
- Performance: ottimizzazione delle query cross-context
Shared Schema: Un Database, Schemi Logici Separati
Nell'approccio shared schema, tutti i moduli condividono lo stesso database fisico e potenzialmente lo stesso schema. Tuttavia, ogni modulo ha le proprie tabelle con un prefisso o uno schema PostgreSQL dedicato. L'accesso ai dati e regolato a livello applicativo: ogni modulo accede solo alle proprie tabelle attraverso il proprio repository.
Vantaggi dello Shared Schema
- Transazioni ACID: una singola transazione può garantire consistenza tra moduli
- Semplicita operativa: un solo database da gestire, monitorare, backuppare
- Query cross-module: possibili con JOIN quando necessario (per reporting, analytics)
- Migrazione semplice: non richiede infrastruttura aggiuntiva
Svantaggi dello Shared Schema
- Accoppiamento a livello dati: rischio di accesso diretto alle tabelle di altri moduli
- Scaling limitato: il database e un singolo punto di scaling
- Schema evolution: le migrazioni possono impattare tutti i moduli
-- Shared schema con prefissi per modulo
-- Ogni modulo ha il proprio prefisso nelle tabelle
-- Modulo Order
CREATE TABLE order_orders (
id UUID PRIMARY KEY,
user_id UUID NOT NULL, -- riferimento, non FK esterna
status VARCHAR(20) NOT NULL,
total_amount DECIMAL(10,2),
total_currency VARCHAR(3),
created_at TIMESTAMP NOT NULL
);
CREATE TABLE order_items (
id UUID PRIMARY KEY,
order_id UUID REFERENCES order_orders(id),
product_id UUID NOT NULL, -- riferimento, non FK esterna
quantity INT NOT NULL,
unit_price DECIMAL(10,2)
);
-- Modulo Catalog
CREATE TABLE catalog_products (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(10,2) NOT NULL,
category_id UUID,
is_available BOOLEAN DEFAULT true
);
-- Modulo User
CREATE TABLE user_accounts (
id UUID PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
display_name VARCHAR(100),
created_at TIMESTAMP NOT NULL
);
-- NOTA: order_orders.user_id NON ha una FK verso user_accounts
-- Questo e intenzionale: i moduli comunicano via API, non via FK
Regola d'Oro del Database
Mai creare foreign key tra tabelle di moduli diversi. I riferimenti tra moduli usano solo ID (UUID). La consistenza referenziale tra moduli e gestita a livello applicativo, non a livello database. Questo e il compromesso che rende possibile l'eventuale estrazione dei moduli come microservizi.
Schema per Module: Isolamento Completo
Nell'approccio schema per module, ogni modulo ha il proprio schema PostgreSQL (o il proprio database logico). Questo offre isolamento completo a livello dati, ma richiede pattern specifici per gestire la consistenza tra moduli.
// Configurazione multi-schema in Spring Boot
// Ogni modulo ha il proprio schema PostgreSQL
// application.yml
// spring:
// datasource:
// url: jdbc:postgresql://localhost:5432/ecommerce
// Modulo Order: accede solo allo schema 'orders'
@Configuration
class OrderDatabaseConfig {
@Bean
public LocalContainerEntityManagerFactoryBean orderEntityManager(
DataSource dataSource) {
LocalContainerEntityManagerFactoryBean em =
new LocalContainerEntityManagerFactoryBean();
em.setDataSource(dataSource);
em.setPackagesToScan("com.ecommerce.order.internal");
Map<String, Object> props = new HashMap<>();
props.put(
"hibernate.default_schema", "orders"
);
em.setJpaPropertyMap(props);
return em;
}
}
// Modulo Catalog: accede solo allo schema 'catalog'
@Configuration
class CatalogDatabaseConfig {
@Bean
public LocalContainerEntityManagerFactoryBean catalogEntityManager(
DataSource dataSource) {
LocalContainerEntityManagerFactoryBean em =
new LocalContainerEntityManagerFactoryBean();
em.setDataSource(dataSource);
em.setPackagesToScan("com.ecommerce.catalog.internal");
Map<String, Object> props = new HashMap<>();
props.put(
"hibernate.default_schema", "catalog"
);
em.setJpaPropertyMap(props);
return em;
}
}
Gestione delle Transazioni
Nel modular monolith, le transazioni possono essere gestite a diversi livelli a seconda delle esigenze di consistenza:
Transazioni ACID Intra-Module
All'interno di un singolo modulo, le transazioni ACID funzionano normalmente. Una singola
annotazione @Transactional garantisce atomicita, consistenza, isolamento e durabilita
per tutte le operazioni del modulo.
Eventual Consistency Cross-Module
Tra moduli diversi, la consistenza e gestita attraverso eventi di dominio. Quando un modulo completa una transazione, pubblica un evento. Gli altri moduli reagiscono all'evento in transazioni separate, garantendo eventual consistency.
// Pattern: Transactional Outbox per garantire
// la pubblicazione affidabile degli eventi
@Entity
@Table(name = "order_outbox_events")
public class OutboxEvent {
@Id
private UUID id;
private String eventType;
private String payload; // JSON serializzato
private Instant createdAt;
private boolean processed;
}
@Service
class OrderServiceImpl implements OrderModuleApi {
@Transactional
public OrderDto createOrder(CreateOrderCommand cmd) {
// 1. Salva l'ordine
Order order = Order.create(cmd);
orderRepository.save(order);
// 2. Salva l'evento nella outbox table
// NELLA STESSA TRANSAZIONE dell'ordine
OutboxEvent event = new OutboxEvent(
UUID.randomUUID(),
"OrderCreated",
JsonUtil.toJson(new OrderCreatedEvent(
order.getId(), order.getUserId()
)),
Instant.now(),
false
);
outboxRepository.save(event);
return order.toDto();
// La transazione include sia l'ordine che l'evento
}
}
// Scheduler che processa gli eventi dalla outbox
@Scheduled(fixedDelay = 1000)
public void processOutboxEvents() {
List<OutboxEvent> events = outboxRepo.findUnprocessed();
for (OutboxEvent event : events) {
eventPublisher.publish(event.toEvent());
event.markProcessed();
outboxRepo.save(event);
}
}
Saga Pattern: Transazioni Long-Running
Il Saga Pattern gestisce transazioni che attraversano più moduli come una sequenza di transazioni locali, ciascuna con la propria compensazione in caso di fallimento. Se un passo fallisce, i passi precedenti vengono annullati attraverso azioni compensative.
// Saga: Creazione Ordine con compensazione
// Orchestration-based Saga
@Service
class CreateOrderSaga {
private final OrderModuleApi orderModule;
private final PaymentModuleApi paymentModule;
private final InventoryModuleApi inventoryModule;
public OrderDto execute(CreateOrderCommand cmd) {
OrderDto order = null;
PaymentDto payment = null;
try {
// Step 1: Crea ordine (stato PENDING)
order = orderModule.createOrder(cmd);
// Step 2: Riserva inventario
inventoryModule.reserveStock(
order.id(), order.items()
);
// Step 3: Processa pagamento
payment = paymentModule.processPayment(
order.userId(), order.total()
);
// Step 4: Conferma ordine
orderModule.confirmOrder(order.id());
return order;
} catch (PaymentFailedException e) {
// Compensazione: rilascia inventario
inventoryModule.releaseStock(order.id());
// Compensazione: annulla ordine
orderModule.cancelOrder(order.id());
throw new OrderCreationFailedException(e);
} catch (InsufficientStockException e) {
// Compensazione: annulla ordine
orderModule.cancelOrder(order.id());
throw new OrderCreationFailedException(e);
}
}
}
Event Sourcing: Un Modello Alternativo
L'Event Sourcing e un pattern in cui lo stato di un'entità e ricostruito a partire dalla sequenza di eventi che lo hanno modificato. Invece di salvare lo stato corrente, si salvano tutti gli eventi. Questo approccio offre:
- Audit trail completo: ogni cambiamento e registrato come evento
- Ricostruzione dello stato: possibilità di ricostruire lo stato a qualsiasi punto nel tempo
- Integrazione naturale: gli eventi sono già disponibili per la comunicazione tra moduli
- Complessità: richiede pattern aggiuntivi (CQRS, proiezioni, snapshot) per query efficienti
// Event Sourcing: l'ordine e ricostruito dagli eventi
public class OrderAggregate {
private UUID id;
private OrderStatus status;
private List<OrderItem> items;
private Money total;
// Ricostruisci lo stato dagli eventi
public static OrderAggregate rebuild(List<DomainEvent> events) {
OrderAggregate order = new OrderAggregate();
for (DomainEvent event : events) {
order.apply(event);
}
return order;
}
private void apply(DomainEvent event) {
if (event instanceof OrderCreated e) {
this.id = e.orderId();
this.status = OrderStatus.CREATED;
this.items = e.items();
} else if (event instanceof ItemAdded e) {
this.items.add(e.item());
this.recalculateTotal();
} else if (event instanceof OrderConfirmed e) {
this.status = OrderStatus.CONFIRMED;
} else if (event instanceof OrderCancelled e) {
this.status = OrderStatus.CANCELLED;
}
}
}
// Event Store: salva la sequenza di eventi
@Repository
class EventStore {
void append(UUID aggregateId, DomainEvent event);
List<DomainEvent> loadEvents(UUID aggregateId);
}
Change Data Capture (CDC)
Il Change Data Capture e una tecnica che cattura le modifiche al database e le propaga come eventi ad altri consumatori. Strumenti come Debezium leggono il transaction log del database e producono eventi per ogni INSERT, UPDATE e DELETE.
Nel contesto del modular monolith, CDC e utile per:
- Sincronizzare viste materializzate tra moduli senza accoppiamento diretto
- Alimentare proiezioni read-model per query cross-module efficienti
- Preparare la migrazione: quando un modulo sarà estratto come microservizio, CDC può sincronizzare i dati durante la transizione
Performance: Query Cross-Context
Una delle sfide del modular monolith e gestire le query che richiedono dati da più moduli. Ecco le strategie principali:
1. API Composition
Il consumatore chiama le API di più moduli e compone il risultato in memoria. Semplice ma può essere inefficiente per grandi volumi di dati.
2. Read Model / Materialized Views
Un modulo di reporting mantiene viste materializzate che aggregano dati da più moduli. Le viste sono aggiornate tramite eventi di dominio. Le query di lettura sono veloci perchè i dati sono già pre-aggregati.
3. CQRS (Command Query Responsibility Segregation)
Separa il modello di scrittura (ottimizzato per transazioni) dal modello di lettura (ottimizzato per query). I comandi passano attraverso le API dei moduli. Le query accedono a proiezioni denormalizzate aggiornate tramite eventi.
// CQRS: Read Model per dashboard ordini
// Aggiornato tramite eventi, denormalizzato per query veloci
@Entity
@Table(name = "reporting_order_summary")
public class OrderSummaryView {
@Id
private UUID orderId;
private String customerName; // dal modulo User
private String customerEmail; // dal modulo User
private int itemCount; // dal modulo Order
private BigDecimal totalAmount; // dal modulo Order
private String paymentStatus; // dal modulo Payment
private Instant createdAt;
}
// Handler che aggiorna il read model reagendo agli eventi
@EventListener
class OrderSummaryProjection {
void on(OrderCreatedEvent e) {
OrderSummaryView view = new OrderSummaryView();
view.setOrderId(e.orderId());
view.setItemCount(e.itemCount());
view.setTotalAmount(e.total());
view.setCreatedAt(e.timestamp());
summaryRepo.save(view);
}
void on(PaymentCompletedEvent e) {
OrderSummaryView view = summaryRepo.findById(e.orderId());
view.setPaymentStatus("COMPLETED");
summaryRepo.save(view);
}
}
Strategia di Migrazione: Da Shared a Separated
Se parti con uno shared schema e decidi di separare gli schemi in futuro, ecco un approccio incrementale e sicuro:
- Aggiungi prefissi: rinomina le tabelle con prefissi per modulo (
order_,catalog_) - Rimuovi FK cross-module: sostituisci le foreign key tra moduli con riferimenti per ID
- Crea schemi separati: sposta le tabelle in schemi PostgreSQL dedicati
- Aggiorna le connessioni: configura ogni modulo per accedere al proprio schema
- Verifica: esegui test di integrazione per confermare che tutto funzioni
Prossimo Articolo
Nel prossimo articolo esploreremo i Communication Patterns tra moduli: chiamate sincrone, messaggi asincroni, event bus in-process, e il pattern CQRS completo. Vedremo come implementare ciascun pattern con Spring Boot e quando scegliere l'uno o l'altro.







