Add multi-datasource module: two databases with per-database DataSource, Flyway, EntityManagerFactory and transaction manager on Boot 4.1

Working setup plus one small application per way of getting it wrong (no @Primary, @Primary only, url vs jdbc-url, wrong repository package, Flyway auto-configuration), plain vs named @Transactional, and ChainedTransactionManager with a failing commit. Transcripts are written by the tests.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
This commit is contained in:
Claude
2026-09-24 09:38:27 +00:00
parent d6b76c57db
commit e59ff029a1
44 changed files with 1465 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
# multi-datasource
Companion project for the article **[Multiple DataSources in Spring Boot 4 with Spring Data JPA](https://ankurm.com/multiple-datasources-spring-boot-4-spring-data-jpa/)** on **[ankurm.com](https://ankurm.com)**.
Two H2 databases (`customers` and `orders`), each with its own `DataSource`, Flyway, `EntityManagerFactory`,
transaction manager and repository scan; plus one small application per way of getting that wrong.
Every console block quoted in the article came out of `output/`. Transcripts 01-09 are written by the test
suite (so a claim that stops being true turns the build red); 10-12 are read out of jars and `javac` by
`scripts/capture-facts.sh`.
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible "going deeper"
sections inside the article itself, next to the paragraph each one extends.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| Spring Data JPA / Commons | 4.1.1 |
| Hibernate ORM | 7.4.5.Final |
| Flyway | 12.4.0 |
| JDK | 25 (Temurin 25.0.4.1+1) |
| Maven | 3.9 |
| H2 | 2.4.240 (the version Boot 4.1.1 manages) |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn test # runs every scenario and rewrites output/01-09
./scripts/run-all.sh # everything, including 10-12
```
## Source layout
| Path | What it holds |
|---|---|
| `customers/` | `Customer`, `CustomerRepository` and `CustomersDbConfig`: the `@Primary` database |
| `orders/` | `PurchaseOrder`, `OrderRepository` and `OrdersDbConfig`: the second database |
| `service/PlacementService` | writes to both databases, then optionally throws; plain and named `@Transactional` |
| `MultiDsApp` | scans only the three packages above |
| `traps/nodefault` | two `DataSource` beans, none `@Primary` |
| `traps/primaryonly` | one `@Primary` `DataSource`, Boot's own JPA setup: it starts and uses the wrong database |
| `traps/jdbcurl` | `@ConfigurationProperties` bound straight onto a pool with a `url` key |
| `traps/builderdefaults` | what a hand-built `EntityManagerFactory` still gets from Spring Boot |
| `traps/flywayauto` | Spring Boot's Flyway auto-configuration with two databases |
| `traps/wrongpackage` | a repository whose entity is not in the factory it is scanned against |
| `src/test/.../chain/` | `ChainedTransactionManager`, and a transaction manager whose commit fails |
| `src/main/resources/db/migration/{customers,orders}/` | one Flyway location per database |
## Index of captured output
| File | Written by | What it shows |
|---|---|---|
| `01-two-databases.txt` | `TwoDatabasesTests` | pools, Flyway histories, what each factory manages, where each repository writes |
| `02-no-primary.txt` | `TrapsTests` | two `DataSource` beans without `@Primary` |
| `03-primary-only-wrong-database.txt` | `TrapsTests` | it starts, and both tables are in the customers database |
| `04-url-vs-jdbc-url.txt` | `TrapsTests` | `url` does not bind onto a pool |
| `05-builder-defaults.txt` | `TrapsTests` | naming strategy and `ddl-auto` on a hand-built factory |
| `06-flyway-auto-configuration.txt` | `TrapsTests` | three ways Flyway auto-configuration meets two databases, and which database each one migrated |
| `07-wrong-package.txt` | `TrapsTests` | `Not a managed type` |
| `08-which-transaction-manager.txt` | `TwoDatabasesTests` | plain vs named `@Transactional` |
| `09-chained-transaction-manager.txt` | `ChainedTests` | rollback, and commit failure in both list orders |
| `10-chained-transaction-manager-jar.txt` | `capture-facts.sh` | `ChainedTransactionManager` is still shipped, and `@Deprecated` |
| `11-enable-jpa-repositories-not-repeatable.txt` | `capture-facts.sh` | `javac` on two `@EnableJpaRepositories` on one class |
| `12-builder-packages.txt` | `capture-facts.sh` | `Builder.packages(Class...)` derives a package name from each class |
@@ -0,0 +1,25 @@
# Two databases, each with its own DataSource, Flyway, EntityManagerFactory and transaction manager
--- 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
@@ -0,0 +1,4 @@
# Two DataSource beans, none marked @Primary
startup failed: BeanCreationException -> NoSuchBeanDefinitionException
root cause: org.springframework.beans.factory.NoSuchBeanDefinitionException: No bean named 'entityManagerFactory' available
@@ -0,0 +1,8 @@
# One @Primary DataSource and nothing else: it starts, and it writes to the wrong database
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
@@ -0,0 +1,4 @@
# DataSourceBuilder.create().build() bound to app.datasource.customers.url
startup failed: BeanCreationException -> BeanInstantiationException -> IllegalArgumentException
root cause: java.lang.IllegalArgumentException: dataSource or dataSourceClassName or jdbcUrl is required.
@@ -0,0 +1,6 @@
# A hand-built EntityManagerFactory that sets no Hibernate properties of its own
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
@@ -0,0 +1,25 @@
# Spring Boot's Flyway auto-configuration when there are two databases
--- 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]
@@ -0,0 +1,4 @@
# A repository for Customer scanned against the orders EntityManagerFactory
startup failed: BeanCreationException -> IllegalArgumentException
root cause: java.lang.IllegalArgumentException: Not a managed type: class com.ankurm.multids.customers.Customer
@@ -0,0 +1,10 @@
# A service method that writes to both databases, then throws
--- @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
@@ -0,0 +1,13 @@
# ChainedTransactionManager over the customers and orders managers
--- 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
@@ -0,0 +1,11 @@
# Is org.springframework.data.transaction.ChainedTransactionManager still shipped, and is it deprecated?
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
@@ -0,0 +1,6 @@
# javac on a class carrying two @EnableJpaRepositories (scripts/facts/RepeatedEnable.java)
RepeatedEnable.java:7: error: EnableJpaRepositories is not a repeatable annotation interface
@EnableJpaRepositories(basePackages = "a.orders", entityManagerFactoryRef = "ordersEntityManagerFactory")
^
1 error
@@ -0,0 +1,9 @@
# What does EntityManagerFactoryBuilder.Builder.packages(Class...) do with the classes it is given?
jar: spring-boot-jpa-4.1.1.jar
--- javap -c: the calls made inside packages(java.lang.Class<?>...) ---
invokespecial #34 // Method java/util/HashSet."<init>":()V
invokestatic #35 // Method org/springframework/util/ClassUtils.getPackageName:(Ljava/lang/Class;)Ljava/lang/String;
invokeinterface #41, 2 // InterfaceMethod java/util/Set.add:(Ljava/lang/Object;)Z
invokestatic #47 // Method org/springframework/util/StringUtils.toStringArray:(Ljava/util/Collection;)[Ljava/lang/String;
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>multi-datasource</artifactId>
<version>1.0.0</version>
<name>multi-datasource</name>
<description>Two databases, two EntityManagerFactories and two transaction managers with Spring Data JPA and Flyway on Spring Boot 4</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Facts that a test cannot observe, read straight out of the jars on the classpath.
# 10-chained-transaction-manager-jar.txt is ChainedTransactionManager still shipped, and is it deprecated?
# 11-enable-jpa-repositories-not-repeatable.txt what javac says about two @EnableJpaRepositories on one class
# 12-builder-packages.txt how EntityManagerFactoryBuilder.Builder.packages(Class...) turns classes into packages
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p output target
mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/classpath.txt >/dev/null
CP="$(cat target/classpath.txt)"
commons="$(tr ':' '\n' < target/classpath.txt | grep 'spring-data-commons' | head -1)"
{
echo "# Is org.springframework.data.transaction.ChainedTransactionManager still shipped, and is it deprecated?"
echo
echo "jar: $(basename "$commons")"
echo "class: $(unzip -l "$commons" | awk '{print $4}' | grep 'transaction/ChainedTransactionManager.class')"
echo
echo "--- javap -v: class-level annotations ---"
javap -v -cp "$commons" org.springframework.data.transaction.ChainedTransactionManager \
| awk '/^RuntimeVisibleAnnotations:/{p=1} p{print} /^BootstrapMethods:/{p=0}' | sed '/^BootstrapMethods:/,$d'
echo "--- javap -v: Deprecated attribute ---"
javap -v -cp "$commons" org.springframework.data.transaction.ChainedTransactionManager | grep -E '^Deprecated:'
} > output/10-chained-transaction-manager-jar.txt
{
echo "# javac on a class carrying two @EnableJpaRepositories (scripts/facts/RepeatedEnable.java)"
echo
javac -proc:none -d target/facts -cp "$CP" scripts/facts/RepeatedEnable.java 2>&1 | grep -v "^Picked up" | sed "s|scripts/facts/||" || true
} > output/11-enable-jpa-repositories-not-repeatable.txt
boot_jpa="$(tr ':' '\n' < target/classpath.txt | grep 'spring-boot-jpa-' | head -1)"
{
echo "# What does EntityManagerFactoryBuilder.Builder.packages(Class...) do with the classes it is given?"
echo
echo "jar: $(basename "$boot_jpa")"
echo
echo "--- javap -c: the calls made inside packages(java.lang.Class<?>...) ---"
javap -c -p -cp "$boot_jpa" 'org.springframework.boot.jpa.EntityManagerFactoryBuilder$Builder' 2>&1 \
| grep -v '^Picked up' \
| awk '/packages\(java.lang.Class<\?>\.\.\.\);/{p=1} p&&/^$/{p=0} p' \
| grep -E 'invoke' | sed 's/^ *[0-9]*: *//'
} > output/12-builder-packages.txt
cat output/10-chained-transaction-manager-jar.txt output/11-enable-jpa-repositories-not-repeatable.txt output/12-builder-packages.txt
@@ -0,0 +1,9 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
// Two scans on ONE class, the natural first attempt. Does not compile: the annotation is not repeatable.
@Configuration
@EnableJpaRepositories(basePackages = "a.customers", entityManagerFactoryRef = "customersEntityManagerFactory")
@EnableJpaRepositories(basePackages = "a.orders", entityManagerFactoryRef = "ordersEntityManagerFactory")
class RepeatedEnable {
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Regenerates every file under output/.
#
# ./scripts/run-all.sh
#
# Needs a JDK 25 and Maven 3.9. Transcripts 01-09 are written by the test suite, so each figure in the
# article is an assertion that fails the build if it stops being true. 10-12 come from javap and javac.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== test suite (transcripts 01-09)"
mvn -B test
echo "== facts read from jars (10-12)"
./scripts/capture-facts.sh
echo
echo "output:"
ls -1 output
@@ -0,0 +1,14 @@
package com.ankurm.multids;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Scans only the packages that belong to the two databases and the service that uses both.
* The {@code traps} package next to it is deliberately NOT scanned: each trap is its own application.
*/
@SpringBootApplication(scanBasePackages = {
"com.ankurm.multids.customers",
"com.ankurm.multids.orders",
"com.ankurm.multids.service"})
public class MultiDsApp {
}
@@ -0,0 +1,47 @@
package com.ankurm.multids.customers;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.time.Instant;
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// No @Column: the column name comes from the naming strategy (createdAt -> created_at).
private Instant createdAt;
protected Customer() {
}
public Customer(String name, String email) {
this.name = name;
this.email = email;
this.createdAt = Instant.now();
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,6 @@
package com.ankurm.multids.customers;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CustomerRepository extends JpaRepository<Customer, Long> {
}
@@ -0,0 +1,70 @@
package com.ankurm.multids.customers;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.persistence.EntityManagerFactory;
import org.flywaydb.core.Flyway;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
import org.springframework.boot.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.util.Map;
/** Everything that belongs to the customers database: DataSource, Flyway, EntityManagerFactory, transaction manager, repositories. */
@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);
}
}
@@ -0,0 +1,6 @@
package com.ankurm.multids.orders;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository extends JpaRepository<PurchaseOrder, Long> {
}
@@ -0,0 +1,65 @@
package com.ankurm.multids.orders;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.persistence.EntityManagerFactory;
import org.flywaydb.core.Flyway;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
import org.springframework.boot.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.util.Map;
/** Everything that belongs to the orders database: DataSource, Flyway, EntityManagerFactory, transaction manager, repositories. */
@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(
basePackageClasses = OrderRepository.class,
entityManagerFactoryRef = "ordersEntityManagerFactory",
transactionManagerRef = "ordersTransactionManager")
public class OrdersDbConfig {
@Bean
@ConfigurationProperties("app.datasource.orders")
DataSourceProperties ordersDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@ConfigurationProperties("app.datasource.orders.configuration")
HikariDataSource ordersDataSource(@Qualifier("ordersDataSourceProperties") DataSourceProperties properties) {
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
@Bean(initMethod = "migrate")
Flyway ordersFlyway(@Qualifier("ordersDataSource") DataSource dataSource) {
return Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/orders")
.load();
}
@Bean
@DependsOn("ordersFlyway")
LocalContainerEntityManagerFactoryBean ordersEntityManagerFactory(
EntityManagerFactoryBuilder builder, @Qualifier("ordersDataSource") DataSource dataSource) {
return builder.dataSource(dataSource)
.packages(PurchaseOrder.class)
.persistenceUnit("orders")
.properties(Map.of("hibernate.hbm2ddl.auto", "validate"))
.build();
}
@Bean
PlatformTransactionManager ordersTransactionManager(
@Qualifier("ordersEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
}
@@ -0,0 +1,49 @@
package com.ankurm.multids.orders;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.Instant;
@Entity
@Table(name = "purchase_order")
public class PurchaseOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// A plain number, not a @ManyToOne: the customer lives in the other database.
private Long customerId;
private BigDecimal amount;
private Instant placedAt;
protected PurchaseOrder() {
}
public PurchaseOrder(Long customerId, BigDecimal amount) {
this.customerId = customerId;
this.amount = amount;
this.placedAt = Instant.now();
}
public Long getId() {
return id;
}
public Long getCustomerId() {
return customerId;
}
public BigDecimal getAmount() {
return amount;
}
public Instant getPlacedAt() {
return placedAt;
}
}
@@ -0,0 +1,43 @@
package com.ankurm.multids.service;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.customers.CustomerRepository;
import com.ankurm.multids.orders.OrderRepository;
import com.ankurm.multids.orders.PurchaseOrder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
/** Writes a customer (customers database) and an order (orders database), then optionally fails. */
@Service
public class PlacementService {
private final CustomerRepository customers;
private final OrderRepository orders;
public PlacementService(CustomerRepository customers, OrderRepository orders) {
this.customers = customers;
this.orders = orders;
}
/** 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");
}
}
}
@@ -0,0 +1,38 @@
package com.ankurm.multids.traps.builderdefaults;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.customers.CustomerRepository;
import org.flywaydb.core.Flyway;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.boot.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import javax.sql.DataSource;
/** A hand-built EntityManagerFactory that passes NO Hibernate properties of its own: what does it still get from Spring Boot? */
@SpringBootApplication
@EnableJpaRepositories(basePackageClasses = CustomerRepository.class)
public class BuilderDefaultsApp {
@Bean
@Primary
DataSource dataSource() {
return DataSourceBuilder.create().url("jdbc:h2:mem:naming").username("sa").password("").build();
}
@Bean(initMethod = "migrate")
Flyway flyway(DataSource dataSource) {
return Flyway.configure().dataSource(dataSource).locations("classpath:db/migration/customers").load();
}
@Bean
@DependsOn("flyway")
LocalContainerEntityManagerFactoryBean entityManagerFactory(EntityManagerFactoryBuilder builder, DataSource dataSource) {
return builder.dataSource(dataSource).packages(Customer.class).build();
}
}
@@ -0,0 +1,97 @@
package com.ankurm.multids.traps.flywayauto;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.customers.CustomerRepository;
import com.ankurm.multids.orders.OrderRepository;
import com.ankurm.multids.orders.PurchaseOrder;
import jakarta.persistence.EntityManagerFactory;
import org.flywaydb.core.Flyway;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.boot.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.env.Environment;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.util.Map;
/**
* Two databases, two EntityManagerFactories, and NO Flyway beans of our own: Spring Boot's Flyway
* auto-configuration is left to do what it does. Set {@code trap.flyway=orders-only} to add a Flyway
* bean for the orders database alone.
*/
@SpringBootApplication
public class FlywayAutoApp {
// @EnableJpaRepositories is not repeatable, so each scan needs a configuration class of its own.
@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(basePackageClasses = CustomerRepository.class,
entityManagerFactoryRef = "customersEntityManagerFactory", transactionManagerRef = "customersTransactionManager")
static class CustomersRepositories {
}
@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(basePackageClasses = OrderRepository.class,
entityManagerFactoryRef = "ordersEntityManagerFactory", transactionManagerRef = "ordersTransactionManager")
static class OrdersRepositories {
}
/** "validate" by default; trap.ddl=none lets the application start so the databases can be inspected. */
private static Map<String, String> ddl(Environment env) {
return Map.of("hibernate.hbm2ddl.auto", env.getProperty("trap.ddl", "validate"));
}
@Bean
@Primary
DataSource customersDataSource() {
return DataSourceBuilder.create().url("jdbc:h2:mem:fa-customers").username("sa").password("").build();
}
@Bean
DataSource ordersDataSource() {
return DataSourceBuilder.create().url("jdbc:h2:mem:fa-orders").username("sa").password("").build();
}
@Bean
@Primary
LocalContainerEntityManagerFactoryBean customersEntityManagerFactory(
EntityManagerFactoryBuilder builder, @Qualifier("customersDataSource") DataSource dataSource, Environment env) {
return builder.dataSource(dataSource).packages(Customer.class).persistenceUnit("customers")
.properties(ddl(env)).build();
}
@Bean
LocalContainerEntityManagerFactoryBean ordersEntityManagerFactory(
EntityManagerFactoryBuilder builder, @Qualifier("ordersDataSource") DataSource dataSource, Environment env) {
return builder.dataSource(dataSource).packages(PurchaseOrder.class).persistenceUnit("orders")
.properties(ddl(env)).build();
}
@Bean
@Primary
PlatformTransactionManager customersTransactionManager(
@Qualifier("customersEntityManagerFactory") EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
@Bean
PlatformTransactionManager ordersTransactionManager(
@Qualifier("ordersEntityManagerFactory") EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
/** Only when trap.flyway=orders-only: one hand-made Flyway bean, for the orders database. */
@Bean(initMethod = "migrate")
@ConditionalOnProperty(name = "trap.flyway", havingValue = "orders-only")
Flyway ordersFlyway(@Qualifier("ordersDataSource") DataSource dataSource) {
return Flyway.configure().dataSource(dataSource).locations("classpath:db/migration/orders").load();
}
}
@@ -0,0 +1,19 @@
package com.ankurm.multids.traps.jdbcurl;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import javax.sql.DataSource;
/** The copy-paste pattern: bind app.datasource.customers.* straight onto the pool. */
@SpringBootApplication
public class JdbcUrlApp {
@Bean
@ConfigurationProperties("app.datasource.customers")
DataSource customersDataSource() {
return DataSourceBuilder.create().build();
}
}
@@ -0,0 +1,25 @@
package com.ankurm.multids.traps.nodefault;
import com.ankurm.multids.customers.CustomerRepository;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import javax.sql.DataSource;
/** Two DataSource beans, none marked @Primary, and Spring Boot's own JPA setup expected to cope. */
@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();
}
}
@@ -0,0 +1,33 @@
package com.ankurm.multids.traps.primaryonly;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.orders.PurchaseOrder;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import javax.sql.DataSource;
/**
* The "it works" trap: one DataSource is @Primary, so Spring Boot builds ONE EntityManagerFactory on it,
* and both entities and both repositories end up there. The second DataSource is never used.
*/
@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();
}
}
@@ -0,0 +1,7 @@
package com.ankurm.multids.traps.wrongpackage;
import com.ankurm.multids.customers.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CustomerInOrdersRepository extends JpaRepository<Customer, Long> {
}
@@ -0,0 +1,14 @@
package com.ankurm.multids.traps.wrongpackage;
import com.ankurm.multids.orders.OrdersDbConfig;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/** The orders configuration, plus a second repository scan that points a CUSTOMER repository at the ORDERS factory. */
@SpringBootApplication
@Import(OrdersDbConfig.class)
@EnableJpaRepositories(basePackageClasses = CustomerInOrdersRepository.class,
entityManagerFactoryRef = "ordersEntityManagerFactory", transactionManagerRef = "ordersTransactionManager")
public class WrongPackageApp {
}
@@ -0,0 +1,20 @@
spring.application.name=multi-datasource
spring.main.banner-mode=off
# 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
logging.level.org.flywaydb=WARN
logging.level.org.hibernate=WARN
@@ -0,0 +1,6 @@
create table customer (
id bigint generated by default as identity primary key,
name varchar(100) not null,
email varchar(200) not null,
created_at timestamp not null
);
@@ -0,0 +1,7 @@
-- 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
);
@@ -0,0 +1 @@
create index ix_purchase_order_customer on purchase_order (customer_id);
@@ -0,0 +1,60 @@
package com.ankurm.multids;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
/** Starts a Spring Boot context without a web server and returns either the context or the failure. */
public final class BootRun {
private BootRun() {
}
public static Result run(String[] extraProperties, Class<?>... sources) {
var props = new java.util.ArrayList<String>();
props.add("spring.main.banner-mode=off");
props.add("logging.level.root=OFF");
props.addAll(java.util.List.of(extraProperties));
try {
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(sources)
.web(WebApplicationType.NONE)
.properties(props.toArray(String[]::new))
.run();
return new Result(ctx, null);
} catch (RuntimeException e) {
return new Result(null, e);
}
}
/** Names the exception classes from the outside in: what a stack trace's "Caused by:" lines say, minus the noise. */
public static String chain(Throwable t) {
var names = new java.util.ArrayList<String>();
for (Throwable c = t; c != null && !names.contains(c.getClass().getSimpleName() + c.hashCode()); c = c.getCause()) {
names.add(c.getClass().getSimpleName());
if (c.getCause() == c) {
break;
}
}
return String.join(" -> ", names);
}
public static Throwable root(Throwable t) {
while (t.getCause() != null && t.getCause() != t) {
t = t.getCause();
}
return t;
}
public record Result(ConfigurableApplicationContext context, RuntimeException failure) {
public boolean started() {
return failure == null;
}
public void close() {
if (context != null) {
context.close();
}
}
}
}
@@ -0,0 +1,67 @@
package com.ankurm.multids;
import com.ankurm.multids.chain.ChainedApp;
import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.transaction.HeuristicCompletionException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** Transcript 09: ChainedTransactionManager, on the happy path and when a COMMIT fails. */
class ChainedTests {
@Test
void chainedTransactionManager() {
try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(ChainedApp.class)
.web(WebApplicationType.NONE).properties("logging.level.root=OFF").run();
var t = new Transcript("09-chained-transaction-manager.txt",
"ChainedTransactionManager over the customers and orders managers")) {
var service = ctx.getBean(ChainedApp.ChainedPlacement.class);
var customersDb = Jdbc.on(ctx, "customersDataSource");
var ordersDb = Jdbc.on(ctx, "ordersDataSource");
t.section("1. business exception after both writes: both roll back");
assertThatThrownBy(() -> service.placeThenFail("Asha")).isInstanceOf(IllegalStateException.class);
t.line("rows: customers.customer=%d orders.purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
assertThat(Jdbc.count(customersDb, "customer")).isZero();
assertThat(Jdbc.count(ordersDb, "purchase_order")).isZero();
t.section("2. the orders database refuses to commit; orders is LAST in the list");
Throwable last = catchThrowable(() -> service.placeWhenOrdersCommitsLast("Bela"));
t.line("thrown: %s", describe(last));
t.line("rows: customers.customer=%d orders.purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
int customersAfterLast = Jdbc.count(customersDb, "customer");
customersDb.update("delete from customer");
ordersDb.update("delete from purchase_order");
t.section("3. the orders database refuses to commit; orders is FIRST in the list");
Throwable first = catchThrowable(() -> service.placeWhenOrdersCommitsFirst("Chitra"));
t.line("thrown: %s", describe(first));
t.line("rows: customers.customer=%d orders.purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
int customersAfterFirst = Jdbc.count(customersDb, "customer");
// The point of the section: the outcome depends on the ORDER of the list.
assertThat(customersAfterLast).isNotEqualTo(customersAfterFirst);
}
}
private static String describe(Throwable e) {
var h = (HeuristicCompletionException) e;
return e.getClass().getName() + " (outcome: " + HeuristicCompletionException.getStateString(h.getOutcomeState()) + ")";
}
private static Throwable catchThrowable(Runnable r) {
try {
r.run();
} catch (Throwable e) {
return e;
}
throw new AssertionError("expected the commit to fail");
}
}
@@ -0,0 +1,32 @@
package com.ankurm.multids;
import org.springframework.context.ApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
import java.util.List;
/** Tiny helpers: ask a named DataSource a question with plain JDBC, bypassing JPA entirely. */
public final class Jdbc {
private Jdbc() {
}
public static JdbcTemplate on(ApplicationContext ctx, String dataSourceBean) {
return new JdbcTemplate(ctx.getBean(dataSourceBean, DataSource.class));
}
public static int count(JdbcTemplate jdbc, String table) {
return jdbc.queryForObject("select count(*) from " + table, Integer.class);
}
public static List<String> tables(JdbcTemplate jdbc) {
return jdbc.queryForList(
"select table_name from information_schema.tables where table_schema = 'PUBLIC' order by table_name", String.class);
}
public static List<String> migrations(JdbcTemplate jdbc) {
return jdbc.queryForList(
"select \"version\" || ' ' || \"description\" from \"flyway_schema_history\" where \"version\" is not null order by \"installed_rank\"", String.class);
}
}
@@ -0,0 +1,54 @@
package com.ankurm.multids;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code output/} and echoes it to the console.
* Every console block quoted in the article comes out of one of these files verbatim.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
public Transcript section(String heading) {
out.println();
out.println("--- " + heading + " ---");
return this;
}
@Override
public void close() {
out.flush();
// Absolute paths of whoever ran the build are environment noise, not a finding.
String text = buffer.toString().replace(System.getProperty("user.dir"), "<multi-datasource>");
try {
Files.createDirectories(path.getParent());
Files.writeString(path, text);
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(text);
}
}
@@ -0,0 +1,147 @@
package com.ankurm.multids;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.customers.CustomerRepository;
import com.ankurm.multids.orders.OrderRepository;
import com.ankurm.multids.orders.PurchaseOrder;
import com.ankurm.multids.traps.builderdefaults.BuilderDefaultsApp;
import com.ankurm.multids.traps.flywayauto.FlywayAutoApp;
import com.ankurm.multids.traps.jdbcurl.JdbcUrlApp;
import com.ankurm.multids.traps.nodefault.NoDefaultApp;
import com.ankurm.multids.traps.primaryonly.PrimaryOnlyApp;
import com.ankurm.multids.traps.wrongpackage.WrongPackageApp;
import jakarta.persistence.EntityManagerFactory;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/** Transcripts 02-07: every way of getting the setup wrong that the article describes, run for real. */
class TrapsTests {
private static void failure(Transcript t, BootRun.Result r) {
assertThat(r.started()).as("the application should have failed to start").isFalse();
t.line("startup failed: %s", BootRun.chain(r.failure()));
t.line("root cause: %s: %s", BootRun.root(r.failure()).getClass().getName(), BootRun.root(r.failure()).getMessage());
}
@Test
void twoDataSourcesAndNoPrimary() {
try (var t = new Transcript("02-no-primary.txt", "Two DataSource beans, none marked @Primary")) {
var r = BootRun.run(new String[0], NoDefaultApp.class);
failure(t, r);
assertThat(BootRun.root(r.failure()).getMessage()).contains("No bean named 'entityManagerFactory'");
}
}
@Test
void oneDataSourcePrimaryAndTheOtherSilentlyUnused() {
try (var t = new Transcript("03-primary-only-wrong-database.txt",
"One @Primary DataSource and nothing else: it starts, and it writes to the wrong database")) {
var r = BootRun.run(new String[]{"spring.flyway.enabled=false"}, PrimaryOnlyApp.class);
assertThat(r.started()).isTrue();
var ctx = r.context();
try {
var customer = ctx.getBean(CustomerRepository.class).save(new Customer("Asha", "[email protected]"));
ctx.getBean(OrderRepository.class).save(new PurchaseOrder(customer.getId(), new BigDecimal("499.00")));
var customersDb = Jdbc.on(ctx, "customersDataSource");
var ordersDb = Jdbc.on(ctx, "ordersDataSource");
t.line("application started: yes");
t.line("EntityManagerFactory beans: %s", List.of(ctx.getBeanNamesForType(EntityManagerFactory.class)));
t.line("customers database tables: %s", Jdbc.tables(customersDb));
t.line("orders database tables: %s", Jdbc.tables(ordersDb));
t.line("rows in the customers database: customer=%d purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(customersDb, "purchase_order"));
assertThat(Jdbc.tables(ordersDb)).isEmpty();
assertThat(Jdbc.count(customersDb, "purchase_order")).isEqualTo(1);
var emf = ctx.getBean(EntityManagerFactory.class);
t.line("hibernate.hbm2ddl.auto on that factory: %s", emf.getProperties().get("hibernate.hbm2ddl.auto"));
} finally {
r.close();
}
}
}
@Test
void urlDoesNotBindOntoAPool() {
try (var t = new Transcript("04-url-vs-jdbc-url.txt",
"DataSourceBuilder.create().build() bound to app.datasource.customers.url")) {
var r = BootRun.run(new String[]{"spring.flyway.enabled=false"}, JdbcUrlApp.class);
failure(t, r);
assertThat(BootRun.root(r.failure()).getMessage()).contains("jdbcUrl is required");
}
}
@Test
void whatTheBuilderAlreadyGivesAHandBuiltFactory() {
try (var t = new Transcript("05-builder-defaults.txt",
"A hand-built EntityManagerFactory that sets no Hibernate properties of its own")) {
var r = BootRun.run(new String[0], BuilderDefaultsApp.class);
assertThat(r.started()).isTrue();
try {
var props = r.context().getBean(EntityManagerFactory.class).getProperties();
t.line("hibernate.physical_naming_strategy = %s", props.get("hibernate.physical_naming_strategy"));
t.line("hibernate.implicit_naming_strategy = %s", props.get("hibernate.implicit_naming_strategy"));
t.line("hibernate.hbm2ddl.auto = %s", props.get("hibernate.hbm2ddl.auto"));
var saved = r.context().getBean(CustomerRepository.class).save(new Customer("Asha", "[email protected]"));
t.line("saved a Customer whose field is createdAt into a column created_at: id=%d", saved.getId());
assertThat(props.get("hibernate.physical_naming_strategy").toString()).contains("SnakeCase");
assertThat(props.get("hibernate.hbm2ddl.auto")).isNull();
} finally {
r.close();
}
}
}
private static void whatEachDatabaseHolds(Transcript t, BootRun.Result r) {
assertThat(r.started()).isTrue();
try {
t.line("application started (Hibernate validation switched off so the databases can be inspected)");
t.line("customers database tables: %s", Jdbc.tables(Jdbc.on(r.context(), "customersDataSource")));
t.line("orders database tables: %s", Jdbc.tables(Jdbc.on(r.context(), "ordersDataSource")));
} finally {
r.close();
}
}
@Test
void flywayAutoConfigurationWithTwoDatabases() {
try (var t = new Transcript("06-flyway-auto-configuration.txt",
"Spring Boot's Flyway auto-configuration when there are two databases")) {
String customersOnly = "spring.flyway.locations=classpath:db/migration/customers";
t.section("A. nothing configured: Flyway targets the @Primary DataSource and scans classpath:db/migration");
var a = BootRun.run(new String[0], FlywayAutoApp.class);
failure(t, a);
assertThat(BootRun.root(a.failure()).getMessage()).contains("Found more than one migration with version 1");
t.section("B. spring.flyway.locations=classpath:db/migration/customers");
var b = BootRun.run(new String[]{customersOnly}, FlywayAutoApp.class);
failure(t, b);
assertThat(BootRun.root(b.failure()).getMessage()).contains("missing table [purchase_order]");
t.blank();
var b2 = BootRun.run(new String[]{customersOnly, "trap.ddl=none"}, FlywayAutoApp.class);
whatEachDatabaseHolds(t, b2);
t.section("C. as B, plus one hand-made Flyway bean for the orders database");
var c = BootRun.run(new String[]{customersOnly, "trap.flyway=orders-only"}, FlywayAutoApp.class);
failure(t, c);
assertThat(BootRun.root(c.failure()).getMessage()).contains("missing table [customer]");
t.blank();
var c2 = BootRun.run(new String[]{customersOnly, "trap.flyway=orders-only", "trap.ddl=none"}, FlywayAutoApp.class);
whatEachDatabaseHolds(t, c2);
}
}
@Test
void aRepositoryInThePackageOfTheWrongFactory() {
try (var t = new Transcript("07-wrong-package.txt",
"A repository for Customer scanned against the orders EntityManagerFactory")) {
var r = BootRun.run(new String[0], WrongPackageApp.class);
failure(t, r);
assertThat(BootRun.root(r.failure()).getMessage()).contains("Not a managed type");
}
}
}
@@ -0,0 +1,114 @@
package com.ankurm.multids;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.customers.CustomerRepository;
import com.ankurm.multids.orders.OrderRepository;
import com.ankurm.multids.orders.PurchaseOrder;
import com.ankurm.multids.service.PlacementService;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.metamodel.EntityType;
import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.transaction.PlatformTransactionManager;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** Transcripts 01 (the working setup) and 08 (which transaction manager a plain @Transactional gets). */
class TwoDatabasesTests {
private static ConfigurableApplicationContext start() {
return new SpringApplicationBuilder(MultiDsApp.class)
.web(WebApplicationType.NONE)
.properties("logging.level.root=WARN")
.run();
}
@Test
void theWorkingSetup() {
try (ConfigurableApplicationContext ctx = start(); var t = new Transcript("01-two-databases.txt",
"Two databases, each with its own DataSource, Flyway, EntityManagerFactory and transaction manager")) {
t.section("the two DataSources (one pool each, configured separately)");
for (String name : List.of("customersDataSource", "ordersDataSource")) {
var pool = ctx.getBean(name, HikariDataSource.class);
t.line("%-20s pool=%-15s max=%d url=%s primary=%s", name, pool.getPoolName(),
pool.getMaximumPoolSize(), pool.getJdbcUrl(), ctx.getBeanFactory().getBeanDefinition(name).isPrimary());
}
t.section("Flyway ran once per database, each with its own history");
var customersDb = Jdbc.on(ctx, "customersDataSource");
var ordersDb = Jdbc.on(ctx, "ordersDataSource");
t.line("customers flyway_schema_history: %s", Jdbc.migrations(customersDb));
t.line("orders flyway_schema_history: %s", Jdbc.migrations(ordersDb));
assertThat(Jdbc.migrations(customersDb)).hasSize(1);
assertThat(Jdbc.migrations(ordersDb)).hasSize(2);
t.section("what each EntityManagerFactory manages");
t.line("EntityManagerFactory beans: %s", Arrays.stream(ctx.getBeanNamesForType(EntityManagerFactory.class)).sorted().toList());
for (String name : List.of("customersEntityManagerFactory", "ordersEntityManagerFactory")) {
var emf = ctx.getBean(name, EntityManagerFactory.class);
t.line("%-32s entities=%s", name,
emf.getMetamodel().getEntities().stream().map(EntityType::getName).toList());
}
t.section("transaction managers");
t.line("beans: %s", Arrays.stream(ctx.getBeanNamesForType(PlatformTransactionManager.class)).sorted().toList());
for (String name : List.of("customersTransactionManager", "ordersTransactionManager")) {
t.line("%-30s primary=%s", name, ctx.getBeanFactory().getBeanDefinition(name).isPrimary());
}
t.section("save one customer and one order through the two repositories");
var customer = ctx.getBean(CustomerRepository.class).save(
new Customer("Asha", "[email protected]"));
ctx.getBean(OrderRepository.class).save(
new PurchaseOrder(customer.getId(), new BigDecimal("499.00")));
t.line("customers database tables: %s", Jdbc.tables(customersDb));
t.line("orders database tables: %s", Jdbc.tables(ordersDb));
t.line("rows: customers.customer=%d orders.purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
assertThat(Jdbc.tables(customersDb)).doesNotContain("PURCHASE_ORDER");
assertThat(Jdbc.tables(ordersDb)).doesNotContain("CUSTOMER");
assertThat(Jdbc.count(customersDb, "customer")).isEqualTo(1);
assertThat(Jdbc.count(ordersDb, "purchase_order")).isEqualTo(1);
}
}
@Test
void plainTransactionalGetsThePrimaryManager() {
try (ConfigurableApplicationContext ctx = start(); var t = new Transcript("08-which-transaction-manager.txt",
"A service method that writes to both databases, then throws")) {
var service = ctx.getBean(PlacementService.class);
var customersDb = Jdbc.on(ctx, "customersDataSource");
var ordersDb = Jdbc.on(ctx, "ordersDataSource");
t.section("@Transactional (no name): Spring picks the @Primary manager, customersTransactionManager");
assertThatThrownBy(() -> service.placeWithDefaultTransaction("Asha", new BigDecimal("10"), true))
.isInstanceOf(IllegalStateException.class);
t.line("thrown: java.lang.IllegalStateException: boom after both writes");
t.line("rows after the rollback: customers.customer=%d orders.purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
assertThat(Jdbc.count(customersDb, "customer")).isZero();
assertThat(Jdbc.count(ordersDb, "purchase_order")).isEqualTo(1);
customersDb.update("delete from customer");
ordersDb.update("delete from purchase_order");
t.section("@Transactional(\"ordersTransactionManager\"): the other database is now the one that rolls back");
assertThatThrownBy(() -> service.placeWithOrdersTransaction("Bela", new BigDecimal("10"), true))
.isInstanceOf(IllegalStateException.class);
t.line("thrown: java.lang.IllegalStateException: boom after both writes");
t.line("rows after the rollback: customers.customer=%d orders.purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
assertThat(Jdbc.count(customersDb, "customer")).isEqualTo(1);
assertThat(Jdbc.count(ordersDb, "purchase_order")).isZero();
}
}
}
@@ -0,0 +1,87 @@
package com.ankurm.multids.chain;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.customers.CustomerRepository;
import com.ankurm.multids.customers.CustomersDbConfig;
import com.ankurm.multids.orders.OrderRepository;
import com.ankurm.multids.orders.OrdersDbConfig;
import com.ankurm.multids.orders.PurchaseOrder;
import jakarta.persistence.EntityManagerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.data.transaction.ChainedTransactionManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
/** The two real configurations, plus three "chained" transaction managers and a service that uses them. */
@SpringBootApplication
@Import({CustomersDbConfig.class, OrdersDbConfig.class})
public class ChainedApp {
/** customers, then orders: the list ChainedTransactionManager is given. */
@Bean
@SuppressWarnings("deprecation")
PlatformTransactionManager chained(@Qualifier("customersTransactionManager") PlatformTransactionManager customers,
@Qualifier("ordersTransactionManager") PlatformTransactionManager orders) {
return new ChainedTransactionManager(customers, orders);
}
@Bean
PlatformTransactionManager failingOrders(@Qualifier("ordersEntityManagerFactory") EntityManagerFactory emf) {
return new CommitFailingTransactionManager(emf);
}
/** The orders side is listed FIRST and refuses to commit. */
@Bean
@SuppressWarnings("deprecation")
PlatformTransactionManager chainOrdersFirst(@Qualifier("failingOrders") PlatformTransactionManager failingOrders,
@Qualifier("customersTransactionManager") PlatformTransactionManager customers) {
return new ChainedTransactionManager(failingOrders, customers);
}
/** The orders side is listed LAST and refuses to commit. */
@Bean
@SuppressWarnings("deprecation")
PlatformTransactionManager chainOrdersLast(@Qualifier("customersTransactionManager") PlatformTransactionManager customers,
@Qualifier("failingOrders") PlatformTransactionManager failingOrders) {
return new ChainedTransactionManager(customers, failingOrders);
}
@Service
public static class ChainedPlacement {
private final CustomerRepository customers;
private final OrderRepository orders;
ChainedPlacement(CustomerRepository customers, OrderRepository orders) {
this.customers = customers;
this.orders = orders;
}
@Transactional("chained")
public void placeThenFail(String name) {
write(name);
throw new IllegalStateException("boom after both writes");
}
@Transactional("chainOrdersFirst")
public void placeWhenOrdersCommitsFirst(String name) {
write(name);
}
@Transactional("chainOrdersLast")
public void placeWhenOrdersCommitsLast(String name) {
write(name);
}
private void write(String name) {
Customer customer = customers.save(new Customer(name, name.toLowerCase() + "@example.com"));
orders.save(new PurchaseOrder(customer.getId(), new BigDecimal("10.00")));
}
}
}
@@ -0,0 +1,19 @@
package com.ankurm.multids.chain;
import jakarta.persistence.EntityManagerFactory;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.transaction.TransactionSystemException;
import org.springframework.transaction.support.DefaultTransactionStatus;
/** Stands in for "the database refused the COMMIT": a real JpaTransactionManager whose commit always throws. */
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");
}
}