Contents

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; } } }