May 5, 2025

Generating JOOQ sources using Flyway and Testcontainers

The typical microservices I am working on are Kotlin based. They use Gradle as a build tool, Postgres for the database layer, Flyway for migration management, and JOOQ for database access. JOOQ generates Java or Kotlin class definitions from the database schema, which provides type safety and avoids hard coded strings in the application logic. The challenge is to make all of this work together. We want to make sure that the JOOQ sources are always in sync with the newest database schema defined by the Flyway migrations, and we are using Gradle for this. In this blog post, I describe my journey through the different solutions and the one that I am currently pretty happy with.

The general approach

There are Gradle plugins for both Flyway and JOOQ, and all of the solutions that I am covering use those. The Flyway plugin takes a JDBC URL and applies the migrations against the specified database. Similarly, the JOOQ plugin takes a JDBC URL (and other configuration options) and generates JOOQ sources for the specified database. By using the same database for both Flyway and JOOQ and making sure that the Flyway plugin always runs before the JOOQ plugin, we solve our problem of keeping the JOOQ sources in sync with the database schema. The relevant section of our build.gradle.kts would look something like this.

flyway {
  url = "<some jdbc url>"
}

jooq {
  configurations {
    create("main") {
      jooqConfiguration.apply {
        generator.database.inputSchema = "public"
        jdbc.apply {
          url = "<some jdbc url>"
        }
      }
    }
  }
}

tasks.named("generateJooq") {
  dependsOn("flywayMigrate")
}

But one question remains: which database do we provide to the plugins, or in other words, what do we set the JDBC URL to?

Sidenote on the classpath

The above snippet is only one part of the build file. Another important part is defining the used packages, and in which classpath they should be visible. In our case, we have three different classpaths to consider: the classpath of the application, the classpath of the build script (which will be used for example by the Flyway plugin), and the classpath of the JOOQ generator. The latter is necessary because the JOOQ plugin spans the code generator in a separate JVM (which creates some headache, as we will see below).

dependencies {
  implementation(kotlin("stdlib"))
  implementation("org.jooq:jooq:3.20.3")
  implementation("org.flywaydb:flyway-core:11.8.0")
  implementation("org.flywaydb:flyway-database-postgresql:11.8.0")
  implementation("org.postgresql:postgresql:42.7.5")

  jooqGenerator("org.postgresql:postgresql:42.7.5")
  jooqGenerator("org.testcontainers:postgresql:1.21.0")
  jooqGenerator("org.slf4j:slf4j-simple:2.0.17")
}

buildscript {
  dependencies {
    classpath("org.testcontainers:postgresql:1.21.0")
    classpath("org.postgresql:postgresql:42.7.5")
    classpath("org.flywaydb:flyway-database-postgresql:10.22.0")
  }
}

With the implementation directives in dependencies, we define the classpath of the application; with the classpath directives in the buildscript.dependencies the classpath for the build script; and with the jooqGenerator directive in dependencies the classpath of the JOOQ generator. We see the postgresql dependency three times: once each for the main application, for the Flyway plugin, and for the JOOQ generator. We also see the slf4j-simple dependency for the JOOQ generator, which ensures that we actually see the logs produced by the generator. Otherwise, the logs are hidden. This took me a bit to figure out, since the documentation on this is not really explicit.

With this sidenote out of the way, back to choosing a JDBC URL.

Static Postgres instance

A solution that I sometimes see is to externalize the problem. The JDBC URL is set to jdbc:postgresql://localhost:12345/servicename, and it is then up to whoever runs the build to ensure that a database is running at the specified port, for example, by running a Postgres Docker container.

This is by far my least favorite solution. I think a build script should be self-contained. Anyone should be able to run it, without any additional setup. Having to ensure that a database is provided goes against this principle. It also makes it very easy to end up in non-reproducible builds, for example, if the provided database is in an inconsistent state. Another problem is the hard-coded port. It could happen that a user has a different service running on that port, which means that they either have to change the build file locally, or move the other service to a different port. There are various ways trying to work around those issues, but so far I haven’t seen one that really fixed all of them.

Testcontainers

In Testcontainers in Spring Boot I describe why I am a big fan of testcontainers, in particular of their JDBC support. It allows specifying a JDBC URL like jdbc:tc:postgresql:17:///databasename, which will automatically bring up a Postgres container when the URL is accessed. This sounds like the perfect solution for our problem: simply provide the same testcontainer URL for both Flyway and JOOQ.

flyway {
  url = "jdbc:tc:postgresql:17:///databasename"
}

