Spring Boot
Multiple Databases
Spring Boot supports connecting to multiple databases in a single application by defining separate DataSource, EntityManagerFactory, and TransactionManager beans for each database. Auto-configuration must be disabled and each database's JPA repositories must be directed to the correct EntityManagerFactory. This entry covers dual-database setup, separate transaction managers, Flyway and Liquibase with multiple datasources, read/write splitting, and distributed transactions with JTA.
Dual DataSource Configuration
Disable Spring Boot's DataSource and JPA auto-configuration and define each DataSource manually. Mark one as @Primary so Spring uses it as the default when no qualifier is specified. Each DataSource reads its properties from a distinct prefix in application.yml, keeping configuration isolated.
yaml
# ── application.yml ────────────────────────────────────────────────────
app:
datasource:
primary:
url: jdbc:postgresql://primary-host:5432/primary_db
username: ${PRIMARY_DB_USER}
password: ${PRIMARY_DB_PASS}
driver-class-name: org.postgresql.Driver
hikari:
pool-name: PrimaryPool
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
secondary:
url: jdbc:mysql://secondary-host:3306/secondary_db
username: ${SECONDARY_DB_USER}
password: ${SECONDARY_DB_PASS}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
pool-name: SecondaryPool
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
// ── Disable auto-configuration ────────────────────────────────────────
@SpringBootApplication(exclude = {
DataSourceAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class,
HibernateJpaAutoConfiguration.class
})
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
// ── DataSource configuration ───────────────────────────────────────────
@Configuration
public class DataSourceConfig {
// ── Primary DataSource (PostgreSQL) ───────────────────────────────
@Bean
@Primary
@ConfigurationProperties("app.datasource.primary")
public DataSourceProperties primaryDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@Primary
@ConfigurationProperties("app.datasource.primary.hikari")
public DataSource primaryDataSource(
@Qualifier("primaryDataSourceProperties")
DataSourceProperties properties) {
return properties.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
// ── Secondary DataSource (MySQL) ──────────────────────────────────
@Bean
@ConfigurationProperties("app.datasource.secondary")
public DataSourceProperties secondaryDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@ConfigurationProperties("app.datasource.secondary.hikari")
public DataSource secondaryDataSource(
@Qualifier("secondaryDataSourceProperties")
DataSourceProperties properties) {
return properties.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
}Primary JPA Configuration
Each database needs its own EntityManagerFactory and PlatformTransactionManager. The @EnableJpaRepositories annotation on each configuration class directs Spring Data to scan a specific package and wire its repositories to the correct EntityManagerFactory and TransactionManager.
Java
// ── Primary JPA configuration (PostgreSQL) ───────────────────────────
@Configuration
@EnableJpaRepositories(
basePackages = "com.myapp.primary.repository",
entityManagerFactoryRef = "primaryEntityManagerFactory",
transactionManagerRef = "primaryTransactionManager"
)
public class PrimaryJpaConfig {
@Bean
@Primary
public LocalContainerEntityManagerFactoryBean
primaryEntityManagerFactory(
@Qualifier("primaryDataSource") DataSource dataSource,
EntityManagerFactoryBuilder builder) {
return builder
.dataSource(dataSource)
.packages("com.myapp.primary.domain") // entity scan package
.persistenceUnit("primary")
.properties(jpaProperties())
.build();
}
@Bean
@Primary
public PlatformTransactionManager primaryTransactionManager(
@Qualifier("primaryEntityManagerFactory")
LocalContainerEntityManagerFactoryBean factory) {
return new JpaTransactionManager(
Objects.requireNonNull(factory.getObject()));
}
private Map<String, Object> jpaProperties() {
Map<String, Object> props = new HashMap<>();
props.put("hibernate.hbm2ddl.auto", "validate");
props.put("hibernate.dialect",
"org.hibernate.dialect.PostgreSQLDialect");
props.put("hibernate.show_sql", "false");
props.put("hibernate.format_sql", "true");
props.put("hibernate.jdbc.batch_size", "50");
props.put("hibernate.order_inserts", "true");
props.put("hibernate.order_updates", "true");
return props;
}
}Secondary JPA Configuration
The secondary configuration mirrors the primary but references the secondary DataSource, scans a different entity package, and uses a different persistence unit name. Keeping entities in separate packages is the clearest way to ensure Hibernate does not mix entities between the two databases.
Java
// ── Secondary JPA configuration (MySQL) ──────────────────────────────
@Configuration
@EnableJpaRepositories(
basePackages = "com.myapp.secondary.repository",
entityManagerFactoryRef = "secondaryEntityManagerFactory",
transactionManagerRef = "secondaryTransactionManager"
)
public class SecondaryJpaConfig {
@Bean
public LocalContainerEntityManagerFactoryBean
secondaryEntityManagerFactory(
@Qualifier("secondaryDataSource") DataSource dataSource,
EntityManagerFactoryBuilder builder) {
return builder
.dataSource(dataSource)
.packages("com.myapp.secondary.domain")
.persistenceUnit("secondary")
.properties(jpaProperties())
.build();
}
@Bean
public PlatformTransactionManager secondaryTransactionManager(
@Qualifier("secondaryEntityManagerFactory")
LocalContainerEntityManagerFactoryBean factory) {
return new JpaTransactionManager(
Objects.requireNonNull(factory.getObject()));
}
private Map<String, Object> jpaProperties() {
Map<String, Object> props = new HashMap<>();
props.put("hibernate.hbm2ddl.auto", "validate");
props.put("hibernate.dialect",
"org.hibernate.dialect.MySQLDialect");
props.put("hibernate.show_sql", "false");
props.put("hibernate.format_sql", "true");
return props;
}
}
// ── Package structure ──────────────────────────────────────────────────
// com.myapp.primary.domain — PostgreSQL entities
// User.java
// Order.java
// Product.java
// com.myapp.primary.repository — PostgreSQL repositories
// UserRepository.java
// OrderRepository.java
//
// com.myapp.secondary.domain — MySQL entities
// LegacyCustomer.java
// LegacyOrder.java
// com.myapp.secondary.repository — MySQL repositories
// LegacyCustomerRepository.javaUsing Multiple Transaction Managers
With multiple transaction managers, every @Transactional annotation must specify which manager to use via the transactionManager attribute, or the @Primary manager is used by default. Services that interact with both databases call each manager separately — there is no distributed coordination between them.
Java
// ── Service using primary database ────────────────────────────────────
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepo; // primary repository
// Uses @Primary transaction manager automatically
@Transactional
public UserResponse create(CreateUserRequest request) {
User user = User.from(request);
return UserResponse.from(userRepo.save(user));
}
@Transactional(readOnly = true)
public UserResponse findById(Long id) {
return userRepo.findById(id)
.map(UserResponse::from)
.orElseThrow(() -> new UserNotFoundException(id));
}
}
// ── Service using secondary database ──────────────────────────────────
@Service
@RequiredArgsConstructor
public class LegacyCustomerService {
private final LegacyCustomerRepository legacyRepo;
// Explicit qualifier — secondary transaction manager
@Transactional(transactionManager = "secondaryTransactionManager")
public LegacyCustomer sync(LegacySyncRequest request) {
return legacyRepo.save(LegacyCustomer.from(request));
}
@Transactional(
transactionManager = "secondaryTransactionManager",
readOnly = true
)
public List<LegacyCustomer> findAll() {
return legacyRepo.findAll();
}
}
// ── Service coordinating both databases (no distributed tx) ───────────
@Service
@RequiredArgsConstructor
@Slf4j
public class MigrationService {
private final UserService userService;
private final LegacyCustomerService legacyService;
// Two independent transactions — NOT atomic across databases
// If the second fails, the first has already committed
public MigrationResult migrate(Long legacyCustomerId) {
// Step 1: Read from secondary (legacy)
LegacyCustomer legacy = legacyService
.findById(legacyCustomerId);
// Step 2: Write to primary (new system)
try {
UserResponse user = userService.create(
CreateUserRequest.from(legacy));
return MigrationResult.success(user.id());
} catch (Exception ex) {
log.error("Migration failed for legacy customer {}",
legacyCustomerId, ex);
return MigrationResult.failure(ex.getMessage());
}
}
}Flyway with Multiple DataSources
Spring Boot's Flyway auto-configuration applies only to the primary DataSource. Define a separate Flyway bean for each additional database, pointing it to the correct DataSource and migration location. Disable auto-configuration and configure each Flyway instance manually.
Java
// ── Disable Flyway auto-configuration ────────────────────────────────
@SpringBootApplication(exclude = {
DataSourceAutoConfiguration.class,
FlywayAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class,
HibernateJpaAutoConfiguration.class
})
public class Application { ... }
// ── Flyway configuration for both databases ───────────────────────────
@Configuration
public class FlywayConfig {
// ── Primary Flyway (PostgreSQL) ───────────────────────────────────
@Bean
@Primary
public Flyway primaryFlyway(
@Qualifier("primaryDataSource") DataSource dataSource) {
Flyway flyway = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/primary")
.baselineOnMigrate(false)
.validateOnMigrate(true)
.table("flyway_schema_history")
.load();
flyway.migrate();
return flyway;
}
// ── Secondary Flyway (MySQL) ──────────────────────────────────────
@Bean
public Flyway secondaryFlyway(
@Qualifier("secondaryDataSource") DataSource dataSource) {
Flyway flyway = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/secondary")
.baselineOnMigrate(false)
.validateOnMigrate(true)
.table("flyway_schema_history")
.load();
flyway.migrate();
return flyway;
}
// ── Ensure Flyway runs before EntityManagerFactory ────────────────
@Bean
@Primary
@DependsOn("primaryFlyway")
public LocalContainerEntityManagerFactoryBean
primaryEntityManagerFactory(
@Qualifier("primaryDataSource") DataSource dataSource,
EntityManagerFactoryBuilder builder) {
return builder
.dataSource(dataSource)
.packages("com.myapp.primary.domain")
.persistenceUnit("primary")
.build();
}
@Bean
@DependsOn("secondaryFlyway")
public LocalContainerEntityManagerFactoryBean
secondaryEntityManagerFactory(
@Qualifier("secondaryDataSource") DataSource dataSource,
EntityManagerFactoryBuilder builder) {
return builder
.dataSource(dataSource)
.packages("com.myapp.secondary.domain")
.persistenceUnit("secondary")
.build();
}
}
// ── Migration file structure ───────────────────────────────────────────
// src/main/resources/
// └── db/
// └── migration/
// ├── primary/
// │ ├── V1__create_users.sql
// │ └── V2__create_orders.sql
// └── secondary/
// ├── V1__create_legacy_customers.sql
// └── V2__add_legacy_indexes.sqlRead/Write Splitting
Route write operations to a primary (master) database and read operations to one or more replicas (read replicas). Spring's AbstractRoutingDataSource selects the target DataSource at runtime based on the current transaction's read-only flag, set on the thread via a TransactionSynchronizationManager lookup.
Java
// ── Routing key enum ──────────────────────────────────────────────────
public enum DataSourceType {
PRIMARY, REPLICA
}
// ── Thread-local context ──────────────────────────────────────────────
public class DataSourceContextHolder {
private static final ThreadLocal<DataSourceType> CONTEXT =
new ThreadLocal<>();
public static void set(DataSourceType type) {
CONTEXT.set(type);
}
public static DataSourceType get() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
// ── Routing DataSource ────────────────────────────────────────────────
public class RoutingDataSource
extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// Route to replica for read-only transactions
boolean readOnly = TransactionSynchronizationManager
.isCurrentTransactionReadOnly();
DataSourceType explicit = DataSourceContextHolder.get();
if (explicit != null) return explicit;
return readOnly
? DataSourceType.REPLICA
: DataSourceType.PRIMARY;
}
}
// ── DataSource configuration with routing ─────────────────────────────
@Configuration
public class RoutingDataSourceConfig {
@Bean
@ConfigurationProperties("app.datasource.primary")
public DataSourceProperties primaryDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@ConfigurationProperties("app.datasource.replica")
public DataSourceProperties replicaDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
public DataSource primaryDataSource(
@Qualifier("primaryDataSourceProperties")
DataSourceProperties props) {
return props.initializeDataSourceBuilder()
.type(HikariDataSource.class).build();
}
@Bean
public DataSource replicaDataSource(
@Qualifier("replicaDataSourceProperties")
DataSourceProperties props) {
return props.initializeDataSourceBuilder()
.type(HikariDataSource.class).build();
}
@Bean
@Primary
public DataSource routingDataSource(
@Qualifier("primaryDataSource") DataSource primary,
@Qualifier("replicaDataSource") DataSource replica) {
Map<Object, Object> targets = Map.of(
DataSourceType.PRIMARY, primary,
DataSourceType.REPLICA, replica
);
RoutingDataSource routing = new RoutingDataSource();
routing.setTargetDataSources(targets);
routing.setDefaultTargetDataSource(primary);
routing.afterPropertiesSet();
return routing;
}
// ── Wrap routing DataSource in LazyConnectionDataSourceProxy ──────
// Delays connection acquisition until actually needed
@Bean
public DataSource dataSource(
@Qualifier("routingDataSource") DataSource routing) {
return new LazyConnectionDataSourceProxy(routing);
}
}
// ── Service — automatic routing via readOnly flag ─────────────────────
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // all reads → replica
public class ProductQueryService {
private final ProductRepository productRepo;
public Page<ProductResponse> findAll(Pageable pageable) {
// readOnly = true → RoutingDataSource picks REPLICA
return productRepo.findAll(pageable).map(ProductResponse::from);
}
@Transactional // write → primary
public ProductResponse create(CreateProductRequest request) {
return ProductResponse.from(
productRepo.save(Product.from(request)));
}
}Liquibase with Multiple DataSources
Liquibase auto-configuration applies to the primary DataSource only. Define a SpringLiquibase bean for each additional DataSource, pointing each to its own changelog location. Use @DependsOn to ensure Liquibase runs before the corresponding EntityManagerFactory initialises.
Java
// ── Disable Liquibase auto-configuration ─────────────────────────────
@SpringBootApplication(exclude = {
DataSourceAutoConfiguration.class,
LiquibaseAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class,
HibernateJpaAutoConfiguration.class
})
public class Application { ... }
// ── Liquibase beans for each database ────────────────────────────────
@Configuration
public class LiquibaseMultiConfig {
// ── Primary Liquibase (PostgreSQL) ────────────────────────────────
@Bean("primaryLiquibase")
@Primary
public SpringLiquibase primaryLiquibase(
@Qualifier("primaryDataSource") DataSource dataSource) {
SpringLiquibase liquibase = new SpringLiquibase();
liquibase.setDataSource(dataSource);
liquibase.setChangeLog(
"classpath:db/changelog/primary/db.changelog-master.yaml");
liquibase.setContexts("production");
liquibase.setShouldRun(true);
liquibase.setDropFirst(false);
liquibase.setDefaultSchema("public");
return liquibase;
}
// ── Secondary Liquibase (MySQL) ───────────────────────────────────
@Bean("secondaryLiquibase")
public SpringLiquibase secondaryLiquibase(
@Qualifier("secondaryDataSource") DataSource dataSource) {
SpringLiquibase liquibase = new SpringLiquibase();
liquibase.setDataSource(dataSource);
liquibase.setChangeLog(
"classpath:db/changelog/secondary/db.changelog-master.yaml");
liquibase.setContexts("production");
liquibase.setShouldRun(true);
liquibase.setDropFirst(false);
return liquibase;
}
// ── EntityManagerFactory depends on migrations completing ─────────
@Bean
@Primary
@DependsOn("primaryLiquibase")
public LocalContainerEntityManagerFactoryBean
primaryEntityManagerFactory(
@Qualifier("primaryDataSource") DataSource dataSource,
EntityManagerFactoryBuilder builder) {
return builder
.dataSource(dataSource)
.packages("com.myapp.primary.domain")
.persistenceUnit("primary")
.build();
}
@Bean
@DependsOn("secondaryLiquibase")
public LocalContainerEntityManagerFactoryBean
secondaryEntityManagerFactory(
@Qualifier("secondaryDataSource") DataSource dataSource,
EntityManagerFactoryBuilder builder) {
return builder
.dataSource(dataSource)
.packages("com.myapp.secondary.domain")
.persistenceUnit("secondary")
.build();
}
}
// ── Changelog structure ────────────────────────────────────────────────
// src/main/resources/
// └── db/
// └── changelog/
// ├── primary/
// │ ├── db.changelog-master.yaml
// │ └── migrations/
// │ ├── 001-create-users.yaml
// │ └── 002-create-orders.yaml
// └── secondary/
// ├── db.changelog-master.yaml
// └── migrations/
// ├── 001-create-legacy-customers.yaml
// └── 002-add-legacy-indexes.yamlDistributed Transactions with JTA
When a single operation must write to two databases atomically — both commit or both roll back — a JTA (Java Transaction API) distributed transaction coordinator is required. Atomikos is the most common embedded JTA provider for Spring Boot. Use distributed transactions sparingly: they are slower, more complex, and introduce coordinator failure modes.
XML
<!-- pom.xml — Atomikos JTA provider -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jta-atomikos</artifactId>
</dependency>
# ── application.yml ────────────────────────────────────────────────────
spring:
jta:
enabled: true
atomikos:
datasource:
primary:
xa-data-source-class-name:
org.postgresql.xa.PGXADataSource
unique-resource-name: primaryDataSource
max-pool-size: 20
min-pool-size: 5
xa-properties:
serverName: primary-host
portNumber: 5432
databaseName: primary_db
user: ${PRIMARY_DB_USER}
password: ${PRIMARY_DB_PASS}
secondary:
xa-data-source-class-name:
com.mysql.cj.jdbc.MysqlXADataSource
unique-resource-name: secondaryDataSource
max-pool-size: 10
min-pool-size: 2
xa-properties:
url: jdbc:mysql://secondary-host:3306/secondary_db
user: ${SECONDARY_DB_USER}
password: ${SECONDARY_DB_PASS}
// ── JTA configuration ─────────────────────────────────────────────────
@Configuration
public class JtaConfig {
@Bean
@Primary
public AtomikosDataSourceBean primaryDataSource() {
AtomikosDataSourceBean ds = new AtomikosDataSourceBean();
ds.setUniqueResourceName("primaryDataSource");
ds.setXaDataSourceClassName(
"org.postgresql.xa.PGXADataSource");
ds.setXaProperties(primaryXaProperties());
ds.setMaxPoolSize(20);
ds.setMinPoolSize(5);
return ds;
}
@Bean
public AtomikosDataSourceBean secondaryDataSource() {
AtomikosDataSourceBean ds = new AtomikosDataSourceBean();
ds.setUniqueResourceName("secondaryDataSource");
ds.setXaDataSourceClassName(
"com.mysql.cj.jdbc.MysqlXADataSource");
ds.setXaProperties(secondaryXaProperties());
ds.setMaxPoolSize(10);
ds.setMinPoolSize(2);
return ds;
}
private Properties primaryXaProperties() {
Properties p = new Properties();
p.setProperty("serverName", "primary-host");
p.setProperty("portNumber", "5432");
p.setProperty("databaseName", "primary_db");
p.setProperty("user", "${PRIMARY_DB_USER}");
p.setProperty("password", "${PRIMARY_DB_PASS}");
return p;
}
private Properties secondaryXaProperties() {
Properties p = new Properties();
p.setProperty("URL", "jdbc:mysql://secondary-host:3306/secondary_db");
p.setProperty("user", "${SECONDARY_DB_USER}");
p.setProperty("password", "${SECONDARY_DB_PASS}");
return p;
}
}
// ── Service with distributed transaction ──────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class DistributedMigrationService {
private final UserRepository userRepo;
private final LegacyCustomerRepository legacyRepo;
// Single @Transactional spans BOTH databases via JTA
// If either write fails — BOTH roll back
@Transactional // JTA TransactionManager coordinates both
public void migrateAtomically(Long legacyId) {
LegacyCustomer legacy = legacyRepo.findById(legacyId)
.orElseThrow(() -> new ResourceNotFoundException(
"Legacy customer not found: " + legacyId));
// Write to primary (PostgreSQL)
User user = userRepo.save(User.fromLegacy(legacy));
// Write to secondary (MySQL) — marks legacy as migrated
legacy.setMigratedUserId(user.getId());
legacy.setMigratedAt(LocalDateTime.now());
legacyRepo.save(legacy); // if this throws → user write also rolls back
log.info("Atomically migrated legacy customer {} → user {}",
legacyId, user.getId());
}
}