Skip to content

Opt-out and deterministic ordering for okapiPostgresLiquibase to coexist with Spring Boot's Liquibase #38

@pdudzinsky

Description

@pdudzinsky

Background

okapi-spring-boot's OutboxAutoConfiguration.PostgresStoreConfiguration registers a SpringLiquibase bean named okapiPostgresLiquibase to apply okapi's bundled changelog. The factory is conditional:

@Bean("okapiPostgresLiquibase")
@ConditionalOnClass(SpringLiquibase::class)
@ConditionalOnBean(value = [DataSource::class, PostgresOutboxStore::class])
@ConditionalOnMissingBean(name = ["okapiPostgresLiquibase"])
fun okapiPostgresLiquibase(): SpringLiquibase = ...

The architectural concern: this is a SpringLiquibase bean. Spring Boot's LiquibaseAutoConfiguration uses @ConditionalOnMissingBean(SpringLiquibase::class) (type match) on its own liquibase factory. When okapiPostgresLiquibase is registered, Spring Boot's liquibase is silently skipped, and the application's own changelog never runs.

For applications using Spring Boot's auto-configured Liquibase to apply their own migrations (test setups, dev environments, anywhere spring.liquibase.enabled=true), there's no clean way to opt out — only fragile workarounds.

Empirical observation

In our setup (Spring Boot 3.5.7 + okapi 0.2.0 + plain okapi-spring-boot + okapi-postgres + okapi-http) the @ConditionalOnBean(PostgresOutboxStore::class) condition on the okapiPostgresLiquibase factory evaluates as not-present, even though outboxStore is declared as a @Bean in the same inner @Configuration. The result is that okapiPostgresLiquibase is never registered, and:

  • Spring Boot's liquibase runs fine ✓
  • okapi's changelog is never applied ✗ — outbox table is missing after startup unless the application runs okapi's changelog itself

This appears to be a separate bug (intra-@Configuration @ConditionalOnBean evaluation order) that warrants its own investigation. Whichever way it gets resolved upstream, applications need a deterministic way to control the bean's presence — hence the requested opt-out.

Two failure modes from one root cause

Depending on Spring's evaluation order in a given setup, applications today see one of two bad outcomes:

  1. okapiPostgresLiquibase registers → Spring Boot's liquibase shadowed → application changelog skipped, half-migrated schema.
  2. okapiPostgresLiquibase does not register (our setup) → okapi changelog never applied, outbox table missing.

Neither is acceptable, and both are non-obvious to debug. There's no startup warning, no error — the application just silently misbehaves.

Reproducing the architectural concern

Minimal reproducer for failure mode 1:

// Spring Boot 3.5.x app with:
//   spring.liquibase.enabled: true
//   spring.liquibase.change-log: classpath:/db/changelog/changelog-master.xml
//   org.liquibase:liquibase-core on the classpath
// Add okapi-bom + okapi-core + okapi-postgres + okapi-http + okapi-spring-boot.
// Provide an HttpMessageDeliverer bean.

If okapiPostgresLiquibase is registered (as the design intends), databasechangelog after startup contains only okapi's 001..004 entries — application changesets are missing, because Spring Boot's liquibase was skipped.

Workarounds today

To get both changelogs running, applications can either:

Option A — Run okapi's changelog programmatically via a standalone @Component:

@Component
internal class OkapiLiquibaseRunner(
    private val dataSource: DataSource,
    appLiquibase: ObjectProvider<SpringLiquibase>,
) : InitializingBean {
    init { appLiquibase.ifAvailable { /* force ordering after Spring Boot's bean */ } }

    override fun afterPropertiesSet() {
        SpringLiquibase().apply {
            this.dataSource = this@OkapiLiquibaseRunner.dataSource
            this.changeLog = "classpath:com/softwaremill/okapi/db/changelog.xml"
        }.afterPropertiesSet()
    }
}

Option B — Include okapi's changelog from the application's own master changelog:

<!-- changelog-master.xml -->
<include file="com/softwaremill/okapi/db/changelog.xml"/>

Both options bypass okapi's auto-configured bean entirely. Both require every consumer to (a) discover the issue and (b) re-implement the bypass.

Proposed fix

