Skip to main content

Multiple DataSources in Spring Boot 4 with Spring Data JPA

Two databases in one Spring Boot 4.1 application: a DataSource, Flyway, EntityManagerFactory and transaction manager per database, package-based repository scanning, and every way it fails, each run and quoted from a committed transcript.

Most Spring Boot applications have one database, and for that case almost nothing needs configuring: put a JDBC URL in application.properties and Boot builds the connection pool, the JPA setup, the transaction manager and the repositories. Then a second database turns up, a legacy schema, a reporting copy, a tenant, and the same application either refuses to start or, worse, starts and quietly does the wrong thing. This article builds the two-database setup from scratch and then breaks it on purpose, in several different ways, because each way fails differently. Every code block is a file in a small project that was compiled and run, and every console block is quoted from a transcript that a test or a script wrote, so the claims below are ones the build would notice if they stopped being true. The project is the multi-datasource module of a companion repository. There is no separate documentation folder: the deeper material sits in the collapsible “going deeper” sections beside the paragraph each one extends.
Versions. Spring Boot 4.1.1, Spring Framework 7.0.9 and Spring Data JPA 4.1.1 (all published to Maven Central on 20 August 2026), Hibernate ORM 7.4.5.Final, Flyway 12.4.0 and H2 2.4.240, on Java 25 (LTS, Temurin 25.0.4.1) and Maven 3.9. Both databases are in-memory H2, so the project runs anywhere. Nothing in the configuration is H2-specific; the upper-case table names in the transcripts are simply how H2 reports them.

Spring Boot builds one of everything, and you now need two

Talking to a database through Spring Data JPA takes a chain of five things, and it helps to name them because the rest of the article is about which of them you now own. A DataSource is the connection pool. Flyway uses it to bring the schema up to date. An EntityManagerFactory is Hibernate’s per-database engine, and it knows which classes are entities. A transaction manager begins and commits transactions on that factory. And the repositories you write are wired to a factory and a transaction manager. With one database, Spring Boot creates the whole chain for you. With two, you build a second chain, and you tell each repository which chain it belongs to.
customers chain DataSource Flyway EntityManagerFactory TransactionManager CustomerRepository orders chain DataSource Flyway EntityManagerFactory TransactionManager OrderRepository Two chains. Boot’s automatic setup builds one.
The picture is the whole design: two independent chains that never touch. Each database has its own pool, its own schema history, its own set of entity classes and its own transactions. The rest of the article is the wiring that keeps them apart, and the ways it goes wrong when it does not. The natural first attempt is to declare two DataSource beans and let Spring Boot do the rest (NoDefaultApp.java):
@SpringBootApplication
@EnableJpaRepositories(basePackageClasses = CustomerRepository.class)
public class NoDefaultApp {

    @Bean
    DataSource customersDataSource() {
        return DataSourceBuilder.create().url("jdbc:h2:mem:nd-customers").username("sa").password("").build();
    }

    @Bean
    DataSource ordersDataSource() {
        return DataSourceBuilder.create().url("jdbc:h2:mem:nd-orders").username("sa").password("").build();
    }
}
It does not start. The transcript (02-no-primary.txt):
startup failed: BeanCreationException -> NoSuchBeanDefinitionException
root cause: org.springframework.beans.factory.NoSuchBeanDefinitionException: No bean named 'entityManagerFactory' available
Read the root cause as a question the repository is asking: “where is the bean named entityManagerFactory?” Spring Boot’s JPA setup only switches on when there is exactly one obvious DataSource, and here there are two and neither is marked @Primary, so it switched itself off and nothing was left to create that bean. The fix is not one property. It is a chain of your own, per database, and the next section is that chain.
Going deeper: why the error mentions a name and not a type

A repository created with @EnableJpaRepositories and no other attributes asks for a factory by the default bean name entityManagerFactory, not by type. That is why the message is No bean named 'entityManagerFactory' available even if you had built some other factory under another name. The way to point a repository at a specific factory is the entityManagerFactoryRef attribute, which the next section uses.