jooq {
  configurations {
    create("main") {
      jooqConfiguration.apply {
        generator.database.inputSchema = "public"
        jdbc.apply {
          driver = "org.testcontainers.jdbc.ContainerDatabaseDriver"
          url = "jdbc:tc:postgresql:17:///databasename"
        }
      }
    }
  }
}

tasks.named("generateJooq") {
  dependsOn("flywayMigrate")
}

(Note that we have to specify the driver explicitly for JOOQ but not for Flyway. Properly explaining why that’s the case probably deserves its own blog post.)

At first, this seems to work. Running jooqGenerate does not produce any errors. But a second look reveals that the directory with generated sources remains empty. This is because the JOOQ plugin starts a new JVM process for the source generation. The way that the testcontainer magic works means that Flyway and JOOQ will end up actually using two different databases. After all, testcontainers are meant to provide isolation, and reusing the same container in two different JVMs would break this. So in our case, JOOQ sees a completely empty database, and therefore produces no source files.

There is a way to avoid this. Testcontainers can be marked as reusable, which allows both JVMs to access the same database. We can do this through the JDBC URL with jdbc:tc:postgresql:17:///databasename?TC_REUSABLE=true. There are two caveats though. The first is that this feature is opt-in. You have to enable this on your machine by either setting the environment variable TESTCONTAINERS_REUSE_ENABLE=true, or by adding a property to ~/.testcontainers.properties. This again goes against the principle that build scripts should be self-contained and not require additional setup. The other caveat is that by using this flag, the Postgres container will run even after the build script finished, and will be used if the build script is run again. This again leads to non-reproducibility problems.

Starting testcontainer manually

The solution to those problems is to start and stop the Postgres container manually. But this creates a new problem: we have to know the JDBC URL of this instance so that we can configure JOOQ and Flyway to use it. One way to do this would be to expose the instance on a predefined port. Then we know the JDBC URL and can use it explicitly in the build file. But we have already discussed above that this could lead to port conflicts, so it would be preferable to avoid it. Instead, we can configure Flyway and JOOQ after we start the Postgres container (on a dynamic port).

Here is how this looks like (you can find the full example at https://github.com/sgrj/gradle-jooq-flyway).

jooq {
  configurations {
    create("main") {
      jooqConfiguration.apply {
        generator.database.inputSchema = "public"
      }
    }
  }
}

tasks.named<JooqGenerate>("generateJooq") {
  doFirst {
    val dbContainer by project.extra(
      PostgreSQLContainer("postgres:17").also { it.start() }
    )

    Flyway
      .configure()
      .locations("filesystem:$projectDir/src/main/resources/db/migration")
      .dataSource(dbContainer.jdbcUrl, dbContainer.username, dbContainer.password)
      .load()
      .migrate()

    jooq {
      configurations {
        getByName("main") {
          jooqConfiguration.jdbc.apply {
            url = dbContainer.jdbcUrl
            username = dbContainer.username
            password = dbContainer.password
          }
        }
      }
    }
  }

  inputs.files(fileTree("src/main/resources/db/migration"))
  allInputsDeclared = true

  finalizedBy("stopDbContainer")
}

tasks.register("stopDbContainer") {
  doLast {
    if (project.extra.has("dbContainer")) {
      val dbContainer: PostgreSQLContainer<*> by project.extra
      dbContainer.stop()
    }
  }
}

Note that in the main jooq block, we don’t specify a database at all.

When we run generateJooq, we want the Postgres container to be brought up automatically. We ensure this with the doFirst block in the generateJooq task. This block is run before the actual task starts. In it, we start a fresh Postgres container. The dbContainer variable contains the JDBC URL as well as username and password. We use them to manually run Flyway (we don’t use the flywayMigrate task), and afterwards, change the JOOQ configuration to also use the same database.

After the generateJooq task finished, we stop the Postgres container. In a first iteration, I did this in a doLast block inside the jooqGenerate task. But the doLast block is only executed when jooqGenerate was successful. If there are any problems with a migration or the source generation, the doLast block is not executed and the container keeps running. Instead, we create a new task and use the finalizedBy directive, which always runs the given task. Since we do the stopping in a separate task, we need to make the container available to this task. That’s what the project.extra is for.

Finally, we define the input files for jooqGenerate. This ensures that jooqGenerate is only run when those files change (so when we add a new migration), or when the output files are deleted or changed.

Conclusion

It took me several iterations to arrive at this solution. So far, I’m pretty happy with it. It is self-contained and independent of the environment it is run in, as long as it supports running test containers.

—Written by Sebastian Jambor. Follow me on Mastodon @crepels@mastodon.social for updates on new blog posts.