Two complementary changes on OutboxAutoConfiguration / its inner PostgresStoreConfiguration:

1. Opt-out property

@Bean("okapiPostgresLiquibase")
@ConditionalOnClass(SpringLiquibase::class)
@ConditionalOnBean(value = [DataSource::class, PostgresOutboxStore::class])
@ConditionalOnMissingBean(name = ["okapiPostgresLiquibase"])
@ConditionalOnProperty(
    prefix = "okapi.liquibase",
    name = ["enabled"],
    havingValue = "true",
    matchIfMissing = true,    // backward compatible: default behavior unchanged
)
fun okapiPostgresLiquibase(): SpringLiquibase = ...

(Same for the MySQL counterpart.)

Consumers using their own Liquibase setup can then opt out cleanly:

okapi:
  liquibase:
    enabled: false

…and apply okapi's changelog via either of the workaround options above.

2. Deterministic ordering relative to LiquibaseAutoConfiguration

Even with the opt-out, default enabled=true behavior could still cause shadowing, because auto-config processing falls back to alphabetical class-name order when no explicit ordering is specified (com.softwaremill.okapi… < org.springframework.boot.autoconfigure…). With okapi's auto-config processed first, okapiPostgresLiquibase would be registered before LiquibaseAutoConfiguration is evaluated → Spring Boot's liquibase skipped.

@AutoConfiguration
@AutoConfigureAfter(LiquibaseAutoConfiguration::class)
class OutboxAutoConfiguration { ... }

This makes the order deterministic. Spring Boot's liquibase is registered first, then okapi's bean evaluates @ConditionalOnMissingBean(name = ["okapiPostgresLiquibase"]) (by name) — liquibase doesn't match by name, so okapi's bean also registers. Both coexist, each runs its own changelog.

Combined with #37 (dedicated tracking tables), new applications dropping in okapi-spring-boot would coexist cleanly with their existing Spring Boot Liquibase setup — no opt-out workaround required.

Backward compatibility

  • @ConditionalOnProperty with matchIfMissing = true keeps the current default behavior. No existing consumer needs changes.
  • @AutoConfigureAfter(LiquibaseAutoConfiguration::class) is purely an ordering hint — it changes when the configuration class is processed, not which beans are registered.

Prior art

A library previously used in our codebase (corify-outbox) avoided this entire class of conflict by registering its SpringLiquibase programmatically via BeanDefinitionRegistry from a SmartInitializingSingleton, after Spring Boot's LiquibaseAutoConfiguration had completed. This bypasses Spring's @ConditionalOnMissingBean mechanics entirely. It also configured dedicated databaseChangeLogTable / databaseChangeLogLockTable (see #37 for the equivalent ask):

@Component
internal class LiquibaseConfiguration(
    private val context: ConfigurableApplicationContext,
    private val outboxDataSources: List<OutboxDataSource>,
) : SmartInitializingSingleton {
    override fun afterSingletonsInstantiated() {
        outboxDataSources.forEach { (dataSourceBeanName, dataSourceInstance) ->
            createLiquibaseBean(dataSourceInstance, dataSourceBeanName)
        }
    }

    private fun createLiquibaseBean(dataSource: DataSource, dataSourceBeanName: String) {
        val beanDefinition = RootBeanDefinition(SpringLiquibase::class.java)
            .apply { scope = BeanDefinition.SCOPE_SINGLETON }
        beanDefinition.propertyValues.add("dataSource", dataSource)
        beanDefinition.propertyValues.add("changeLog", "classpath:liquibase/changelog.xml")
        beanDefinition.propertyValues.add("databaseChangeLogTable", "outbox_liquibase_changelog")
        beanDefinition.propertyValues.add("databaseChangeLogLockTable", "outbox_liquibase_changelog_lock")
        (context as? GenericApplicationContext)?.registerBeanDefinition(
            "outboxLiquibase-$dataSourceBeanName",
            beanDefinition,
        )
        context.getBean("outboxLiquibase-$dataSourceBeanName", SpringLiquibase::class.java)
    }
}

A simpler @ConditionalOnProperty + @AutoConfigureAfter would achieve the same coexistence without resorting to programmatic bean registration.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions