Contents
- Configuring Two Independent DataSources
- Separate EntityManagerFactory per DataSource
- AbstractRoutingDataSource
- Read/Write Splitting
- Multi-Tenant Routing
- Transaction Management Pitfalls
- Health Indicators for Multiple DataSources
When you have two completely separate databases (e.g., one for orders, one for inventory), configure them as two independent DataSource beans. Mark one as @Primary so Spring Boot's auto-configuration uses it as the default.
# application.yml
app:
datasource:
orders:
url: jdbc:postgresql://orders-db:5432/orders
username: orders_user
password: ${ORDERS_DB_PASSWORD}
driver-class-name: org.postgresql.Driver
inventory:
url: jdbc:postgresql://inventory-db:5432/inventory
username: inventory_user
password: ${INVENTORY_DB_PASSWORD}
driver-class-name: org.postgresql.Driver
# Disable Spring Boot's DataSource auto-configuration
spring:
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
import com.zaxxer.hikari.*;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.*;
@Configuration
public class DataSourceConfig {
// @Primary — used by Spring Boot auto-configuration and default injection
@Bean
@Primary
@ConfigurationProperties("app.datasource.orders")
public HikariDataSource ordersDataSource() {
return new HikariDataSource();
}
@Bean
@ConfigurationProperties("app.datasource.inventory")
public HikariDataSource inventoryDataSource() {
return new HikariDataSource();
}
}
// Inject by name where needed
@Service
public class InventoryService {
private final JdbcTemplate jdbc;
// Qualify with the bean name to get the inventory datasource
public InventoryService(@Qualifier("inventoryDataSource") DataSource ds) {
this.jdbc = new JdbcTemplate(ds);
}
}
When using JPA with multiple DataSources, each DataSource needs its own EntityManagerFactory, TransactionManager, and package scan. Use @EnableJpaRepositories on separate @Configuration classes to bind repositories to the right factory.
// Orders JPA configuration
@Configuration
@EnableJpaRepositories(
basePackages = "com.example.orders.repository",
entityManagerFactoryRef = "ordersEntityManagerFactory",
transactionManagerRef = "ordersTransactionManager"
)
public class OrdersJpaConfig {
@Bean
@Primary
public LocalContainerEntityManagerFactoryBean ordersEntityManagerFactory(
@Qualifier("ordersDataSource") DataSource ds,
EntityManagerFactoryBuilder builder) {
return builder
.dataSource(ds)
.packages("com.example.orders.entity")
.persistenceUnit("orders")
.build();
}
@Bean
@Primary
public PlatformTransactionManager ordersTransactionManager(
@Qualifier("ordersEntityManagerFactory")
EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
}
// Inventory JPA configuration
@Configuration
@EnableJpaRepositories(
basePackages = "com.example.inventory.repository",
entityManagerFactoryRef = "inventoryEntityManagerFactory",
transactionManagerRef = "inventoryTransactionManager"
)
public class InventoryJpaConfig {
@Bean
public LocalContainerEntityManagerFactoryBean inventoryEntityManagerFactory(
@Qualifier("inventoryDataSource") DataSource ds,
EntityManagerFactoryBuilder builder) {
return builder
.dataSource(ds)
.packages("com.example.inventory.entity")
.persistenceUnit("inventory")
.build();
}
@Bean
public PlatformTransactionManager inventoryTransactionManager(
@Qualifier("inventoryEntityManagerFactory")
EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
}
AbstractRoutingDataSource is a Spring DataSource implementation that holds a map of real DataSources and delegates each getConnection() call to the one identified by determineCurrentLookupKey(). The key is typically stored in a ThreadLocal, set at the start of each request.
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
// 1. Thread-local holder for the current datasource key
public class DataSourceContextHolder {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setKey(String key) { CONTEXT.set(key); }
public static String getKey() { return CONTEXT.get(); }
public static void clear() { CONTEXT.remove(); }
}
// 2. Routing DataSource — overrides determineCurrentLookupKey()
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getKey();
}
}
// 3. Wire it up
@Configuration
public class RoutingDataSourceConfig {
@Bean
public DataSource routingDataSource(
@Qualifier("primaryDataSource") DataSource primary,
@Qualifier("replicaDataSource") DataSource replica) {
RoutingDataSource routing = new RoutingDataSource();
routing.setDefaultTargetDataSource(primary);
routing.setTargetDataSources(Map.of(
"primary", primary,
"replica", replica
));
routing.afterPropertiesSet();
return routing;
}
}
Route all @Transactional(readOnly = true) operations to the replica and write operations to the primary. An AOP aspect or a TransactionSynchronizationManager check sets the routing key before the transaction opens.
import org.springframework.aop.aspectj.annotation.AspectJProxyFactory;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.aop.framework.ProxyFactory;
// AOP aspect — intercepts @Transactional methods and sets the datasource key
@Aspect
@Component
@Order(1) // must run BEFORE @Transactional opens the connection
public class ReadWriteRoutingAspect {
@Around("@annotation(transactional)")
public Object route(ProceedingJoinPoint pjp,
org.springframework.transaction.annotation.Transactional transactional)
throws Throwable {
boolean readOnly = transactional.readOnly();
DataSourceContextHolder.setKey(readOnly ? "replica" : "primary");
try {
return pjp.proceed();
} finally {
DataSourceContextHolder.clear();
}
}
}
// Service — just annotate with readOnly for reads
@Service
@Transactional
public class ProductService {
private final ProductRepository productRepository;
@Transactional(readOnly = true) // → routed to replica
public Page<Product> findAll(Pageable pageable) {
return productRepository.findAll(pageable);
}
@Transactional(readOnly = true) // → replica
public Optional<Product> findById(Long id) {
return productRepository.findById(id);
}
// → primary (default)
public Product save(Product product) {
return productRepository.save(product);
}
}
The routing key must be set before the transaction begins — once a connection is obtained from the pool, all operations in that transaction use the same connection. This is why the AOP aspect needs @Order(1) to run before Spring's @Transactional AOP proxy (which runs at @Order(Integer.MAX_VALUE) by default).
For multi-tenancy, resolve the tenant from the incoming request (JWT claim, HTTP header, subdomain) and use it as the routing key. A servlet filter or Spring interceptor sets the tenant in the ThreadLocal at the start of each request.
// Resolve tenant from JWT or header and store in ThreadLocal
@Component
public class TenantResolutionFilter implements jakarta.servlet.Filter {
@Override
public void doFilter(jakarta.servlet.ServletRequest req,
jakarta.servlet.ServletResponse res,
jakarta.servlet.FilterChain chain)
throws java.io.IOException, jakarta.servlet.ServletException {
var httpReq = (jakarta.servlet.http.HttpServletRequest) req;
// Option A: from HTTP header
String tenant = httpReq.getHeader("X-Tenant-ID");
// Option B: from JWT claim (after Spring Security processes the token)
// Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// if (auth instanceof JwtAuthenticationToken jwt) {
// tenant = jwt.getToken().getClaimAsString("tenant_id");
// }
if (tenant != null) {
DataSourceContextHolder.setKey(tenant);
}
try {
chain.doFilter(req, res);
} finally {
DataSourceContextHolder.clear(); // always clean up!
}
}
}
// Register multiple tenant datasources
@Configuration
public class MultiTenantDataSourceConfig {
@Bean
public DataSource tenantRoutingDataSource(
@Qualifier("tenantADataSource") DataSource tenantA,
@Qualifier("tenantBDataSource") DataSource tenantB,
@Qualifier("tenantCDataSource") DataSource tenantC) {
RoutingDataSource routing = new RoutingDataSource();
routing.setDefaultTargetDataSource(tenantA);
routing.setTargetDataSources(Map.of(
"tenant-a", tenantA,
"tenant-b", tenantB,
"tenant-c", tenantC
));
routing.afterPropertiesSet();
return routing;
}
}
Never span a single @Transactional across two DataSources. JPA transactions are bound to a single EntityManager (and therefore a single DataSource). If you need atomic operations across two databases you need a distributed transaction manager (XA/JTA — e.g., Atomikos or Narayana), or accept eventual consistency with a Saga/Outbox pattern.
// WRONG — this tries to participate in two separate transaction managers
@Transactional // uses @Primary ordersTransactionManager
public void createOrderAndDeductStock(Order order, StockAdjustment adj) {
orderRepository.save(order); // orders DB
inventoryRepository.save(adj); // inventory DB — NOT in the same transaction!
// If inventoryRepository.save() fails, orderRepository.save() is NOT rolled back
}
// CORRECT option 1 — accept eventual consistency with compensating transactions
@Transactional("ordersTransactionManager")
public void createOrder(Order order) {
orderRepository.save(order);
// Publish an event to trigger stock deduction asynchronously
eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
}
@Transactional("inventoryTransactionManager")
@EventListener
public void deductStock(OrderCreatedEvent event) {
inventoryRepository.deductStock(event.orderId());
}
// CORRECT option 2 — specify the transaction manager explicitly
@Transactional("ordersTransactionManager")
public void saveOrder(Order order) { orderRepository.save(order); }
@Transactional("inventoryTransactionManager")
public void updateStock(StockAdjustment adj) { inventoryRepository.save(adj); }
Spring Boot's Actuator auto-registers a DataSourceHealthIndicator for each DataSource bean. With multiple DataSources they appear as separate components under /actuator/health.
# Expose detailed health information
management:
endpoint:
health:
show-details: always
// GET /actuator/health — example response with two datasources
{
"status": "UP",
"components": {
"ordersDb": {
"status": "UP",
"details": { "database": "PostgreSQL", "validationQuery": "isValid()" }
},
"inventoryDb": {
"status": "UP",
"details": { "database": "PostgreSQL", "validationQuery": "isValid()" }
}
}
}
// Custom health indicator — check both DataSources and report aggregate
@Component("databasesHealth")
public class MultiDataSourceHealthIndicator extends AbstractHealthIndicator {
private final DataSource ordersDs;
private final DataSource inventoryDs;
public MultiDataSourceHealthIndicator(
@Qualifier("ordersDataSource") DataSource ordersDs,
@Qualifier("inventoryDataSource") DataSource inventoryDs) {
this.ordersDs = ordersDs;
this.inventoryDs = inventoryDs;
}
@Override
protected void doHealthCheck(Health.Builder builder) throws Exception {
boolean ordersOk = isReachable(ordersDs);
boolean inventoryOk = isReachable(inventoryDs);
builder.withDetail("orders", ordersOk ? "UP" : "DOWN")
.withDetail("inventory", inventoryOk ? "UP" : "DOWN");
if (ordersOk && inventoryOk) builder.up();
else builder.down();
}
private boolean isReachable(DataSource ds) {
try (java.sql.Connection c = ds.getConnection()) {
return c.isValid(2);
} catch (Exception e) {
return false;
}
}
}