The transcript comes from TrapsTests.java, the test twoDataSourcesAndNoPrimary. The chain of exception classes is printed from the outside in, so BeanCreationException is what Spring reports and NoSuchBeanDefinitionException is the reason.

Going deeper

One configuration class per database

The working setup puts each database’s whole chain in one class, so that a reader looking for “everything about the customers database” opens one file (CustomersDbConfig.java):
@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(
        basePackageClasses = CustomerRepository.class,
        entityManagerFactoryRef = "customersEntityManagerFactory",
        transactionManagerRef = "customersTransactionManager")
public class CustomersDbConfig {

    @Bean
    @Primary
    @ConfigurationProperties("app.datasource.customers")
    DataSourceProperties customersDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    @Primary
    @ConfigurationProperties("app.datasource.customers.configuration")
    HikariDataSource customersDataSource(@Qualifier("customersDataSourceProperties") DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }

    @Bean(initMethod = "migrate")
    Flyway customersFlyway(@Qualifier("customersDataSource") DataSource dataSource) {
        return Flyway.configure()
                .dataSource(dataSource)
                .locations("classpath:db/migration/customers")
                .load();
    }

    @Bean
    @Primary
    @DependsOn("customersFlyway")
    LocalContainerEntityManagerFactoryBean customersEntityManagerFactory(
            EntityManagerFactoryBuilder builder, @Qualifier("customersDataSource") DataSource dataSource) {
        return builder.dataSource(dataSource)
                .packages(Customer.class)
                .persistenceUnit("customers")
                .properties(Map.of("hibernate.hbm2ddl.auto", "validate"))
                .build();
    }

