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:
@@ -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;
|
||||
@@ -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>
|
||||
Executable
+45
@@ -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 {
|
||||
}
|
||||
Executable
+19
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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();
|
||||
}
|
||||
}
|
||||
+33
@@ -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();
|
||||
}
|
||||
}
|
||||
+7
@@ -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> {
|
||||
}
|
||||
+14
@@ -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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user