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

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
This commit is contained in:
Claude
2026-09-24 09:38:27 +00:00
parent d6b76c57db
commit e59ff029a1
44 changed files with 1465 additions and 0 deletions
@@ -0,0 +1,14 @@
package com.ankurm.multids;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Scans only the packages that belong to the two databases and the service that uses both.
* The {@code traps} package next to it is deliberately NOT scanned: each trap is its own application.
*/
@SpringBootApplication(scanBasePackages = {
"com.ankurm.multids.customers",
"com.ankurm.multids.orders",
"com.ankurm.multids.service"})
public class MultiDsApp {
}
@@ -0,0 +1,47 @@
package com.ankurm.multids.customers;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.time.Instant;
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// No @Column: the column name comes from the naming strategy (createdAt -> created_at).
private Instant createdAt;
protected Customer() {
}
public Customer(String name, String email) {
this.name = name;
this.email = email;
this.createdAt = Instant.now();
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,6 @@
package com.ankurm.multids.customers;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CustomerRepository extends JpaRepository<Customer, Long> {
}
@@ -0,0 +1,70 @@
package com.ankurm.multids.customers;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.persistence.EntityManagerFactory;
import org.flywaydb.core.Flyway;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
import org.springframework.boot.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.util.Map;
/** Everything that belongs to the customers database: DataSource, Flyway, EntityManagerFactory, transaction manager, repositories. */
@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(
basePackageClasses = CustomerRepository.class,
entityManagerFactoryRef = "customersEntityManagerFactory",
transactionManagerRef = "customersTransactionManager")
public class CustomersDbConfig {
@Bean
@Primary
@ConfigurationProperties("app.datasource.customers")
DataSourceProperties customersDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@Primary
@ConfigurationProperties("app.datasource.customers.configuration")
HikariDataSource customersDataSource(@Qualifier("customersDataSourceProperties") DataSourceProperties properties) {
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
@Bean(initMethod = "migrate")
Flyway customersFlyway(@Qualifier("customersDataSource") DataSource dataSource) {
return Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/customers")
.load();
}
@Bean
@Primary
@DependsOn("customersFlyway")
LocalContainerEntityManagerFactoryBean customersEntityManagerFactory(
EntityManagerFactoryBuilder builder, @Qualifier("customersDataSource") DataSource dataSource) {
return builder.dataSource(dataSource)
.packages(Customer.class)
.persistenceUnit("customers")
.properties(Map.of("hibernate.hbm2ddl.auto", "validate"))
.build();
}
@Bean
@Primary
PlatformTransactionManager customersTransactionManager(
@Qualifier("customersEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
}
@@ -0,0 +1,6 @@
package com.ankurm.multids.orders;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository extends JpaRepository<PurchaseOrder, Long> {
}
@@ -0,0 +1,65 @@
package com.ankurm.multids.orders;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.persistence.EntityManagerFactory;
import org.flywaydb.core.Flyway;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
import org.springframework.boot.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.util.Map;
/** Everything that belongs to the orders database: DataSource, Flyway, EntityManagerFactory, transaction manager, repositories. */
@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(
basePackageClasses = OrderRepository.class,
entityManagerFactoryRef = "ordersEntityManagerFactory",
transactionManagerRef = "ordersTransactionManager")
public class OrdersDbConfig {
@Bean
@ConfigurationProperties("app.datasource.orders")
DataSourceProperties ordersDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@ConfigurationProperties("app.datasource.orders.configuration")
HikariDataSource ordersDataSource(@Qualifier("ordersDataSourceProperties") DataSourceProperties properties) {
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
@Bean(initMethod = "migrate")
Flyway ordersFlyway(@Qualifier("ordersDataSource") DataSource dataSource) {
return Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/orders")
.load();
}
@Bean
@DependsOn("ordersFlyway")
LocalContainerEntityManagerFactoryBean ordersEntityManagerFactory(
EntityManagerFactoryBuilder builder, @Qualifier("ordersDataSource") DataSource dataSource) {
return builder.dataSource(dataSource)
.packages(PurchaseOrder.class)
.persistenceUnit("orders")
.properties(Map.of("hibernate.hbm2ddl.auto", "validate"))
.build();
}
@Bean
PlatformTransactionManager ordersTransactionManager(
@Qualifier("ordersEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
}
@@ -0,0 +1,49 @@
package com.ankurm.multids.orders;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.Instant;
@Entity
@Table(name = "purchase_order")
public class PurchaseOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// A plain number, not a @ManyToOne: the customer lives in the other database.
private Long customerId;
private BigDecimal amount;
private Instant placedAt;
protected PurchaseOrder() {
}
public PurchaseOrder(Long customerId, BigDecimal amount) {
this.customerId = customerId;
this.amount = amount;
this.placedAt = Instant.now();
}
public Long getId() {
return id;
}
public Long getCustomerId() {
return customerId;
}
public BigDecimal getAmount() {
return amount;
}
public Instant getPlacedAt() {
return placedAt;
}
}
@@ -0,0 +1,43 @@
package com.ankurm.multids.service;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.customers.CustomerRepository;
import com.ankurm.multids.orders.OrderRepository;
import com.ankurm.multids.orders.PurchaseOrder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
/** Writes a customer (customers database) and an order (orders database), then optionally fails. */
@Service
public class PlacementService {
private final CustomerRepository customers;
private final OrderRepository orders;
public PlacementService(CustomerRepository customers, OrderRepository orders) {
this.customers = customers;
this.orders = orders;
}
/** Plain {@code @Transactional}: Spring picks the one transaction manager marked {@code @Primary}. */
@Transactional
public void placeWithDefaultTransaction(String name, BigDecimal amount, boolean failAfterWriting) {
writeBoth(name, amount, failAfterWriting);
}
/** Names the orders transaction manager, so the ORDERS write is the one that rolls back. */
@Transactional("ordersTransactionManager")
public void placeWithOrdersTransaction(String name, BigDecimal amount, boolean failAfterWriting) {
writeBoth(name, amount, failAfterWriting);
}
private void writeBoth(String name, BigDecimal amount, boolean failAfterWriting) {
Customer customer = customers.save(new Customer(name, name.toLowerCase() + "@example.com"));
orders.save(new PurchaseOrder(customer.getId(), amount));
if (failAfterWriting) {
throw new IllegalStateException("boom after both writes");
}
}
}
@@ -0,0 +1,38 @@
package com.ankurm.multids.traps.builderdefaults;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.customers.CustomerRepository;
import org.flywaydb.core.Flyway;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.boot.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import javax.sql.DataSource;
/** A hand-built EntityManagerFactory that passes NO Hibernate properties of its own: what does it still get from Spring Boot? */
@SpringBootApplication
@EnableJpaRepositories(basePackageClasses = CustomerRepository.class)
public class BuilderDefaultsApp {
@Bean
@Primary
DataSource dataSource() {
return DataSourceBuilder.create().url("jdbc:h2:mem:naming").username("sa").password("").build();
}
@Bean(initMethod = "migrate")
Flyway flyway(DataSource dataSource) {
return Flyway.configure().dataSource(dataSource).locations("classpath:db/migration/customers").load();
}
@Bean
@DependsOn("flyway")
LocalContainerEntityManagerFactoryBean entityManagerFactory(EntityManagerFactoryBuilder builder, DataSource dataSource) {
return builder.dataSource(dataSource).packages(Customer.class).build();
}
}
@@ -0,0 +1,97 @@
package com.ankurm.multids.traps.flywayauto;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.customers.CustomerRepository;
import com.ankurm.multids.orders.OrderRepository;
import com.ankurm.multids.orders.PurchaseOrder;
import jakarta.persistence.EntityManagerFactory;
import org.flywaydb.core.Flyway;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.boot.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.env.Environment;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.util.Map;
/**
* Two databases, two EntityManagerFactories, and NO Flyway beans of our own: Spring Boot's Flyway
* auto-configuration is left to do what it does. Set {@code trap.flyway=orders-only} to add a Flyway
* bean for the orders database alone.
*/
@SpringBootApplication
public class FlywayAutoApp {
// @EnableJpaRepositories is not repeatable, so each scan needs a configuration class of its own.
@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(basePackageClasses = CustomerRepository.class,
entityManagerFactoryRef = "customersEntityManagerFactory", transactionManagerRef = "customersTransactionManager")
static class CustomersRepositories {
}
@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(basePackageClasses = OrderRepository.class,
entityManagerFactoryRef = "ordersEntityManagerFactory", transactionManagerRef = "ordersTransactionManager")
static class OrdersRepositories {
}
/** "validate" by default; trap.ddl=none lets the application start so the databases can be inspected. */
private static Map<String, String> ddl(Environment env) {
return Map.of("hibernate.hbm2ddl.auto", env.getProperty("trap.ddl", "validate"));
}
@Bean
@Primary
DataSource customersDataSource() {
return DataSourceBuilder.create().url("jdbc:h2:mem:fa-customers").username("sa").password("").build();
}
@Bean
DataSource ordersDataSource() {
return DataSourceBuilder.create().url("jdbc:h2:mem:fa-orders").username("sa").password("").build();
}
@Bean
@Primary
LocalContainerEntityManagerFactoryBean customersEntityManagerFactory(
EntityManagerFactoryBuilder builder, @Qualifier("customersDataSource") DataSource dataSource, Environment env) {
return builder.dataSource(dataSource).packages(Customer.class).persistenceUnit("customers")
.properties(ddl(env)).build();
}
@Bean
LocalContainerEntityManagerFactoryBean ordersEntityManagerFactory(
EntityManagerFactoryBuilder builder, @Qualifier("ordersDataSource") DataSource dataSource, Environment env) {
return builder.dataSource(dataSource).packages(PurchaseOrder.class).persistenceUnit("orders")
.properties(ddl(env)).build();
}
@Bean
@Primary
PlatformTransactionManager customersTransactionManager(
@Qualifier("customersEntityManagerFactory") EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
@Bean
PlatformTransactionManager ordersTransactionManager(
@Qualifier("ordersEntityManagerFactory") EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
/** Only when trap.flyway=orders-only: one hand-made Flyway bean, for the orders database. */
@Bean(initMethod = "migrate")
@ConditionalOnProperty(name = "trap.flyway", havingValue = "orders-only")
Flyway ordersFlyway(@Qualifier("ordersDataSource") DataSource dataSource) {
return Flyway.configure().dataSource(dataSource).locations("classpath:db/migration/orders").load();
}
}
@@ -0,0 +1,19 @@
package com.ankurm.multids.traps.jdbcurl;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import javax.sql.DataSource;
/** The copy-paste pattern: bind app.datasource.customers.* straight onto the pool. */
@SpringBootApplication
public class JdbcUrlApp {
@Bean
@ConfigurationProperties("app.datasource.customers")
DataSource customersDataSource() {
return DataSourceBuilder.create().build();
}
}
@@ -0,0 +1,25 @@
package com.ankurm.multids.traps.nodefault;
import com.ankurm.multids.customers.CustomerRepository;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import javax.sql.DataSource;
/** Two DataSource beans, none marked @Primary, and Spring Boot's own JPA setup expected to cope. */
@SpringBootApplication
@EnableJpaRepositories(basePackageClasses = CustomerRepository.class)
public class NoDefaultApp {
@Bean
DataSource customersDataSource() {
return DataSourceBuilder.create().url("jdbc:h2:mem:nd-customers").username("sa").password("").build();
}
@Bean
DataSource ordersDataSource() {
return DataSourceBuilder.create().url("jdbc:h2:mem:nd-orders").username("sa").password("").build();
}
}
@@ -0,0 +1,33 @@
package com.ankurm.multids.traps.primaryonly;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.orders.PurchaseOrder;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import javax.sql.DataSource;
/**
* The "it works" trap: one DataSource is @Primary, so Spring Boot builds ONE EntityManagerFactory on it,
* and both entities and both repositories end up there. The second DataSource is never used.
*/
@SpringBootApplication
@EntityScan(basePackageClasses = {Customer.class, PurchaseOrder.class})
@EnableJpaRepositories(basePackages = {"com.ankurm.multids.customers", "com.ankurm.multids.orders"})
public class PrimaryOnlyApp {
@Bean
@Primary
DataSource customersDataSource() {
return DataSourceBuilder.create().url("jdbc:h2:mem:po-customers").username("sa").password("").build();
}
@Bean
DataSource ordersDataSource() {
return DataSourceBuilder.create().url("jdbc:h2:mem:po-orders").username("sa").password("").build();
}
}
@@ -0,0 +1,7 @@
package com.ankurm.multids.traps.wrongpackage;
import com.ankurm.multids.customers.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CustomerInOrdersRepository extends JpaRepository<Customer, Long> {
}
@@ -0,0 +1,14 @@
package com.ankurm.multids.traps.wrongpackage;
import com.ankurm.multids.orders.OrdersDbConfig;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/** The orders configuration, plus a second repository scan that points a CUSTOMER repository at the ORDERS factory. */
@SpringBootApplication
@Import(OrdersDbConfig.class)
@EnableJpaRepositories(basePackageClasses = CustomerInOrdersRepository.class,
entityManagerFactoryRef = "ordersEntityManagerFactory", transactionManagerRef = "ordersTransactionManager")
public class WrongPackageApp {
}
@@ -0,0 +1,20 @@
spring.application.name=multi-datasource
spring.main.banner-mode=off
# One block per database. The prefix is ours ("app."), not Spring Boot's ("spring.datasource.").
app.datasource.customers.url=jdbc:h2:mem:customers
app.datasource.customers.username=sa
app.datasource.customers.password=
app.datasource.orders.url=jdbc:h2:mem:orders
app.datasource.orders.username=sa
app.datasource.orders.password=
# Pool settings are per database too. They bind onto the HikariDataSource we build, under ".configuration".
app.datasource.customers.configuration.pool-name=customers-pool
app.datasource.customers.configuration.maximum-pool-size=4
app.datasource.orders.configuration.pool-name=orders-pool
app.datasource.orders.configuration.maximum-pool-size=8
logging.level.org.flywaydb=WARN
logging.level.org.hibernate=WARN
@@ -0,0 +1,6 @@
create table customer (
id bigint generated by default as identity primary key,
name varchar(100) not null,
email varchar(200) not null,
created_at timestamp not null
);
@@ -0,0 +1,7 @@
-- customer_id points at a row in a DIFFERENT database, so there is no foreign key here and there cannot be one.
create table purchase_order (
id bigint generated by default as identity primary key,
customer_id bigint not null,
amount decimal(10, 2) not null,
placed_at timestamp not null
);
@@ -0,0 +1 @@
create index ix_purchase_order_customer on purchase_order (customer_id);
@@ -0,0 +1,60 @@
package com.ankurm.multids;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
/** Starts a Spring Boot context without a web server and returns either the context or the failure. */
public final class BootRun {
private BootRun() {
}
public static Result run(String[] extraProperties, Class<?>... sources) {
var props = new java.util.ArrayList<String>();
props.add("spring.main.banner-mode=off");
props.add("logging.level.root=OFF");
props.addAll(java.util.List.of(extraProperties));
try {
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(sources)
.web(WebApplicationType.NONE)
.properties(props.toArray(String[]::new))
.run();
return new Result(ctx, null);
} catch (RuntimeException e) {
return new Result(null, e);
}
}
/** Names the exception classes from the outside in: what a stack trace's "Caused by:" lines say, minus the noise. */
public static String chain(Throwable t) {
var names = new java.util.ArrayList<String>();
for (Throwable c = t; c != null && !names.contains(c.getClass().getSimpleName() + c.hashCode()); c = c.getCause()) {
names.add(c.getClass().getSimpleName());
if (c.getCause() == c) {
break;
}
}
return String.join(" -> ", names);
}
public static Throwable root(Throwable t) {
while (t.getCause() != null && t.getCause() != t) {
t = t.getCause();
}
return t;
}
public record Result(ConfigurableApplicationContext context, RuntimeException failure) {
public boolean started() {
return failure == null;
}
public void close() {
if (context != null) {
context.close();
}
}
}
}
@@ -0,0 +1,67 @@
package com.ankurm.multids;
import com.ankurm.multids.chain.ChainedApp;
import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.transaction.HeuristicCompletionException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** Transcript 09: ChainedTransactionManager, on the happy path and when a COMMIT fails. */
class ChainedTests {
@Test
void chainedTransactionManager() {
try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(ChainedApp.class)
.web(WebApplicationType.NONE).properties("logging.level.root=OFF").run();
var t = new Transcript("09-chained-transaction-manager.txt",
"ChainedTransactionManager over the customers and orders managers")) {
var service = ctx.getBean(ChainedApp.ChainedPlacement.class);
var customersDb = Jdbc.on(ctx, "customersDataSource");
var ordersDb = Jdbc.on(ctx, "ordersDataSource");
t.section("1. business exception after both writes: both roll back");
assertThatThrownBy(() -> service.placeThenFail("Asha")).isInstanceOf(IllegalStateException.class);
t.line("rows: customers.customer=%d orders.purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
assertThat(Jdbc.count(customersDb, "customer")).isZero();
assertThat(Jdbc.count(ordersDb, "purchase_order")).isZero();
t.section("2. the orders database refuses to commit; orders is LAST in the list");
Throwable last = catchThrowable(() -> service.placeWhenOrdersCommitsLast("Bela"));
t.line("thrown: %s", describe(last));
t.line("rows: customers.customer=%d orders.purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
int customersAfterLast = Jdbc.count(customersDb, "customer");
customersDb.update("delete from customer");
ordersDb.update("delete from purchase_order");
t.section("3. the orders database refuses to commit; orders is FIRST in the list");
Throwable first = catchThrowable(() -> service.placeWhenOrdersCommitsFirst("Chitra"));
t.line("thrown: %s", describe(first));
t.line("rows: customers.customer=%d orders.purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
int customersAfterFirst = Jdbc.count(customersDb, "customer");
// The point of the section: the outcome depends on the ORDER of the list.
assertThat(customersAfterLast).isNotEqualTo(customersAfterFirst);
}
}
private static String describe(Throwable e) {
var h = (HeuristicCompletionException) e;
return e.getClass().getName() + " (outcome: " + HeuristicCompletionException.getStateString(h.getOutcomeState()) + ")";
}
private static Throwable catchThrowable(Runnable r) {
try {
r.run();
} catch (Throwable e) {
return e;
}
throw new AssertionError("expected the commit to fail");
}
}
@@ -0,0 +1,32 @@
package com.ankurm.multids;
import org.springframework.context.ApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
import java.util.List;
/** Tiny helpers: ask a named DataSource a question with plain JDBC, bypassing JPA entirely. */
public final class Jdbc {
private Jdbc() {
}
public static JdbcTemplate on(ApplicationContext ctx, String dataSourceBean) {
return new JdbcTemplate(ctx.getBean(dataSourceBean, DataSource.class));
}
public static int count(JdbcTemplate jdbc, String table) {
return jdbc.queryForObject("select count(*) from " + table, Integer.class);
}
public static List<String> tables(JdbcTemplate jdbc) {
return jdbc.queryForList(
"select table_name from information_schema.tables where table_schema = 'PUBLIC' order by table_name", String.class);
}
public static List<String> migrations(JdbcTemplate jdbc) {
return jdbc.queryForList(
"select \"version\" || ' ' || \"description\" from \"flyway_schema_history\" where \"version\" is not null order by \"installed_rank\"", String.class);
}
}
@@ -0,0 +1,54 @@
package com.ankurm.multids;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code output/} and echoes it to the console.
* Every console block quoted in the article comes out of one of these files verbatim.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
public Transcript section(String heading) {
out.println();
out.println("--- " + heading + " ---");
return this;
}
@Override
public void close() {
out.flush();
// Absolute paths of whoever ran the build are environment noise, not a finding.
String text = buffer.toString().replace(System.getProperty("user.dir"), "<multi-datasource>");
try {
Files.createDirectories(path.getParent());
Files.writeString(path, text);
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(text);
}
}
@@ -0,0 +1,147 @@
package com.ankurm.multids;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.customers.CustomerRepository;
import com.ankurm.multids.orders.OrderRepository;
import com.ankurm.multids.orders.PurchaseOrder;
import com.ankurm.multids.traps.builderdefaults.BuilderDefaultsApp;
import com.ankurm.multids.traps.flywayauto.FlywayAutoApp;
import com.ankurm.multids.traps.jdbcurl.JdbcUrlApp;
import com.ankurm.multids.traps.nodefault.NoDefaultApp;
import com.ankurm.multids.traps.primaryonly.PrimaryOnlyApp;
import com.ankurm.multids.traps.wrongpackage.WrongPackageApp;
import jakarta.persistence.EntityManagerFactory;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/** Transcripts 02-07: every way of getting the setup wrong that the article describes, run for real. */
class TrapsTests {
private static void failure(Transcript t, BootRun.Result r) {
assertThat(r.started()).as("the application should have failed to start").isFalse();
t.line("startup failed: %s", BootRun.chain(r.failure()));
t.line("root cause: %s: %s", BootRun.root(r.failure()).getClass().getName(), BootRun.root(r.failure()).getMessage());
}
@Test
void twoDataSourcesAndNoPrimary() {
try (var t = new Transcript("02-no-primary.txt", "Two DataSource beans, none marked @Primary")) {
var r = BootRun.run(new String[0], NoDefaultApp.class);
failure(t, r);
assertThat(BootRun.root(r.failure()).getMessage()).contains("No bean named 'entityManagerFactory'");
}
}
@Test
void oneDataSourcePrimaryAndTheOtherSilentlyUnused() {
try (var t = new Transcript("03-primary-only-wrong-database.txt",
"One @Primary DataSource and nothing else: it starts, and it writes to the wrong database")) {
var r = BootRun.run(new String[]{"spring.flyway.enabled=false"}, PrimaryOnlyApp.class);
assertThat(r.started()).isTrue();
var ctx = r.context();
try {
var customer = ctx.getBean(CustomerRepository.class).save(new Customer("Asha", "[email protected]"));
ctx.getBean(OrderRepository.class).save(new PurchaseOrder(customer.getId(), new BigDecimal("499.00")));
var customersDb = Jdbc.on(ctx, "customersDataSource");
var ordersDb = Jdbc.on(ctx, "ordersDataSource");
t.line("application started: yes");
t.line("EntityManagerFactory beans: %s", List.of(ctx.getBeanNamesForType(EntityManagerFactory.class)));
t.line("customers database tables: %s", Jdbc.tables(customersDb));
t.line("orders database tables: %s", Jdbc.tables(ordersDb));
t.line("rows in the customers database: customer=%d purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(customersDb, "purchase_order"));
assertThat(Jdbc.tables(ordersDb)).isEmpty();
assertThat(Jdbc.count(customersDb, "purchase_order")).isEqualTo(1);
var emf = ctx.getBean(EntityManagerFactory.class);
t.line("hibernate.hbm2ddl.auto on that factory: %s", emf.getProperties().get("hibernate.hbm2ddl.auto"));
} finally {
r.close();
}
}
}
@Test
void urlDoesNotBindOntoAPool() {
try (var t = new Transcript("04-url-vs-jdbc-url.txt",
"DataSourceBuilder.create().build() bound to app.datasource.customers.url")) {
var r = BootRun.run(new String[]{"spring.flyway.enabled=false"}, JdbcUrlApp.class);
failure(t, r);
assertThat(BootRun.root(r.failure()).getMessage()).contains("jdbcUrl is required");
}
}
@Test
void whatTheBuilderAlreadyGivesAHandBuiltFactory() {
try (var t = new Transcript("05-builder-defaults.txt",
"A hand-built EntityManagerFactory that sets no Hibernate properties of its own")) {
var r = BootRun.run(new String[0], BuilderDefaultsApp.class);
assertThat(r.started()).isTrue();
try {
var props = r.context().getBean(EntityManagerFactory.class).getProperties();
t.line("hibernate.physical_naming_strategy = %s", props.get("hibernate.physical_naming_strategy"));
t.line("hibernate.implicit_naming_strategy = %s", props.get("hibernate.implicit_naming_strategy"));
t.line("hibernate.hbm2ddl.auto = %s", props.get("hibernate.hbm2ddl.auto"));
var saved = r.context().getBean(CustomerRepository.class).save(new Customer("Asha", "[email protected]"));
t.line("saved a Customer whose field is createdAt into a column created_at: id=%d", saved.getId());
assertThat(props.get("hibernate.physical_naming_strategy").toString()).contains("SnakeCase");
assertThat(props.get("hibernate.hbm2ddl.auto")).isNull();
} finally {
r.close();
}
}
}
private static void whatEachDatabaseHolds(Transcript t, BootRun.Result r) {
assertThat(r.started()).isTrue();
try {
t.line("application started (Hibernate validation switched off so the databases can be inspected)");
t.line("customers database tables: %s", Jdbc.tables(Jdbc.on(r.context(), "customersDataSource")));
t.line("orders database tables: %s", Jdbc.tables(Jdbc.on(r.context(), "ordersDataSource")));
} finally {
r.close();
}
}
@Test
void flywayAutoConfigurationWithTwoDatabases() {
try (var t = new Transcript("06-flyway-auto-configuration.txt",
"Spring Boot's Flyway auto-configuration when there are two databases")) {
String customersOnly = "spring.flyway.locations=classpath:db/migration/customers";
t.section("A. nothing configured: Flyway targets the @Primary DataSource and scans classpath:db/migration");
var a = BootRun.run(new String[0], FlywayAutoApp.class);
failure(t, a);
assertThat(BootRun.root(a.failure()).getMessage()).contains("Found more than one migration with version 1");
t.section("B. spring.flyway.locations=classpath:db/migration/customers");
var b = BootRun.run(new String[]{customersOnly}, FlywayAutoApp.class);
failure(t, b);
assertThat(BootRun.root(b.failure()).getMessage()).contains("missing table [purchase_order]");
t.blank();
var b2 = BootRun.run(new String[]{customersOnly, "trap.ddl=none"}, FlywayAutoApp.class);
whatEachDatabaseHolds(t, b2);
t.section("C. as B, plus one hand-made Flyway bean for the orders database");
var c = BootRun.run(new String[]{customersOnly, "trap.flyway=orders-only"}, FlywayAutoApp.class);
failure(t, c);
assertThat(BootRun.root(c.failure()).getMessage()).contains("missing table [customer]");
t.blank();
var c2 = BootRun.run(new String[]{customersOnly, "trap.flyway=orders-only", "trap.ddl=none"}, FlywayAutoApp.class);
whatEachDatabaseHolds(t, c2);
}
}
@Test
void aRepositoryInThePackageOfTheWrongFactory() {
try (var t = new Transcript("07-wrong-package.txt",
"A repository for Customer scanned against the orders EntityManagerFactory")) {
var r = BootRun.run(new String[0], WrongPackageApp.class);
failure(t, r);
assertThat(BootRun.root(r.failure()).getMessage()).contains("Not a managed type");
}
}
}
@@ -0,0 +1,114 @@
package com.ankurm.multids;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.customers.CustomerRepository;
import com.ankurm.multids.orders.OrderRepository;
import com.ankurm.multids.orders.PurchaseOrder;
import com.ankurm.multids.service.PlacementService;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.metamodel.EntityType;
import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.transaction.PlatformTransactionManager;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** Transcripts 01 (the working setup) and 08 (which transaction manager a plain @Transactional gets). */
class TwoDatabasesTests {
private static ConfigurableApplicationContext start() {
return new SpringApplicationBuilder(MultiDsApp.class)
.web(WebApplicationType.NONE)
.properties("logging.level.root=WARN")
.run();
}
@Test
void theWorkingSetup() {
try (ConfigurableApplicationContext ctx = start(); var t = new Transcript("01-two-databases.txt",
"Two databases, each with its own DataSource, Flyway, EntityManagerFactory and transaction manager")) {
t.section("the two DataSources (one pool each, configured separately)");
for (String name : List.of("customersDataSource", "ordersDataSource")) {
var pool = ctx.getBean(name, HikariDataSource.class);
t.line("%-20s pool=%-15s max=%d url=%s primary=%s", name, pool.getPoolName(),
pool.getMaximumPoolSize(), pool.getJdbcUrl(), ctx.getBeanFactory().getBeanDefinition(name).isPrimary());
}
t.section("Flyway ran once per database, each with its own history");
var customersDb = Jdbc.on(ctx, "customersDataSource");
var ordersDb = Jdbc.on(ctx, "ordersDataSource");
t.line("customers flyway_schema_history: %s", Jdbc.migrations(customersDb));
t.line("orders flyway_schema_history: %s", Jdbc.migrations(ordersDb));
assertThat(Jdbc.migrations(customersDb)).hasSize(1);
assertThat(Jdbc.migrations(ordersDb)).hasSize(2);
t.section("what each EntityManagerFactory manages");
t.line("EntityManagerFactory beans: %s", Arrays.stream(ctx.getBeanNamesForType(EntityManagerFactory.class)).sorted().toList());
for (String name : List.of("customersEntityManagerFactory", "ordersEntityManagerFactory")) {
var emf = ctx.getBean(name, EntityManagerFactory.class);
t.line("%-32s entities=%s", name,
emf.getMetamodel().getEntities().stream().map(EntityType::getName).toList());
}
t.section("transaction managers");
t.line("beans: %s", Arrays.stream(ctx.getBeanNamesForType(PlatformTransactionManager.class)).sorted().toList());
for (String name : List.of("customersTransactionManager", "ordersTransactionManager")) {
t.line("%-30s primary=%s", name, ctx.getBeanFactory().getBeanDefinition(name).isPrimary());
}
t.section("save one customer and one order through the two repositories");
var customer = ctx.getBean(CustomerRepository.class).save(
new Customer("Asha", "[email protected]"));
ctx.getBean(OrderRepository.class).save(
new PurchaseOrder(customer.getId(), new BigDecimal("499.00")));
t.line("customers database tables: %s", Jdbc.tables(customersDb));
t.line("orders database tables: %s", Jdbc.tables(ordersDb));
t.line("rows: customers.customer=%d orders.purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
assertThat(Jdbc.tables(customersDb)).doesNotContain("PURCHASE_ORDER");
assertThat(Jdbc.tables(ordersDb)).doesNotContain("CUSTOMER");
assertThat(Jdbc.count(customersDb, "customer")).isEqualTo(1);
assertThat(Jdbc.count(ordersDb, "purchase_order")).isEqualTo(1);
}
}
@Test
void plainTransactionalGetsThePrimaryManager() {
try (ConfigurableApplicationContext ctx = start(); var t = new Transcript("08-which-transaction-manager.txt",
"A service method that writes to both databases, then throws")) {
var service = ctx.getBean(PlacementService.class);
var customersDb = Jdbc.on(ctx, "customersDataSource");
var ordersDb = Jdbc.on(ctx, "ordersDataSource");
t.section("@Transactional (no name): Spring picks the @Primary manager, customersTransactionManager");
assertThatThrownBy(() -> service.placeWithDefaultTransaction("Asha", new BigDecimal("10"), true))
.isInstanceOf(IllegalStateException.class);
t.line("thrown: java.lang.IllegalStateException: boom after both writes");
t.line("rows after the rollback: customers.customer=%d orders.purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
assertThat(Jdbc.count(customersDb, "customer")).isZero();
assertThat(Jdbc.count(ordersDb, "purchase_order")).isEqualTo(1);
customersDb.update("delete from customer");
ordersDb.update("delete from purchase_order");
t.section("@Transactional(\"ordersTransactionManager\"): the other database is now the one that rolls back");
assertThatThrownBy(() -> service.placeWithOrdersTransaction("Bela", new BigDecimal("10"), true))
.isInstanceOf(IllegalStateException.class);
t.line("thrown: java.lang.IllegalStateException: boom after both writes");
t.line("rows after the rollback: customers.customer=%d orders.purchase_order=%d",
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
assertThat(Jdbc.count(customersDb, "customer")).isEqualTo(1);
assertThat(Jdbc.count(ordersDb, "purchase_order")).isZero();
}
}
}
@@ -0,0 +1,87 @@
package com.ankurm.multids.chain;
import com.ankurm.multids.customers.Customer;
import com.ankurm.multids.customers.CustomerRepository;
import com.ankurm.multids.customers.CustomersDbConfig;
import com.ankurm.multids.orders.OrderRepository;
import com.ankurm.multids.orders.OrdersDbConfig;
import com.ankurm.multids.orders.PurchaseOrder;
import jakarta.persistence.EntityManagerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.data.transaction.ChainedTransactionManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
/** The two real configurations, plus three "chained" transaction managers and a service that uses them. */
@SpringBootApplication
@Import({CustomersDbConfig.class, OrdersDbConfig.class})
public class ChainedApp {
/** customers, then orders: the list ChainedTransactionManager is given. */
@Bean
@SuppressWarnings("deprecation")
PlatformTransactionManager chained(@Qualifier("customersTransactionManager") PlatformTransactionManager customers,
@Qualifier("ordersTransactionManager") PlatformTransactionManager orders) {
return new ChainedTransactionManager(customers, orders);
}
@Bean
PlatformTransactionManager failingOrders(@Qualifier("ordersEntityManagerFactory") EntityManagerFactory emf) {
return new CommitFailingTransactionManager(emf);
}
/** The orders side is listed FIRST and refuses to commit. */
@Bean
@SuppressWarnings("deprecation")
PlatformTransactionManager chainOrdersFirst(@Qualifier("failingOrders") PlatformTransactionManager failingOrders,
@Qualifier("customersTransactionManager") PlatformTransactionManager customers) {
return new ChainedTransactionManager(failingOrders, customers);
}
/** The orders side is listed LAST and refuses to commit. */
@Bean
@SuppressWarnings("deprecation")
PlatformTransactionManager chainOrdersLast(@Qualifier("customersTransactionManager") PlatformTransactionManager customers,
@Qualifier("failingOrders") PlatformTransactionManager failingOrders) {
return new ChainedTransactionManager(customers, failingOrders);
}
@Service
public static class ChainedPlacement {
private final CustomerRepository customers;
private final OrderRepository orders;
ChainedPlacement(CustomerRepository customers, OrderRepository orders) {
this.customers = customers;
this.orders = orders;
}
@Transactional("chained")
public void placeThenFail(String name) {
write(name);
throw new IllegalStateException("boom after both writes");
}
@Transactional("chainOrdersFirst")
public void placeWhenOrdersCommitsFirst(String name) {
write(name);
}
@Transactional("chainOrdersLast")
public void placeWhenOrdersCommitsLast(String name) {
write(name);
}
private void write(String name) {
Customer customer = customers.save(new Customer(name, name.toLowerCase() + "@example.com"));
orders.save(new PurchaseOrder(customer.getId(), new BigDecimal("10.00")));
}
}
}
@@ -0,0 +1,19 @@
package com.ankurm.multids.chain;
import jakarta.persistence.EntityManagerFactory;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.transaction.TransactionSystemException;
import org.springframework.transaction.support.DefaultTransactionStatus;
/** Stands in for "the database refused the COMMIT": a real JpaTransactionManager whose commit always throws. */
public class CommitFailingTransactionManager extends JpaTransactionManager {
public CommitFailingTransactionManager(EntityManagerFactory emf) {
super(emf);
}
@Override
protected void doCommit(DefaultTransactionStatus status) {
throw new TransactionSystemException("simulated: the orders database refused the commit");
}
}