    @Bean
    @Primary
    PlatformTransactionManager customersTransactionManager(
            @Qualifier("customersEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }
}
OrdersDbConfig.java is the same class with the names changed and without @Primary. The connection details come from properties under a prefix of your own choosing (application.properties):
# One block per database. The prefix is ours ("app."), not Spring Boot's ("spring.datasource.").
app.datasource.customers.url=jdbc:h2:mem:customers
app.datasource.customers.username=sa
app.datasource.customers.password=

app.datasource.orders.url=jdbc:h2:mem:orders
app.datasource.orders.username=sa
app.datasource.orders.password=

# Pool settings are per database too. They bind onto the HikariDataSource we build, under ".configuration".
app.datasource.customers.configuration.pool-name=customers-pool
app.datasource.customers.configuration.maximum-pool-size=4
app.datasource.orders.configuration.pool-name=orders-pool
app.datasource.orders.configuration.maximum-pool-size=8
Start the application, save one customer and one order, and ask each database what it holds. This is what the test wrote (from 01-two-databases.txt):
--- the two DataSources (one pool each, configured separately) ---
customersDataSource  pool=customers-pool  max=4  url=jdbc:h2:mem:customers  primary=true
ordersDataSource     pool=orders-pool     max=8  url=jdbc:h2:mem:orders  primary=false

--- Flyway ran once per database, each with its own history ---
customers flyway_schema_history: [1 customer]
orders    flyway_schema_history: [1 purchase order, 2 index customer]

--- what each EntityManagerFactory manages ---
EntityManagerFactory beans: [customersEntityManagerFactory, ordersEntityManagerFactory]
customersEntityManagerFactory    entities=[Customer]
ordersEntityManagerFactory       entities=[PurchaseOrder]

--- transaction managers ---
beans: [customersTransactionManager, ordersTransactionManager]
customersTransactionManager    primary=true
ordersTransactionManager       primary=false

--- save one customer and one order through the two repositories ---
customers database tables: [CUSTOMER, flyway_schema_history]
orders    database tables: [PURCHASE_ORDER, flyway_schema_history]
rows: customers.customer=1  orders.purchase_order=1
Everything in that transcript is a separate fact about a separate database: two pools with their own names and sizes, one Flyway history each, two EntityManagerFactory beans (and only two, so Spring Boot’s automatic one stayed out of the way), each factory knowing one entity, and a customer table that exists only in the customers database. Only one of each kind of bean is marked @Primary, and it is the customers one. That is a rule you have to keep: anything that asks Spring for “the” DataSource, “the” EntityManagerFactory or “the” transaction manager, without naming one, gets the primary. The sections on the wrong database and on @Transactional below are about what that quietly decides for you.
Pick the primary on purpose. Marking one database primary is not a neutral tidy-up. It is the database that every unnamed @Transactional will use, and the one that Spring Boot’s automatic JPA setup builds on if you let it (both are measured below). Choose the one that most of your code touches, and name the other one explicitly everywhere else.
Going deeper: what each piece of the class does

DataSourceProperties is Spring Boot’s own holder for a URL, user and password. Binding app.datasource.customers.* onto it accepts the property name url (the next section shows a pool that does not), and initializeDataSourceBuilder() turns the values into a pool. Binding the second prefix, app.datasource.customers.configuration, onto the pool object itself is how pool size and pool name are set per database; the transcript shows max=4 and max=8 arriving where they were configured.

The Flyway bean is declared with initMethod = "migrate" so that creating the bean migrates the database. The factory is declared @DependsOn("customersFlyway") so that the order is written down: migrate first, then build the factory that validates the schema. Its Hibernate property hbm2ddl.auto=validate makes Hibernate check the entities against the migrated schema instead of changing it. The next section explains why that property is written out by hand. I did not test what happens without the @DependsOn, so this article does not claim that it fails.

@EnableJpaRepositories sits on the class and names its factory and transaction manager by bean name. The section on repositories below is about what its package attribute decides.

Going deeper

Two details in that class that look optional and are not

Both of them are copied wrongly from older articles, so each got its own run. The first is how the connection URL reaches the pool. A very common shortcut is to bind the properties straight onto a pool created by DataSourceBuilder (JdbcUrlApp.java):
@SpringBootApplication
public class JdbcUrlApp {

    @Bean
    @ConfigurationProperties("app.datasource.customers")
    DataSource customersDataSource() {
        return DataSourceBuilder.create().build();
    }
}
with the URL in app.datasource.customers.url. The transcript (04-url-vs-jdbc-url.txt):
startup failed: BeanCreationException -> BeanInstantiationException -> IllegalArgumentException
root cause: java.lang.IllegalArgumentException: dataSource or dataSourceClassName or jdbcUrl is required.
The pool’s own property is called jdbcUrl, and the pool complained that it was never given one, so the url key never reached it. The configuration class earlier avoids the whole question by binding onto DataSourceProperties, which does use the name url, and then building the pool from that. The second detail is about the factory. It looks as if a hand-built EntityManagerFactory must be told about naming conventions and schema handling, because those come from the spring.jpa.* properties that only the automatic factory reads. I built one that passes no Hibernate properties at all and printed what it ended up with (BuilderDefaultsApp.java, output in 05-builder-defaults.txt):
hibernate.physical_naming_strategy = org.hibernate.boot.model.naming.PhysicalNamingStrategySnakeCaseImpl
hibernate.implicit_naming_strategy = org.springframework.boot.hibernate.SpringImplicitNamingStrategy
hibernate.hbm2ddl.auto             = null
saved a Customer whose field is createdAt into a column created_at: id=1
So the naming rules do come along. The EntityManagerFactoryBuilder that Spring Boot supplies as a bean carries the snake-case naming strategy, which is why the createdAt field found its created_at column. What does not come along is the schema-handling default: hibernate.hbm2ddl.auto is unset, and the automatic factory in the wrong-database section below shows what Spring Boot sets there for an embedded database. That is the reason the configuration above writes validate out by hand.
Going deeper: what “unset” means and what I did not measure

With the property unset, the row was saved into the table Flyway had created. I did not try every value of the setting. What the transcripts establish is narrower: the builder carries the naming strategies, it does not carry a hbm2ddl.auto value, and the automatic factory in the wrong-database section does (create-drop, in that run).

If you want different Hibernate settings per database, this is also the place to put them: the properties(...) call on the builder is per factory, which a global spring.jpa.* property cannot be.

Going deeper

Which repository talks to which database

A repository does not know about databases. It knows about an entity class, and it is bound, when it is created, to one EntityManagerFactory. Two settings decide that binding: the package the repository interface sits in, which is how @EnableJpaRepositories finds it, and the factory named in entityManagerFactoryRef. On the other side, the packages passed to the factory decide which entity classes that factory knows.
customers package Customer, CustomerRepository customersEntityManagerFactory manages: Customer customers database orders package PurchaseOrder, OrderRepository ordersEntityManagerFactory manages: PurchaseOrder orders database CustomerInOrdersRepository a Customer repository in the wrong scan Not a managed type: class Customer
The top two rows are the arrangement that worked. The bottom shape is the mistake: a repository for Customer that a scan pointed at the orders factory. Spring Data checks that the entity is known to the factory when it creates the repository, so the mistake is loud, and it is loud at startup rather than at the first query. Here is that mistake as code (WrongPackageApp.java) and what it produces (07-wrong-package.txt):
@SpringBootApplication
@Import(OrdersDbConfig.class)
@EnableJpaRepositories(basePackageClasses = CustomerInOrdersRepository.class,
        entityManagerFactoryRef = "ordersEntityManagerFactory", transactionManagerRef = "ordersTransactionManager")
public class WrongPackageApp {
}
startup failed: BeanCreationException -> IllegalArgumentException
root cause: java.lang.IllegalArgumentException: Not a managed type: class com.ankurm.multids.customers.Customer
One more thing that people run into on the way to this arrangement: @EnableJpaRepositories cannot simply be written twice on one class. I tried it, and javac refused (RepeatedEnable.java, output in 11-enable-jpa-repositories-not-repeatable.txt):
RepeatedEnable.java:7: error: EnableJpaRepositories is not a repeatable annotation interface
@EnableJpaRepositories(basePackages = "a.orders", entityManagerFactoryRef = "ordersEntityManagerFactory")
^
1 error
That is why each database has its own configuration class, each carrying its own annotation; the per-database classes in this project are not only a style choice. If you would rather have one class, the same annotation can be placed on small nested configuration classes, which is what the Flyway scenario in the next section does.
Going deeper: package scanning in both directions

There are two scans in play and they are independent. The repository scan is basePackageClasses = CustomerRepository.class on the configuration class: you name a class and its package is the one scanned. The entity scan is .packages(Customer.class) on the builder, and here I checked instead of assuming: reading the builder’s bytecode shows it calls ClassUtils.getPackageName on each class you pass and collects the results (12-builder-packages.txt). So the class you give it is a marker for a package, and every entity in that package belongs to that factory.

In this project each database’s entity and repository sit in the same package, so one class literal serves both scans. If yours live in different packages, the two attributes need different values. Keep the two databases’ packages separate, as the transcripts above do: a repository whose entity is in the other database’s package is the failure shown just above.

Going deeper

Migrations: one Flyway per database

Each database gets its own folder of migrations and its own flyway_schema_history table. The orders database has two migrations and the customers one has one, so the histories differ (V1__purchase_order.sql):
-- customer_id points at a row in a DIFFERENT database, so there is no foreign key here and there cannot be one.
create table purchase_order (
    id          bigint generated by default as identity primary key,
    customer_id bigint         not null,
    amount      decimal(10, 2) not null,
    placed_at   timestamp      not null
);
The comment in that file is a design fact, not a remark. A row in the orders database refers to a customer in a different database, so no foreign key can be declared between them. The database will not check that the customer exists, and neither will Hibernate, because the field is a plain number rather than a @ManyToOne. That check becomes your code’s job. The configuration class earlier builds one Flyway object per database by hand. You may wonder whether Spring Boot’s Flyway auto-configuration could do it for you. I tried it three ways (from 06-flyway-auto-configuration.txt):
--- A. nothing configured: Flyway targets the @Primary DataSource and scans classpath:db/migration ---
startup failed: BeanCreationException -> BeanCreationException -> FlywayException
root cause: org.flywaydb.core.api.FlywayException: Found more than one migration with version 1
Offenders:
-> <multi-datasource>/target/classes/db/migration/customers/V1__customer.sql (SQL)
-> <multi-datasource>/target/classes/db/migration/orders/V1__purchase_order.sql (SQL)

--- B. spring.flyway.locations=classpath:db/migration/customers ---
startup failed: BeanCreationException -> PersistenceException -> SchemaManagementException
root cause: org.hibernate.tool.schema.spi.SchemaManagementException: Schema validation: missing table [purchase_order]

application started (Hibernate validation switched off so the databases can be inspected)
customers database tables: [CUSTOMER, flyway_schema_history]
orders    database tables: []

--- C. as B, plus one hand-made Flyway bean for the orders database ---
startup failed: BeanCreationException -> PersistenceException -> SchemaManagementException
root cause: org.hibernate.tool.schema.spi.SchemaManagementException: Schema validation: missing table [customer]

application started (Hibernate validation switched off so the databases can be inspected)
customers database tables: []
orders    database tables: [PURCHASE_ORDER, flyway_schema_history]
The three outcomes tell you what the auto-configuration is. In A, it worked on one database and scanned the default location, which includes every subfolder, so it found both databases’ V1 files and refused to continue. In B, after narrowing the location to the customers folder, the application starts once Hibernate’s validation is switched off, and the table listing shows what happened: the customers database was migrated and the orders database has no tables at all, which is why the orders factory fails validation when it is on. In C, adding one hand-made Flyway bean for the orders database gave the opposite picture: the orders database is migrated and the customers database is empty. The auto-configuration is one Flyway for the primary database, and it steps aside as soon as you define a Flyway bean of your own.
Do not mix. Either give every database its own hand-made Flyway bean, as the working setup does, or leave Flyway to Spring Boot for a single database. Case C is the one that catches people: you add a second Flyway bean to migrate the new database and the first database silently stops being migrated.
Going deeper: the scenario and what it holds fixed

The scenario application is FlywayAutoApp.java. It has two databases, two factories that validate their schema, and no Flyway beans of its own, so that Spring Boot’s auto-configuration is the only thing that could migrate. The property trap.flyway=orders-only turns on the single hand-made bean used in case C, the property spring.flyway.locations is the one used in case B, and trap.ddl=none switches Hibernate’s validation off so that the application starts and the tables can be listed.

Because @EnableJpaRepositories is not repeatable (see the repositories section), this scenario is also the example of putting each database’s repository scan on its own small nested configuration class instead of a per-database top-level class.

If you use Liquibase rather than Flyway, this article does not cover it, and I did not run it. The Flyway vs Liquibase comparison compares the two tools on a single database.

Going deeper

The setup that starts, and writes to the wrong database

Here is the failure worth fearing, because nothing complains. Give the two DataSource beans a @Primary on one of them and do no other JPA configuration. The earlier “no primary” failure goes away, because now there is one obvious DataSource again, and Spring Boot builds a single EntityManagerFactory on it. If you also tell Spring Boot where the entities are, it puts all of them in that one factory (PrimaryOnlyApp.java).
@SpringBootApplication
@EntityScan(basePackageClasses = {Customer.class, PurchaseOrder.class})
@EnableJpaRepositories(basePackages = {"com.ankurm.multids.customers", "com.ankurm.multids.orders"})
public class PrimaryOnlyApp {

    @Bean
    @Primary
    DataSource customersDataSource() {
        return DataSourceBuilder.create().url("jdbc:h2:mem:po-customers").username("sa").password("").build();
    }

    @Bean
    DataSource ordersDataSource() {
        return DataSourceBuilder.create().url("jdbc:h2:mem:po-orders").username("sa").password("").build();
    }
}
The application starts. Then one customer and one order are saved through the two repositories, and each database is asked what it contains (from 03-primary-only-wrong-database.txt):
application started: yes
EntityManagerFactory beans: [entityManagerFactory]
customers database tables: [CUSTOMER, PURCHASE_ORDER]
orders    database tables: []
rows in the customers database: customer=1  purchase_order=1
hibernate.hbm2ddl.auto on that factory: create-drop
The orders database has no tables at all. Both tables, and both rows, are in the customers database. The application worked, every repository call succeeded, and the second database sat there unused. Look at the last line too: Spring Boot’s own factory set hbm2ddl.auto to create-drop for the embedded database, so it created the missing purchase_order table on the fly, which is what made the whole thing appear to work.
CustomerRepository OrderRepository one EntityManagerFactory Boot’s automatic one customers database both tables orders database empty @Primary alone: one factory, two entities, one database in use.
The picture shows why this is dangerous: a green start tells you nothing about where the data went. The check that catches it is not a health endpoint or a log line. It is a test that saves through each repository and then asks each database, with plain JDBC, what it holds, which is what the transcript above is.
“It starts” is not a test. With two databases, write one test that writes through every repository and reads back from the intended database using JDBC, bypassing JPA. It is the only thing that distinguishes the working setup from this one, and it takes a few lines. The test that produced transcript 01 does exactly that.

Going deeper

Which transaction manager does @Transactional pick?

A service method that writes to both databases is where the two chains meet, and where the earlier @Primary choice starts to matter. Here is such a service, with two ways to declare the transaction (PlacementService.java):
/** Plain {@code @Transactional}: Spring picks the one transaction manager marked {@code @Primary}. */
@Transactional
public void placeWithDefaultTransaction(String name, BigDecimal amount, boolean failAfterWriting) {
    writeBoth(name, amount, failAfterWriting);
}

/** Names the orders transaction manager, so the ORDERS write is the one that rolls back. */
@Transactional("ordersTransactionManager")
public void placeWithOrdersTransaction(String name, BigDecimal amount, boolean failAfterWriting) {
    writeBoth(name, amount, failAfterWriting);
}

private void writeBoth(String name, BigDecimal amount, boolean failAfterWriting) {
    Customer customer = customers.save(new Customer(name, name.toLowerCase() + "@example.com"));
    orders.save(new PurchaseOrder(customer.getId(), amount));
    if (failAfterWriting) {
        throw new IllegalStateException("boom after both writes");
    }
}
Each method writes a customer and an order and then throws, so that the transaction has to roll back. What survived (from 08-which-transaction-manager.txt):
--- @Transactional (no name): Spring picks the @Primary manager, customersTransactionManager ---
thrown: java.lang.IllegalStateException: boom after both writes
rows after the rollback: customers.customer=0  orders.purchase_order=1

--- @Transactional("ordersTransactionManager"): the other database is now the one that rolls back ---
thrown: java.lang.IllegalStateException: boom after both writes
rows after the rollback: customers.customer=1  orders.purchase_order=0
@Transactional no name: the @Primary manager customers transaction rolled back orders write already committed @Transactional(“orders…”) names the orders manager orders transaction rolled back customers write already committed The service transaction covers one database. The other write is a separate transaction that commits on its own, as soon as its repository call returns.
Two lessons are in that transcript. First, a plain @Transactional means “use the primary manager”, so with the customers database as primary it protected the customer write and left the order write to commit by itself. Second, and more important, naming the other manager does not fix anything. It swaps which write is protected. A transaction on a JPA transaction manager covers one EntityManagerFactory. Neither annotation can make writes to two databases succeed or fail together.
The failure is silent and half-committed. In both runs the exception reached the caller and one row was rolled back, so a quick manual test looks like the rollback worked. The row in the other database is the one nobody looks for.
Going deeper: what each database saw

The repositories are created with a transaction manager of their own (the transactionManagerRef in the configuration class). When the service method runs under a transaction on one manager, the repository call for the other database starts its own transaction on its own manager and commits when the call returns. That is consistent with the counts in the transcript. I did not instrument the transactions to log when each began, so this explanation is inferred from the outcome, not observed directly.

The service is exercised by the test TwoDatabasesTests.java, plainTransactionalGetsThePrimaryManager, which also clears both tables between the two runs so that each count belongs to one run.

Going deeper

Making two commits behave like one: ChainedTransactionManager

Spring Data still ships a class that looks like the answer: a transaction manager that wraps several others and drives them together. Before using it, check whether it is still supported. I read it from the jar that Spring Boot 4.1.1 brings in (from 10-chained-transaction-manager-jar.txt):
jar:      spring-data-commons-4.1.1.jar
class:    org/springframework/data/transaction/ChainedTransactionManager.class

--- javap -v: class-level annotations ---
RuntimeVisibleAnnotations:
  0: #245()
    java.lang.Deprecated
--- javap -v: Deprecated attribute ---
Deprecated: true
It is still there, in Spring Data Commons 4.1.1, and it is marked @Deprecated. The annotation has no arguments, so the class file records neither a removal flag nor a version, and I did not look for a reason or a replacement. The point of the next runs is what the class can and cannot do. To exercise it, the test project wraps the two real managers, and builds a variant of the orders manager whose commit always fails, standing in for a database that refuses the commit (ChainedApp.java and CommitFailingTransactionManager.java):
@Bean
@SuppressWarnings("deprecation")
PlatformTransactionManager chained(@Qualifier("customersTransactionManager") PlatformTransactionManager customers,
                                   @Qualifier("ordersTransactionManager") PlatformTransactionManager orders) {
    return new ChainedTransactionManager(customers, orders);
}
public class CommitFailingTransactionManager extends JpaTransactionManager {

    public CommitFailingTransactionManager(EntityManagerFactory emf) {
        super(emf);
    }

    @Override
    protected void doCommit(DefaultTransactionStatus status) {
        throw new TransactionSystemException("simulated: the orders database refused the commit");
    }
}
Three runs (from 09-chained-transaction-manager.txt):
--- 1. business exception after both writes: both roll back ---
rows: customers.customer=0  orders.purchase_order=0

--- 2. the orders database refuses to commit; orders is LAST in the list ---
thrown: org.springframework.transaction.HeuristicCompletionException (outcome: rolled back)
rows: customers.customer=0  orders.purchase_order=0

--- 3. the orders database refuses to commit; orders is FIRST in the list ---
thrown: org.springframework.transaction.HeuristicCompletionException (outcome: mixed)
rows: customers.customer=1  orders.purchase_order=0
chain(customers, ordersFailing) orders is last in the list outcome: rolled back 0 customers, 0 orders chain(ordersFailing, customers) orders is first in the list outcome: mixed 1 customer, 0 orders Same code, same failure, and a different result depending only on the order of the list.
The first run is the reassuring one: a business exception after both writes rolled both back, which a single manager could not do in the previous section. The second and third runs are the caveat. The exception is HeuristicCompletionException, and its outcome is the whole story. When the failing database was last in the list, nothing was committed. When it was first, the customer row was committed and the order was not, and the exception says mixed. The result depends on the position of a bean in a list, and a blind retry of the whole operation would insert the customer a second time, because half of it has already happened.
Best-effort is not atomic. What the chain gives you is that a failure before any commit rolls everything back. It does not give you a single decision across two databases. If a commit can fail after another has succeeded, you have a mixed outcome and an exception that says so. That is the limit to plan around, whatever the deprecation notice’s reason.
Going deeper: what to do when the two writes must agree

This article does not run a real distributed transaction manager, so it makes no claim about how one behaves. What the runs above do support is a design position: if a customer row and an order row must never disagree, keep them in one database, or write the second one as a consequence of the first that can be retried safely, instead of as a simultaneous commit.

The event article on this site describes the pattern that fits: publish an event after the first commit and let a listener write the second database, with the caveat that a failing listener must not be silent. Spring Application Events runs exactly that, including what an AFTER_COMMIT listener can and cannot see.

Going deeper

Should you have two databases here at all?

Two databases are a cost you pay in every feature. Each one is a second pool to size, a second migration history to keep in step, a second set of transactions that cannot be joined, and a foreign key you cannot declare. If you are here because one schema would do, use two schemas in one database and one DataSource. Reach for two databases when the data really lives in two places, such as a legacy system you must read, a reporting replica, or one database per tenant, and then treat the boundary between them as a place where consistency is your code’s job, not the framework’s.

If you do go on, write the routing test first: save through every repository and read back from the intended database with plain JDBC. The setup that starts and writes to the wrong database is the one that passes every test except that one.

Going deeper

  • Run everything yourself: the module README has the quick-start and an index of the twelve transcripts; ./scripts/run-all.sh regenerates them

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.