Introduction: The Database in a Modular Monolith
Database design is one of the most critical architectural decisions in a modular monolith. Unlike microservices, where each service has its own database, in a modular monolith the modules share the same process and potentially the same physical database. The challenge is maintaining data ownership per module while leveraging the advantages of a shared database, such as cross-module ACID transactions.
In this article we will explore the two main approaches: shared schema and schema per module, with their respective trade-offs. We will examine patterns for transaction management, eventual consistency, and strategies for gradually migrating from a shared schema to separated schemas.
What You Will Learn in This Article
- Shared schema vs schema per module: advantages and disadvantages
- Data ownership: rules for maintaining boundary integrity
- In-process ACID transactions vs eventual consistency
- Saga Pattern for long-running transactions
- Event Sourcing as an alternative model for consistency
- Change Data Capture for synchronizing data between modules
- Migration strategies: from shared to separated
- Performance: optimizing cross-context queries
Shared Schema: One Database, Separate Logical Schemas
In the shared schema approach, all modules share the same physical database and potentially the same schema. However, each module has its own tables with a dedicated prefix or PostgreSQL schema. Data access is regulated at the application level: each module accesses only its own tables through its own repository.
Shared Schema Advantages
- ACID transactions: a single transaction can guarantee consistency across modules
- Operational simplicity: one database to manage, monitor, back up
- Cross-module queries: possible with JOINs when needed (for reporting, analytics)
- Simple migration: no additional infrastructure required
Shared Schema Disadvantages
- Data-level coupling: risk of direct access to other modules' tables
- Limited scaling: the database is a single scaling point
- Schema evolution: migrations can impact all modules
-- Shared schema with module prefixes
-- Each module has its own table prefix
-- Order module
CREATE TABLE order_orders (
id UUID PRIMARY KEY,
user_id UUID NOT NULL, -- reference, not external FK
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, -- reference, not external FK
quantity INT NOT NULL,
unit_price DECIMAL(10,2)
);
-- Catalog module
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
);
-- User module
CREATE TABLE user_accounts (
id UUID PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
display_name VARCHAR(100),
created_at TIMESTAMP NOT NULL
);
-- NOTE: order_orders.user_id does NOT have a FK to user_accounts
-- This is intentional: modules communicate via API, not via FK
The Golden Database Rule
Never create foreign keys between tables of different modules. Cross-module references use only IDs (UUIDs). Referential consistency between modules is managed at the application level, not the database level. This is the trade-off enabling eventual extraction of modules as microservices.
Schema per Module: Complete Isolation
In the schema per module approach, each module has its own PostgreSQL schema (or its own logical database). This provides complete data-level isolation but requires specific patterns for managing cross-module consistency.
// Multi-schema configuration in Spring Boot
// Each module has its own PostgreSQL schema
// application.yml
// spring:
// datasource:
// url: jdbc:postgresql://localhost:5432/ecommerce
// Order module: accesses only the 'orders' schema
@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;
}
}
// Catalog module: accesses only the 'catalog' schema
@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;
}
}
Transaction Management
In a modular monolith, transactions can be managed at different levels depending on consistency requirements:
Intra-Module ACID Transactions
Within a single module, ACID transactions work normally. A single @Transactional
annotation guarantees atomicity, consistency, isolation, and durability for all module operations.
Cross-Module Eventual Consistency
Between different modules, consistency is managed through domain events. When a module completes a transaction, it publishes an event. Other modules react to the event in separate transactions, ensuring eventual consistency.
// Pattern: Transactional Outbox for guaranteed
// reliable event publication
@Entity
@Table(name = "order_outbox_events")
public class OutboxEvent {
@Id
private UUID id;
private String eventType;
private String payload; // serialized JSON
private Instant createdAt;
private boolean processed;
}
@Service
class OrderServiceImpl implements OrderModuleApi {
@Transactional
public OrderDto createOrder(CreateOrderCommand cmd) {
// 1. Save the order
Order order = Order.create(cmd);
orderRepository.save(order);
// 2. Save the event in the outbox table
// IN THE SAME TRANSACTION as the order
OutboxEvent event = new OutboxEvent(
UUID.randomUUID(),
"OrderCreated",
JsonUtil.toJson(new OrderCreatedEvent(
order.getId(), order.getUserId()
)),
Instant.now(),
false
);
outboxRepository.save(event);
return order.toDto();
// Transaction includes both the order and the event
}
}
// Scheduler that processes events from the 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: Long-Running Transactions
The Saga Pattern manages transactions spanning multiple modules as a sequence of local transactions, each with its own compensation in case of failure. If a step fails, previous steps are undone through compensating actions.
// Saga: Order Creation with compensation
// 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: Create order (PENDING status)
order = orderModule.createOrder(cmd);
// Step 2: Reserve inventory
inventoryModule.reserveStock(
order.id(), order.items()
);
// Step 3: Process payment
payment = paymentModule.processPayment(
order.userId(), order.total()
);
// Step 4: Confirm order
orderModule.confirmOrder(order.id());
return order;
} catch (PaymentFailedException e) {
// Compensation: release inventory
inventoryModule.releaseStock(order.id());
// Compensation: cancel order
orderModule.cancelOrder(order.id());
throw new OrderCreationFailedException(e);
} catch (InsufficientStockException e) {
// Compensation: cancel order
orderModule.cancelOrder(order.id());
throw new OrderCreationFailedException(e);
}
}
}
Event Sourcing: An Alternative Model
Event Sourcing is a pattern where an entity's state is reconstructed from the sequence of events that modified it. Instead of saving the current state, all events are saved. This approach offers:
- Complete audit trail: every change is recorded as an event
- State reconstruction: ability to reconstruct state at any point in time
- Natural integration: events are already available for inter-module communication
- Complexity: requires additional patterns (CQRS, projections, snapshots) for efficient queries
// Event Sourcing: the order is reconstructed from events
public class OrderAggregate {
private UUID id;
private OrderStatus status;
private List<OrderItem> items;
private Money total;
// Reconstruct state from events
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: saves the event sequence
@Repository
class EventStore {
void append(UUID aggregateId, DomainEvent event);
List<DomainEvent> loadEvents(UUID aggregateId);
}
Change Data Capture (CDC)
Change Data Capture is a technique that captures database changes and propagates them as events to other consumers. Tools like Debezium read the database transaction log and produce events for every INSERT, UPDATE, and DELETE.
In the modular monolith context, CDC is useful for:
- Synchronizing materialized views between modules without direct coupling
- Feeding read-model projections for efficient cross-module queries
- Migration preparation: when a module is extracted as a microservice, CDC can synchronize data during the transition
Performance: Cross-Context Queries
One of the modular monolith challenges is handling queries requiring data from multiple modules. Here are the main strategies:
1. API Composition
The consumer calls the APIs of multiple modules and composes the result in memory. Simple but can be inefficient for large data volumes.
2. Read Model / Materialized Views
A reporting module maintains materialized views aggregating data from multiple modules. Views are updated through domain events. Read queries are fast because data is already pre-aggregated.
3. CQRS (Command Query Responsibility Segregation)
Separates the write model (optimized for transactions) from the read model (optimized for queries). Commands pass through module APIs. Queries access denormalized projections updated via events.
// CQRS: Read Model for order dashboard
// Updated via events, denormalized for fast queries
@Entity
@Table(name = "reporting_order_summary")
public class OrderSummaryView {
@Id
private UUID orderId;
private String customerName; // from User module
private String customerEmail; // from User module
private int itemCount; // from Order module
private BigDecimal totalAmount; // from Order module
private String paymentStatus; // from Payment module
private Instant createdAt;
}
// Handler updating the read model by reacting to events
@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);
}
}
Migration Strategy: From Shared to Separated
If you start with a shared schema and decide to separate schemas later, here is an incremental and safe approach:
- Add prefixes: rename tables with module prefixes (
order_,catalog_) - Remove cross-module FKs: replace foreign keys between modules with ID references
- Create separate schemas: move tables into dedicated PostgreSQL schemas
- Update connections: configure each module to access its own schema
- Verify: run integration tests to confirm everything works
Next Article
In the next article we will explore Communication Patterns between modules: synchronous calls, asynchronous messages, in-process event bus, and the complete CQRS pattern. We will see how to implement each pattern with Spring Boot and when to choose one over another.







