Request for opinion: Axon config with Java/Spring, multiple Postgres and Testcontainers

Hi everyone, how are you doing?

I’m working on a POC and would really like your feedback on the initial setup.

The idea is to use two separate databases: one dedicated to the event_store, and another for application concerns that don’t belong in event sourcing.

For this first step, my goal was simply to take an event from a request and persist it in the event_store.

For this first step, my goal was simply to take an event from a request and persist it in the event_store. Along the way I ran into some challenges, from modeling the DB schema to running the first integration test with Testcontainers.

Below you’ll find my current configuration. I’d be very grateful for any feedback regarding improvements, best practices, or potential pitfalls.

I’d like your thoughts on the section where I use @Profile("!test") and @Profile("test") to switch between the production DataSource and the Testcontainers DataSource.

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.5'
    id 'io.spring.dependency-management' version '1.1.7'

    id 'com.diffplug.spotless' version '6.25.0'     // Automatic formatter
    id "org.sonarqube" version "6.3.1.5724"         // SonarQube scanner
    id 'jacoco'                                     // Code coverage
}

group = 'com.poc'
version = '0.0.1-SNAPSHOT'
description = 'poc for booking'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

dependencies {
    // Spring Boot starters
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    // Database driver
    runtimeOnly 'org.postgresql:postgresql'

    // Database migrations
    implementation 'org.flywaydb:flyway-core'
    implementation 'org.flywaydb:flyway-database-postgresql'

    // Axon Framework
    implementation 'org.axonframework:axon-spring-boot-starter:4.12.1'
    testImplementation 'org.axonframework:axon-test:4.12.1'

    // Code quality and boilerplate reduction
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // Testing
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.boot:spring-boot-testcontainers:3.5.5'
    testImplementation 'org.testcontainers:junit-jupiter'
    testImplementation 'org.testcontainers:postgresql'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    // Dev tools
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
}

/** Spotless **/
spotless {
    // enforce LF on CI runners
    lineEndings 'UNIX'

    java {
        target 'src/**/*.java'
        // keep your Eclipse formatter config
        eclipse().configFile 'formatter.xml'
        importOrder('', 'java', 'javax', 'org', 'com')
        removeUnusedImports()
        formatAnnotations()
        endWithNewline()
        trimTrailingWhitespace()
        lineEndings 'UNIX'
    }

    // format Gradle scripts without Greclipse to avoid Eclipse bundles
    groovyGradle {
        target '*.gradle', 'gradle/*.gradle'
        endWithNewline()
        trimTrailingWhitespace()
        indentWithSpaces(4)
        lineEndings 'UNIX'
    }
}

/** Jacoco **/
jacoco {
    toolVersion = "0.8.11"
}

/** SonarQube **/
sonarqube {
    properties {
        property("sonar.organization", "...")
        property("sonar.projectKey", "...")
        property "sonar.sources", "src/main/java"
        property "sonar.tests", "src/test/java"
        //property("sonar.exclusions", ".github/**,**/*.md,**/*.yml,**/*.yaml,**/*.json")
        property(
            "sonar.coverage.jacoco.xmlReportPaths",
            "${layout.buildDirectory.get().asFile}/reports/jacoco/test/jacocoTestReport.xml"
        )
    }
}

/** Tasks configuration **/
tasks.test {
    useJUnitPlatform()
    testLogging {
        events "passed", "skipped", "failed"
        exceptionFormat "short"
        showStandardStreams = true
    }
}

tasks.jacocoTestReport {
    dependsOn tasks.test
    reports {
        xml.required = true
        html.required = true
        csv.required = false
    }
}

tasks.check {
    dependsOn("spotlessCheck")
}

…src/main/resources/application.yml

server:
  port: 8080
spring:
  application:
    name: booking
  datasource:
    url: jdbc:postgresql://localhost:5432/booking_events
    username: booking_user
    password: booking_pass
    driver-class-name: org.postgresql.Driver
    hikari:
      minimum-idle: 2
      maximum-pool-size: 10

  flyway:
    enabled: true
    url: ${spring.datasource.url}
    user: ${spring.datasource.username}
    password: ${spring.datasource.password}
    locations: classpath:db/migration
    clean-disabled: true

axon:
  axonserver:
    enabled: false
  serializer:
    events: jackson
    messages: jackson
    general: jackson

booking:
  command-flow:
    # 'transport-type' defines how commands are dispatched within the application:
    # - direct    → synchronous call directly to the handler (current mode, no bus)
    # - messaging → send via a Command Bus (Axon or other), enabling async processing
    transport-type: direct

  secondary-datasource:
    url: jdbc:postgresql://localhost:5433/booking_app
    username: booking_user
    password: booking_pass
    driver-class-name: org.postgresql.Driver
    hikari:
      minimum-idle: 1
      maximum-pool-size: 5
      pool-name: secondary-pool

…src/test/resources/application.yml

spring:
  profiles:
    active: test

  datasource: {}

  flyway:
    enabled: true
    locations: classpath:db/migration
    create-schemas: true
    clean-disabled: false

axon:
  axonserver:
    enabled: false
  serializer:
    events: jackson
    messages: jackson
    general: jackson

