diff --git a/multi-datasource/README.md b/multi-datasource/README.md new file mode 100644 index 0000000..eb8e8be --- /dev/null +++ b/multi-datasource/README.md @@ -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 | diff --git a/multi-datasource/output/01-two-databases.txt b/multi-datasource/output/01-two-databases.txt new file mode 100644 index 0000000..a272f73 --- /dev/null +++ b/multi-datasource/output/01-two-databases.txt @@ -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 diff --git a/multi-datasource/output/02-no-primary.txt b/multi-datasource/output/02-no-primary.txt new file mode 100644 index 0000000..cfe077f --- /dev/null +++ b/multi-datasource/output/02-no-primary.txt @@ -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 diff --git a/multi-datasource/output/03-primary-only-wrong-database.txt b/multi-datasource/output/03-primary-only-wrong-database.txt new file mode 100644 index 0000000..730282c --- /dev/null +++ b/multi-datasource/output/03-primary-only-wrong-database.txt @@ -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 diff --git a/multi-datasource/output/04-url-vs-jdbc-url.txt b/multi-datasource/output/04-url-vs-jdbc-url.txt new file mode 100644 index 0000000..6a5695b --- /dev/null +++ b/multi-datasource/output/04-url-vs-jdbc-url.txt @@ -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. diff --git a/multi-datasource/output/05-builder-defaults.txt b/multi-datasource/output/05-builder-defaults.txt new file mode 100644 index 0000000..998a0c6 --- /dev/null +++ b/multi-datasource/output/05-builder-defaults.txt @@ -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 diff --git a/multi-datasource/output/06-flyway-auto-configuration.txt b/multi-datasource/output/06-flyway-auto-configuration.txt new file mode 100644 index 0000000..80254c6 --- /dev/null +++ b/multi-datasource/output/06-flyway-auto-configuration.txt @@ -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: +-> /target/classes/db/migration/customers/V1__customer.sql (SQL) +-> /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] diff --git a/multi-datasource/output/07-wrong-package.txt b/multi-datasource/output/07-wrong-package.txt new file mode 100644 index 0000000..e00494b --- /dev/null +++ b/multi-datasource/output/07-wrong-package.txt @@ -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 diff --git a/multi-datasource/output/08-which-transaction-manager.txt b/multi-datasource/output/08-which-transaction-manager.txt new file mode 100644 index 0000000..333ee24 --- /dev/null +++ b/multi-datasource/output/08-which-transaction-manager.txt @@ -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 diff --git a/multi-datasource/output/09-chained-transaction-manager.txt b/multi-datasource/output/09-chained-transaction-manager.txt new file mode 100644 index 0000000..819480e --- /dev/null +++ b/multi-datasource/output/09-chained-transaction-manager.txt @@ -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 diff --git a/multi-datasource/output/10-chained-transaction-manager-jar.txt b/multi-datasource/output/10-chained-transaction-manager-jar.txt new file mode 100644 index 0000000..f7e1287 --- /dev/null +++ b/multi-datasource/output/10-chained-transaction-manager-jar.txt @@ -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 diff --git a/multi-datasource/output/11-enable-jpa-repositories-not-repeatable.txt b/multi-datasource/output/11-enable-jpa-repositories-not-repeatable.txt new file mode 100644 index 0000000..6983027 --- /dev/null +++ b/multi-datasource/output/11-enable-jpa-repositories-not-repeatable.txt @@ -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 diff --git a/multi-datasource/output/12-builder-packages.txt b/multi-datasource/output/12-builder-packages.txt new file mode 100644 index 0000000..bd25e9d --- /dev/null +++ b/multi-datasource/output/12-builder-packages.txt @@ -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."":()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; diff --git a/multi-datasource/pom.xml b/multi-datasource/pom.xml new file mode 100644 index 0000000..56d1e1c --- /dev/null +++ b/multi-datasource/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + multi-datasource + 1.0.0 + multi-datasource + Two databases, two EntityManagerFactories and two transaction managers with Spring Data JPA and Flyway on Spring Boot 4 + + + 25 + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-flyway + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/multi-datasource/scripts/capture-facts.sh b/multi-datasource/scripts/capture-facts.sh new file mode 100755 index 0000000..c912e87 --- /dev/null +++ b/multi-datasource/scripts/capture-facts.sh @@ -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 diff --git a/multi-datasource/scripts/facts/RepeatedEnable.java b/multi-datasource/scripts/facts/RepeatedEnable.java new file mode 100644 index 0000000..909d1d7 --- /dev/null +++ b/multi-datasource/scripts/facts/RepeatedEnable.java @@ -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 { +} diff --git a/multi-datasource/scripts/run-all.sh b/multi-datasource/scripts/run-all.sh new file mode 100755 index 0000000..d261770 --- /dev/null +++ b/multi-datasource/scripts/run-all.sh @@ -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 diff --git a/multi-datasource/src/main/java/com/ankurm/multids/MultiDsApp.java b/multi-datasource/src/main/java/com/ankurm/multids/MultiDsApp.java new file mode 100644 index 0000000..066f0a7 --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/MultiDsApp.java @@ -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 { +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/customers/Customer.java b/multi-datasource/src/main/java/com/ankurm/multids/customers/Customer.java new file mode 100644 index 0000000..dbc91a8 --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/customers/Customer.java @@ -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; + } +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/customers/CustomerRepository.java b/multi-datasource/src/main/java/com/ankurm/multids/customers/CustomerRepository.java new file mode 100644 index 0000000..5e1ca5b --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/customers/CustomerRepository.java @@ -0,0 +1,6 @@ +package com.ankurm.multids.customers; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CustomerRepository extends JpaRepository { +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/customers/CustomersDbConfig.java b/multi-datasource/src/main/java/com/ankurm/multids/customers/CustomersDbConfig.java new file mode 100644 index 0000000..00d36c6 --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/customers/CustomersDbConfig.java @@ -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); + } +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/orders/OrderRepository.java b/multi-datasource/src/main/java/com/ankurm/multids/orders/OrderRepository.java new file mode 100644 index 0000000..c551f11 --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/orders/OrderRepository.java @@ -0,0 +1,6 @@ +package com.ankurm.multids.orders; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderRepository extends JpaRepository { +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/orders/OrdersDbConfig.java b/multi-datasource/src/main/java/com/ankurm/multids/orders/OrdersDbConfig.java new file mode 100644 index 0000000..b680ff8 --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/orders/OrdersDbConfig.java @@ -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); + } +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/orders/PurchaseOrder.java b/multi-datasource/src/main/java/com/ankurm/multids/orders/PurchaseOrder.java new file mode 100644 index 0000000..9111f3f --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/orders/PurchaseOrder.java @@ -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; + } +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/service/PlacementService.java b/multi-datasource/src/main/java/com/ankurm/multids/service/PlacementService.java new file mode 100644 index 0000000..2b61e89 --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/service/PlacementService.java @@ -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"); + } + } +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/traps/builderdefaults/BuilderDefaultsApp.java b/multi-datasource/src/main/java/com/ankurm/multids/traps/builderdefaults/BuilderDefaultsApp.java new file mode 100644 index 0000000..28961df --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/traps/builderdefaults/BuilderDefaultsApp.java @@ -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(); + } +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/traps/flywayauto/FlywayAutoApp.java b/multi-datasource/src/main/java/com/ankurm/multids/traps/flywayauto/FlywayAutoApp.java new file mode 100644 index 0000000..31641bf --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/traps/flywayauto/FlywayAutoApp.java @@ -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 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(); + } +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/traps/jdbcurl/JdbcUrlApp.java b/multi-datasource/src/main/java/com/ankurm/multids/traps/jdbcurl/JdbcUrlApp.java new file mode 100644 index 0000000..2eb90a4 --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/traps/jdbcurl/JdbcUrlApp.java @@ -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(); + } +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/traps/nodefault/NoDefaultApp.java b/multi-datasource/src/main/java/com/ankurm/multids/traps/nodefault/NoDefaultApp.java new file mode 100644 index 0000000..b3bbf03 --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/traps/nodefault/NoDefaultApp.java @@ -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(); + } +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/traps/primaryonly/PrimaryOnlyApp.java b/multi-datasource/src/main/java/com/ankurm/multids/traps/primaryonly/PrimaryOnlyApp.java new file mode 100644 index 0000000..eca34c0 --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/traps/primaryonly/PrimaryOnlyApp.java @@ -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(); + } +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/traps/wrongpackage/CustomerInOrdersRepository.java b/multi-datasource/src/main/java/com/ankurm/multids/traps/wrongpackage/CustomerInOrdersRepository.java new file mode 100644 index 0000000..79e2208 --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/traps/wrongpackage/CustomerInOrdersRepository.java @@ -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 { +} diff --git a/multi-datasource/src/main/java/com/ankurm/multids/traps/wrongpackage/WrongPackageApp.java b/multi-datasource/src/main/java/com/ankurm/multids/traps/wrongpackage/WrongPackageApp.java new file mode 100644 index 0000000..e267302 --- /dev/null +++ b/multi-datasource/src/main/java/com/ankurm/multids/traps/wrongpackage/WrongPackageApp.java @@ -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 { +} diff --git a/multi-datasource/src/main/resources/application.properties b/multi-datasource/src/main/resources/application.properties new file mode 100644 index 0000000..fc37316 --- /dev/null +++ b/multi-datasource/src/main/resources/application.properties @@ -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 diff --git a/multi-datasource/src/main/resources/db/migration/customers/V1__customer.sql b/multi-datasource/src/main/resources/db/migration/customers/V1__customer.sql new file mode 100644 index 0000000..eea133d --- /dev/null +++ b/multi-datasource/src/main/resources/db/migration/customers/V1__customer.sql @@ -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 +); diff --git a/multi-datasource/src/main/resources/db/migration/orders/V1__purchase_order.sql b/multi-datasource/src/main/resources/db/migration/orders/V1__purchase_order.sql new file mode 100644 index 0000000..5d728eb --- /dev/null +++ b/multi-datasource/src/main/resources/db/migration/orders/V1__purchase_order.sql @@ -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 +); diff --git a/multi-datasource/src/main/resources/db/migration/orders/V2__index_customer.sql b/multi-datasource/src/main/resources/db/migration/orders/V2__index_customer.sql new file mode 100644 index 0000000..7b4fbf0 --- /dev/null +++ b/multi-datasource/src/main/resources/db/migration/orders/V2__index_customer.sql @@ -0,0 +1 @@ +create index ix_purchase_order_customer on purchase_order (customer_id); diff --git a/multi-datasource/src/test/java/com/ankurm/multids/BootRun.java b/multi-datasource/src/test/java/com/ankurm/multids/BootRun.java new file mode 100644 index 0000000..dca6c87 --- /dev/null +++ b/multi-datasource/src/test/java/com/ankurm/multids/BootRun.java @@ -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(); + 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(); + 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(); + } + } + } +} diff --git a/multi-datasource/src/test/java/com/ankurm/multids/ChainedTests.java b/multi-datasource/src/test/java/com/ankurm/multids/ChainedTests.java new file mode 100644 index 0000000..8ac7fab --- /dev/null +++ b/multi-datasource/src/test/java/com/ankurm/multids/ChainedTests.java @@ -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"); + } +} diff --git a/multi-datasource/src/test/java/com/ankurm/multids/Jdbc.java b/multi-datasource/src/test/java/com/ankurm/multids/Jdbc.java new file mode 100644 index 0000000..67d44be --- /dev/null +++ b/multi-datasource/src/test/java/com/ankurm/multids/Jdbc.java @@ -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 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 migrations(JdbcTemplate jdbc) { + return jdbc.queryForList( + "select \"version\" || ' ' || \"description\" from \"flyway_schema_history\" where \"version\" is not null order by \"installed_rank\"", String.class); + } +} diff --git a/multi-datasource/src/test/java/com/ankurm/multids/Transcript.java b/multi-datasource/src/test/java/com/ankurm/multids/Transcript.java new file mode 100644 index 0000000..9f9c392 --- /dev/null +++ b/multi-datasource/src/test/java/com/ankurm/multids/Transcript.java @@ -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"), ""); + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, text); + } catch (IOException e) { + throw new IllegalStateException("could not write " + path, e); + } + System.out.print(text); + } +} diff --git a/multi-datasource/src/test/java/com/ankurm/multids/TrapsTests.java b/multi-datasource/src/test/java/com/ankurm/multids/TrapsTests.java new file mode 100644 index 0000000..f6afc68 --- /dev/null +++ b/multi-datasource/src/test/java/com/ankurm/multids/TrapsTests.java @@ -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", "asha@example.com")); + 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", "asha@example.com")); + 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"); + } + } +} diff --git a/multi-datasource/src/test/java/com/ankurm/multids/TwoDatabasesTests.java b/multi-datasource/src/test/java/com/ankurm/multids/TwoDatabasesTests.java new file mode 100644 index 0000000..6d97fba --- /dev/null +++ b/multi-datasource/src/test/java/com/ankurm/multids/TwoDatabasesTests.java @@ -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", "asha@example.com")); + 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(); + } + } +} diff --git a/multi-datasource/src/test/java/com/ankurm/multids/chain/ChainedApp.java b/multi-datasource/src/test/java/com/ankurm/multids/chain/ChainedApp.java new file mode 100644 index 0000000..19ec22b --- /dev/null +++ b/multi-datasource/src/test/java/com/ankurm/multids/chain/ChainedApp.java @@ -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"))); + } + } +} diff --git a/multi-datasource/src/test/java/com/ankurm/multids/chain/CommitFailingTransactionManager.java b/multi-datasource/src/test/java/com/ankurm/multids/chain/CommitFailingTransactionManager.java new file mode 100644 index 0000000..abf2973 --- /dev/null +++ b/multi-datasource/src/test/java/com/ankurm/multids/chain/CommitFailingTransactionManager.java @@ -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"); + } +}