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.
Domande frequenti
Qual è la principale differenza tra un microservizio e un modular monolith in termini di design del database?
Mentre i microservizi hanno ciascuno il proprio database, nel modular monolith i moduli condividono lo stesso processo e potenzialmente lo stesso database fisico.
Qual è l'approccio principale per gestire la consistenza tra moduli diversi in un modular monolith?
La consistenza eventuale, che si basa su eventi di dominio pubblicati da un modulo e reagiti dagli altri moduli in transazioni separate.
Qual è il vantaggio principale dell'approccio "Shared Schema" nel design del database per un modular monolith?
La possibilità di mantenere la data ownership per modulo pur sfruttando i vantaggi di un database condiviso, come le transazioni ACID cross-module.
Qual è il pattern utilizzato per gestire transazioni che attraversano più moduli in un modular monolith?
Il Saga Pattern, che gestisce transazioni come una sequenza di transazioni locali, ciascuna con la propria compensazione in caso di fallimento.

Commenti
Caricamento commenti...
Accedi per commentare