booking:
  command-flow:
    transport-type: direct

  secondary-datasource:
    url: jdbc:postgresql://localhost:5433/booking_app
    username: booking_user
    password: booking_pass
    driver-class-name: org.postgresql.Driver

…poc/booking/infrastructure/config/EventStoreConfig.java

/**
 * Axon wrapper for Spring's transaction manager. This ensures Axon uses the
 * JDBC transaction manager bound to the Event Store DS, not a
 * JpaTransactionManager that could be auto-configured if JPA is on the
 * classpath.
 */
@Configuration
public class EventStoreConfig {

    /** Loads datasource properties from spring.datasource */
    @Bean("primaryDsProps")
    @ConfigurationProperties(prefix = "spring.datasource")
    public DataSourceProperties eventStoreDataSourceProperties() {
        return new DataSourceProperties();
    }

    /**
     * Primary DataSource for the Event Store. Marked as @Primary and named
     * "dataSource" so Axon and Flyway pick it by default. Spring Boot can build a
     * HikariDataSource directly from DataSourceProperties.
     *
     * <p>
     * Important: When multiple DataSource beans are present, Spring Boot stops
     * creating the default one. Declaring this bean as @Primary makes the canonical
     * DS explicit and avoids ambiguity.
     */
    @Primary
    @Profile("!test")
    @Bean(name = "dataSource")
    public DataSource eventStoreDataSource(@Qualifier("primaryDsProps") DataSourceProperties props) {
        // Spring Boot can build a HikariDataSource directly from DataSourceProperties
        HikariDataSource ds = props.initializeDataSourceBuilder()
            .type(HikariDataSource.class)
            .driverClassName("org.postgresql.Driver").build();

        ds.setPoolName("eventstore-pool");

        return ds;
    }

    /**
     * Provides a dedicated DataSource for test profile.
     *
     * <p>
     * The configuration relies on Spring Boot's `JdbcConnectionDetails` (typically
     * backed by Testcontainers). A lightweight Hikari pool is created with lower
     * capacity, suitable for integration testing.
     */
    @Primary
    @Profile("test")
    @Bean(name = "dataSource")
    public DataSource eventStoreDataSourceTest(JdbcConnectionDetails details) {
        HikariDataSource ds = new HikariDataSource();
        ds.setPoolName("eventstore-pool");
        ds.setJdbcUrl(details.getJdbcUrl());
        ds.setUsername(details.getUsername());
        ds.setPassword(details.getPassword());
        ds.setDriverClassName(details.getDriverClassName());
        ds.setMinimumIdle(2);
        ds.setMaximumPoolSize(10);
        return ds;
    }

    /**
     * Spring JDBC transaction manager bound to the Event Store DataSource. Axon
     * must use a TransactionManager that points to the same database where Flyway
     * created the event tables.
     */
    @Bean
    public PlatformTransactionManager eventStoreTransactionManager(
        @Qualifier("dataSource") DataSource ds) {
        return new DataSourceTransactionManager(ds);
    }

    /**
     * Axon wrapper for Spring's transaction manager. This ensures Axon uses the
     * JDBC transaction manager bound to the Event Store DS, not a
     * JpaTransactionManager that could be auto-configured if JPA is on the
     * classpath.
     */
    @Bean
    public TransactionManager axonTransactionManager(PlatformTransactionManager eventTxManager) {
        return new SpringTransactionManager(eventTxManager);
    }

    /**
     * Configures the EventStorageEngine that stores events in a relational database
     * using JDBC. Responsible for writing and reading events from the event store
     * tables.
     */
    @Bean
    public EventStorageEngine eventStorageEngine(
        @Qualifier("dataSource") DataSource dataSource,
        TransactionManager axonTxManager,
        Serializer serializer,
        EventSchema eventSchema) {

        // Configure a JDBC-based EventStorageEngine
        return JdbcEventStorageEngine.builder()
            .connectionProvider(new SpringDataSourceConnectionProvider(dataSource))
            .transactionManager(axonTxManager)
            .eventSerializer(serializer)
            .snapshotSerializer(serializer)
            .schema(eventSchema)
            .build();
    }

    /**
     * Central EventStore backed by the JDBC storage engine. Application components
     * publish and read events through this bean.
     */
    @Bean
    public EventStore eventStore(EventStorageEngine storageEngine) {
        return EmbeddedEventStore.builder().storageEngine(storageEngine).build();
    }

    /** Logical schema used by Axon. Must reflect the tables created by Flyway. */
    @Bean
    public EventSchema eventSchema() {
        return EventSchema.builder()
            .eventTable("domain_event_entry")
            .snapshotTable("snapshot_event_entry")
            .globalIndexColumn("global_index")
            .eventIdentifierColumn("event_identifier")
            .metaDataColumn("meta_data")
            .payloadColumn("payload")
            .payloadTypeColumn("payload_type")
            .payloadRevisionColumn("payload_revision")
            .timestampColumn("time_stamp")
            .aggregateIdentifierColumn("aggregate_identifier")
            .sequenceNumberColumn("sequence_number")
            .typeColumn("type")
            .build();
    }
}