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();
}
}