<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Behzod Halil</title>
    <description>The latest articles on DEV Community by Behzod Halil (@behzodhalil).</description>
    <link>https://dev.to/behzodhalil</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4097390%2F8f877833-a835-4080-9085-97070b6bd08e.jpg</url>
      <title>DEV Community: Behzod Halil</title>
      <link>https://dev.to/behzodhalil</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/behzodhalil"/>
    <language>en</language>
    <item>
      <title>jOOQ codegen from Flyway migrations, with no database</title>
      <dc:creator>Behzod Halil</dc:creator>
      <pubDate>Sat, 29 Aug 2026 15:18:10 +0000</pubDate>
      <link>https://dev.to/behzodhalil/jooq-codegen-from-flyway-migrations-with-no-database-1mia</link>
      <guid>https://dev.to/behzodhalil/jooq-codegen-from-flyway-migrations-with-no-database-1mia</guid>
      <description>&lt;p&gt;jOOQ generates its code from a schema, which means something has to be holding a schema at build time. The usual answer is a database, and a database is the one thing our CI does not have.&lt;/p&gt;

&lt;p&gt;StockPlus runs a Spring Boot backend where every query is written in the jOOQ DSL, so nothing on the server compiles until the generator has produced its classes. The GitHub Actions workflow has no &lt;code&gt;services:&lt;/code&gt; block, no Docker, and no Postgres. This is how the schema gets there anyway, which property in the config is the one to read twice, and the single migration mistake that no build configuration can catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we stood
&lt;/h2&gt;

&lt;p&gt;Three facts, and the shape of the problem falls out of them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flyway owns the schema. It is described by 54 migration files, latest version &lt;code&gt;V56&lt;/code&gt;, applied in order at startup. The description already exists, in the repo, in SQL.&lt;/li&gt;
&lt;li&gt;Code generation is not an occasional chore. The Gradle config sets &lt;code&gt;generateSchemaSourceOnCompilation.set(true)&lt;/code&gt;, so it runs before every compile, on a laptop and in CI alike. Anything it depends on is on the critical path of every build.&lt;/li&gt;
&lt;li&gt;CI has two jobs, &lt;code&gt;:server:test&lt;/code&gt; and &lt;code&gt;:server:bootJar&lt;/code&gt;, on a plain &lt;code&gt;ubuntu-latest&lt;/code&gt; runner with a JDK and a Gradle cache. No service container, no database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the schema is right there in a directory, and the question is only whether the code generator can be made to read it. What we ended up with is one directory read by two different things at two different times:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fncf9b290o8il89hyt8uy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fncf9b290o8il89hyt8uy.png" alt="The migration directory feeds two readers. At build time, DDLDatabase passes the SQL through the jOOQ parser to produce 164 generated files covering 53 tables. At runtime, Flyway applies the same files to produce the Postgres schema." width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One directory, two readers. Neither of them needs the other to have run.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Four ways to hand jOOQ a schema, and why three of them lose
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Point the generator at a live database.&lt;/strong&gt; This is what most jOOQ setups do and it is the path of least resistance. It also makes the build depend on a machine. Whoever ran migrations last decides what your generated API looks like; a colleague sitting on an older branch generates a different one; CI needs a service container and a wait-for-healthy step. Worst of all, drift is silent in the direction that hurts: apply a migration by hand, never commit it, and codegen cheerfully generates the column while the repo has no record of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start a container, migrate, generate, tear it down.&lt;/strong&gt; &lt;a href="https://java.testcontainers.org/modules/databases/postgres/" rel="noopener noreferrer"&gt;Testcontainers&lt;/a&gt; does this well and it is the right answer for a lot of projects, because a real Postgres accepts anything Postgres accepts. It needs a Docker daemon wherever the build runs, which our runner does not have, and it puts a container start on the path of every compile rather than once per CI run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Declare the schema a second time, as entities.&lt;/strong&gt; The JPA answer, and it is ruled out here for a reason that has nothing to do with taste: two descriptions of one schema drift, and the one that drifts is never the one you are looking at.&lt;/p&gt;

&lt;p&gt;The fourth option is the one that fits. &lt;code&gt;jooq-meta-extensions&lt;/code&gt; ships &lt;code&gt;DDLDatabase&lt;/code&gt;, which runs DDL through jOOQ’s own SQL parser and builds an in-memory schema out of the result. No server, no container, and no second declaration. It reads the same files Flyway applies. jOOQ documents it under &lt;a href="https://www.jooq.org/doc/latest/manual/code-generation/codegen-ddl/" rel="noopener noreferrer"&gt;code generation from DDL files&lt;/a&gt;, and the Gradle side is the &lt;a href="https://github.com/etiennestuder/gradle-jooq-plugin" rel="noopener noreferrer"&gt;gradle-jooq-plugin&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The configuration is about ten lines
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;jooqGenerator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.jooq:jooq-meta-extensions:${libs.versions.jooq.get()}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"org.jooq.meta.extensions.ddl.DDLDatabase"&lt;/span&gt;
    &lt;span class="n"&gt;properties&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Property&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;withKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"src/main/resources/db/migration/*.sql"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Property&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;withKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sort"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;withValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"flyway"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Property&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;withKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"defaultNameCase"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;withValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"lower"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Kotlin, server/build.gradle.kts&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scripts&lt;/code&gt; is a glob over the Flyway directory. Note what is not there: a copy, an export, a dump. It is the same directory Flyway reads at runtime, so there is no second artifact to keep in sync and no way for the generated code to describe a schema the application will not actually get.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sort&lt;/code&gt; is the one to read twice. It decides the order the scripts are applied in before the schema is assembled, and the default is lexical. Lexically, &lt;code&gt;V10&lt;/code&gt; sorts before &lt;code&gt;V2&lt;/code&gt;. Apply DDL in that order and a migration that alters a table runs before the migration that creates it. The failure itself is loud; the cause is not, because everything worked for nine migrations and the tenth is where it starts. Setting it to &lt;code&gt;flyway&lt;/code&gt; parses the version out of the filename and orders the way Flyway does.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;defaultNameCase&lt;/code&gt; matters because Postgres folds unquoted identifiers to lower case. Leave it and the parsed schema keeps whatever case the DDL was typed in, so the generated constants stop matching the names the running database will answer to. All three are documented among the &lt;a href="https://www.jooq.org/doc/latest/manual/code-generation/codegen-configuration/" rel="noopener noreferrer"&gt;code generation configuration options&lt;/a&gt;, and the version scheme &lt;code&gt;sort&lt;/code&gt; is parsing is Flyway’s own, from the &lt;a href="https://documentation.red-gate.com/fd/flyway-documentation-138346877.html" rel="noopener noreferrer"&gt;Flyway documentation&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A config property that looks cosmetic in a code generator is usually encoding an assumption about ordering or identifiers. Those are the two places where a build can be correct on every input you have tried so far.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;This is a parser, not a database, and the difference shows up in three places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It constrains what you may write in a migration.&lt;/strong&gt; Every statement has to be something jOOQ’s parser understands. Across all 54 migration files here there is no &lt;code&gt;CREATE EXTENSION&lt;/code&gt;, no &lt;code&gt;DO $$ ... $$&lt;/code&gt; block, and no function or trigger. That stopped being a coincidence the moment the build depended on it: reach for a Postgres-only construct now and the thing that tells you no is the code generator, not the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It generates what Flyway would produce, not what production has.&lt;/strong&gt; If someone altered a table by hand in production, this setup cannot see it, because it never looks at production. A generator pointed at a live database can. That is the one honest argument for the option we rejected, and it is worth saying plainly rather than discovering later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no data, and therefore no statistics.&lt;/strong&gt; Anything in a generator that wants to inspect rows rather than structure has nothing to work with.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it bought
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Database at build time&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker in CI&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation, jOOQ's own timer&lt;/td&gt;
&lt;td&gt;923 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full &lt;code&gt;generateJooq&lt;/code&gt;, &lt;code&gt;--rerun-tasks --no-daemon&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;22.6 s wall clock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generated output&lt;/td&gt;
&lt;td&gt;164 files, 53 tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Descriptions of the schema in the repo&lt;/td&gt;
&lt;td&gt;one&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Measured 27 August 2026, Apple M4, Gradle with no daemon, no Postgres installed and the Docker daemon stopped.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The 22.6 seconds is a cold, deliberately pessimistic number: no daemon and every task re-run. The 923 ms is what generation itself costs once Gradle has started, and it is the figure that matters, because &lt;code&gt;generateSchemaSourceOnCompilation&lt;/code&gt; means you pay it often.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one mistake this cannot catch
&lt;/h2&gt;

&lt;p&gt;This is exactly as trustworthy as the migration directory itself. Nothing in the config above has an opinion about whether that directory makes sense, and there is one way for it to stop making sense that no amount of care prevents.&lt;/p&gt;

&lt;p&gt;Two branches each add a &lt;code&gt;V53&lt;/code&gt;. Both are individually correct, both pass review, both pass their own tests. Flyway refuses to start when two migrations share a version, and it throws during &lt;code&gt;validate&lt;/code&gt;, before Spring’s context is up, so the application does not boot at all. The database is untouched, nothing is applied, and the deployment is dead.&lt;/p&gt;

&lt;p&gt;It happened here on 25 August 2026: &lt;code&gt;V53__institution_curation_identity.sql&lt;/code&gt; and &lt;code&gt;V53__add_briefing_push_prefs.sql&lt;/code&gt; merged minutes apart.&lt;/p&gt;

&lt;p&gt;The reason this needs more than discipline is that the collision is invisible in the change that creates it. Each diff is clean on its own. The conflict comes into existence at the moment the second branch merges, which is precisely when nobody is reading. Checking the highest version on main before merging does not help either: main can gain a migration between the check and the merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat for the curious
&lt;/h2&gt;

&lt;p&gt;The fix is a test, not a process, because it has to look at the whole tree rather than at a diff.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;migrationDir&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"src/main/resources/db/migration"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="cm"&gt;/** `V53__add_briefing_push_prefs.sql` -&amp;gt; `53`. */&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;versionOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;Regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"""^V(\d+(?:[._]\d+)*)__"""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;groupValues&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`GIVEN&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;migration&lt;/span&gt; &lt;span class="n"&gt;directory&lt;/span&gt; &lt;span class="nc"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;versions&lt;/span&gt; &lt;span class="n"&gt;are&lt;/span&gt; &lt;span class="n"&gt;grouped&lt;/span&gt; &lt;span class="nc"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;none&lt;/span&gt; &lt;span class="nf"&gt;repeats`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// GIVEN&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;migrations&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;migrationDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listFiles&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;".sql"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;orEmpty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;// WHEN&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;duplicates&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;migrations&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mapNotNull&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;versionOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;let&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filterValues&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// THEN&lt;/span&gt;
    &lt;span class="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duplicates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="cm"&gt;/* ... names the colliding files ... */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Kotlin, FlywayMigrationVersionsTest.kt&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The name carries the whole specification and the body markers stay bare, which is this repo’s convention rather than a flourish. A failing build prints that name, so the report says what broke without anyone opening the file.&lt;/p&gt;

&lt;p&gt;It runs against whatever is actually on the branch, so a merge or a rebase that introduces the collision fails the build rather than the deploy. That is the entire trick: move the check from the diff, where the problem is invisible, to the directory, where it is obvious.&lt;/p&gt;

&lt;p&gt;A second test in the same class catches the quieter sibling. A filename typo like &lt;code&gt;V53_add_thing.sql&lt;/code&gt;, with one underscore instead of two, is silently ignored by Flyway rather than rejected. The migration never runs, the column it adds never exists, and the first thing you learn about it is a query failing in production.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you copy this, decide what you want it to do about repeatable migrations. Our version regex returns null for an &lt;code&gt;R__&lt;/code&gt; file and the second test asserts that nothing is null, so adding a repeatable migration would fail the build with a message about an unparseable filename. There are none in this repo today, which is the only reason the two tests agree.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What generalises
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;When a build step needs a schema, prefer the description you already version over a server you have to run. The migrations were always the source of truth; pointing the generator at them removes a moving part rather than adding one.&lt;/li&gt;
&lt;li&gt;Config properties that look cosmetic usually encode an ordering or an identifier assumption. Those are the ones that hold for every input you have tried and then stop.&lt;/li&gt;
&lt;li&gt;A mistake that is invisible in its own diff cannot be caught by review, however careful. It needs something that looks at the whole tree, and that something is usually cheaper to write than the incident it prevents.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is really about jOOQ. It is about which artifact you let a build depend on, and preferring the one that is already versioned.&lt;/p&gt;

&lt;p&gt;The client half of this app produced the same lesson in a different register: &lt;a href="https://getstockplus.app/blog/onesignal-kotlin-multiplatform-push" rel="noopener noreferrer"&gt;moving its push pipeline to OneSignal&lt;/a&gt; turned up a send that returned HTTP 200 while delivering nothing to anybody. A duplicate migration version and a successful-looking failed push are the same species of bug: the system had every chance to say so and did not.&lt;/p&gt;

&lt;p&gt;The server described here backs &lt;a href="https://getstockplus.app/" rel="noopener noreferrer"&gt;StockPlus&lt;/a&gt;, an app for following congressional and insider filings, 13F holdings and price alerts on iOS and Android. The interesting parts of building it are mostly the parts you cannot see from a store listing, which is what this blog is for.&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>java</category>
      <category>sql</category>
      <category>devops</category>
    </item>
    <item>
      <title>Shipping OneSignal push in a Kotlin Multiplatform app</title>
      <dc:creator>Behzod Halil</dc:creator>
      <pubDate>Fri, 28 Aug 2026 13:57:38 +0000</pubDate>
      <link>https://dev.to/behzodhalil/shipping-onesignal-push-in-a-kotlin-multiplatform-app-5fhj</link>
      <guid>https://dev.to/behzodhalil/shipping-onesignal-push-in-a-kotlin-multiplatform-app-5fhj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://getstockplus.app/blog/onesignal-kotlin-multiplatform-push" rel="noopener noreferrer"&gt;getstockplus.app&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most of a push-notification implementation has nothing to do with the product. Token tables, refresh callbacks, stale-token pruning, a separate iOS pipeline: none of it is the feature, and all of it needs maintaining.&lt;/p&gt;

&lt;p&gt;StockPlus is a Kotlin Multiplatform app whose core product is price alerts, delivered as push notifications. This is how we moved it from direct Firebase Cloud Messaging to OneSignal: what actually changed architecturally, and the four silent failures that had to be hunted down along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Addressing users, not devices
&lt;/h2&gt;

&lt;p&gt;Direct FCM builds a message around a registration token. One token, one device. That sounds simple, but it quietly generates a lot of server-side work: a table of tokens per user, a refresh callback because tokens rotate, pruning on &lt;code&gt;UNREGISTERED&lt;/code&gt; because they go stale, a hand-written fan-out loop because a phone and a tablet are two rows, and an entirely separate iOS pipeline with separate credentials.&lt;/p&gt;

&lt;p&gt;OneSignal inverts the unit of addressing. The client declares an identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nc"&gt;OneSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server then addresses that identity instead of a device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"app_id"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"target_channel"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="s"&gt;"push"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"include_aliases"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nf"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"external_id"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="s"&gt;"headings"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nf"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s"&gt;"contents"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nf"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s"&gt;"data"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One HTTP call reaches every device where that user is logged in, on both platforms, and the OneSignal path stores no device tokens of its own. (The legacy &lt;code&gt;users.fcm_token&lt;/code&gt; column is still there, still feeding the old channel described below.) The real change is the unit of addressing, not the vendor. The migration removed more lines than it added, which is usually a good sign: deleted code has no bugs and needs no tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  No flag day
&lt;/h2&gt;

&lt;p&gt;The pipeline was already live and price alerts are the product, so a big-bang cutover was out. The new channel went in beside the old one, selected purely by configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;isEnabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;
    &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNotBlank&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;restApiKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNotBlank&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;onesignal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${ONESIGNAL_APP_ID:}&lt;/span&gt;       &lt;span class="c1"&gt;# unset =&amp;gt; legacy FCM path&lt;/span&gt;
    &lt;span class="na"&gt;rest-api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${ONESIGNAL_REST_API_KEY:}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploying the code is not the cutover: the binary behaves exactly as before until the credentials are set. Rollback is an environment variable rather than a revert. Local dev and CI have no credentials, so they transparently use the legacy path and nothing ever accidentally sends from a laptop. Note which way the default points: doing nothing gets you the old, proven behaviour.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The legacy path has to stay fully functional: retries, backoff, stale-token pruning, all of it. A fallback that has quietly rotted is not a fallback.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Durable first, push second
&lt;/h2&gt;

&lt;p&gt;This is the design decision worth defending hardest, and it applies whichever vendor you pick: a push notification is not the notification. It is an announcement that a notification exists.&lt;/p&gt;

&lt;p&gt;Every send path persists a durable inbox row first, then attempts the push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;sendAlertTriggered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;fcmToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt;
    &lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;alertType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AlertType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt;
    &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="py"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildAlertMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alertType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// Inbox is the source of truth; the push below is best-effort.&lt;/span&gt;
    &lt;span class="n"&gt;notificationRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alertType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;deliverPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fcmToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push delivery is genuinely unreliable, and not because the vendors are bad at it: denied permissions, offline devices, OS throttling, rotated tokens, guest users. On iOS, best-effort delivery is the explicit platform contract. Ordering it durable-first turns each of those failures from lost product data into a missed buzz: the alert is sitting in the inbox when the user next opens the app. It also lets the whole push layer be best-effort all the way down: no retries blocking a request, no transaction spanning an HTTP call, no error a user can ever see.&lt;/p&gt;

&lt;h2&gt;
  
  
  One declaration, opposite directions
&lt;/h2&gt;

&lt;p&gt;Shared code depends on an &lt;code&gt;expect class&lt;/code&gt;. Both platforms satisfy the same declaration, but they satisfy it in opposite directions, so it has to stay narrow enough that neither implementation needs to widen it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PushIdentityBinder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Android is the easy one: the OneSignal SDK is a Gradle dependency, so the implementation calls it directly. It never throws (any vendor surprise degrades to "no push", never to "sign-in crashed"):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PushIdentityBinder&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;runCatching&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;OneSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;runCatching&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;OneSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;iOS is where it gets interesting. The OneSignal iOS SDK is a Swift package, and Kotlin cannot see it: Swift sees Kotlin through the generated framework, but not the reverse. So on iOS the control flow is inverted: Kotlin holds the closures, and Swift fills them in at startup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;IosPushIdentityBridge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;onLogin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;)?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;onLogout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;)?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One wrinkle costs a confusing hour the first time. The module holding that object is an &lt;code&gt;implementation&lt;/code&gt; dependency of the iOS framework rather than an &lt;code&gt;export&lt;/code&gt;ed one, so its symbols never appear in the framework header and Swift cannot see the bridge at all. The fix is a thin re-export in the module that &lt;em&gt;is&lt;/em&gt; exported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;setPushIdentityHandlers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;onLogin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;onLogout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;IosPushIdentityBridge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onLogin&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;onLogin&lt;/span&gt;
    &lt;span class="nc"&gt;IosPushIdentityBridge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onLogout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;onLogout&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;PushBridgeKt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setPushIdentityHandlers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;onLogin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="kt"&gt;OneSignal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nv"&gt;onLogout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;OneSignal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That call has to run before the root component spins up. A cold start with a saved session binds identity immediately, and getting the order wrong silently no-ops on exactly the launch that matters most: a returning, logged-in user.&lt;/p&gt;

&lt;p&gt;Desktop binds a no-op. Three platforms, three strategies (direct call, inverted callback, deliberate nothing) behind one interface with zero conditionals in shared code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing threw, nothing was red
&lt;/h2&gt;

&lt;p&gt;Push is a pipeline of best-effort steps, which means its default failure mode is silence. Four silent failures turned up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The successful failure.&lt;/strong&gt; OneSignal returns HTTP 200 with a populated &lt;code&gt;errors&lt;/code&gt; field when no subscribed device matches the external id, so a naive &lt;code&gt;response.isSuccessful&lt;/code&gt; check reports permanent success whilst delivering nothing, forever. The body has to be parsed.&lt;/p&gt;

&lt;p&gt;The obvious parse is wrong too, and we shipped it before fixing it. Treating any non-empty &lt;code&gt;errors&lt;/code&gt; array as failure misreads a broadcast: a chunk where one id out of five hundred is unknown lists that id under &lt;code&gt;errors&lt;/code&gt; and still delivers to the other four hundred and ninety-nine. &lt;code&gt;recipients&lt;/code&gt; is the field that separates partial from total failure, so errors are logged for visibility and only a zero recipient count is treated as a failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;json&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readTree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;responseBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;errors&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"errors"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;hasErrors&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isMissingNode&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hasErrors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OneSignal reported errors for {}: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// An absent "recipients" falls back to the errors-only test, so a response&lt;/span&gt;
&lt;span class="c1"&gt;// shape we do not recognise is still a failure rather than silently a success.&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;recipients&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"recipients"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;deliveredNothing&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isInt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asInt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;hasErrors&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Transport success is not application success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The misconfiguration in camouflage.&lt;/strong&gt; The legacy path had two skip conditions with byte-identical behaviour: Firebase never initialised (someone forgot an env var), and no token on file (completely normal for guests). Both silently sent nothing. Now the first logs a &lt;code&gt;warn&lt;/code&gt; naming the exact variable to check, and the second logs &lt;code&gt;debug&lt;/code&gt;. When a broken configuration and a normal condition produce the same behaviour, they must not produce the same log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The early return that only breaks one platform.&lt;/strong&gt; This one nearly shipped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;operator&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;AppResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Identity binding FIRST: it needs only the userId. On iOS the FCM token&lt;/span&gt;
    &lt;span class="c1"&gt;// is always null; OneSignal is the only push channel there.&lt;/span&gt;
    &lt;span class="n"&gt;sessionManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentUserId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pushIdentityBinder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;resolvedToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="n"&gt;pushTokenProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolvedToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNullOrBlank&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AppResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"No push token available"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"NO_PUSH_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pushTokenRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolvedToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pushTokenProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The obvious ordering (fetch the token, bail if null, then do the rest) gates identity binding behind a token that is &lt;em&gt;always&lt;/em&gt; null on iOS. Android works perfectly; iOS never calls &lt;code&gt;login()&lt;/code&gt;, never matches a send, and reports no error anywhere. In shared multiplatform code an early return guards everything after it on every platform, so it is worth asking whether the guard's precondition is even meaningful on all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The transitive dependency.&lt;/strong&gt; The OneSignal dashboard showed zero Android recipients whilst iOS delivered fine. The SDK's verbose logging showed &lt;code&gt;FIREBASE_FCM_INIT_ERROR&lt;/code&gt;: the device had never subscribed at all. The dependency tree explained why: OneSignal 5.9.8 supports &lt;code&gt;firebase-messaging [23.0.8, 24.0.99]&lt;/code&gt;, but an unrelated Firestore feature pulled in the Firebase BOM, which forced 24.1.1. Gradle's conflict resolution picks the &lt;em&gt;highest&lt;/em&gt; version, not one satisfying every constraint, so the build stayed green and the registrar died on real devices.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;configurations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configureEach&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;resolutionStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;force&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.google.firebase:firebase-messaging:24.0.0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That block is declared in three modules (the push module itself, the shared entrypoint, and the Android application module) and the duplication is load-bearing: &lt;code&gt;resolutionStrategy&lt;/code&gt; only governs the declaring module, and it is the application module that resolves the classpath actually shipping in the APK. Your version catalog records what you asked for; &lt;code&gt;./gradlew :entrypoint:android:dependencies&lt;/code&gt; records what you got.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two traps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Do not declare your own &lt;code&gt;MESSAGING_EVENT&lt;/code&gt; service. It wins over the one OneSignal merges in from its AAR, and data-only pushes get silently dropped. Our manifest carries a permanent comment saying so, because there is no lint check for code that must not exist.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;OneSignal.logout()&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; clearing the session: it needs the outgoing access token. Reverse the order and a signed-out phone keeps receiving the previous account's price alerts. That is not a missing-notification bug, that is a data leak, one line of ordering away.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What actually mattered
&lt;/h2&gt;

&lt;p&gt;The SDK calls really are two lines, and the two lines were never the work. What mattered was picking the addressing model before the vendor, making push best-effort by making something else durable first, migrating behind configuration with the safe path as the default, and hunting silent failures deliberately: verbose vendor logging on day one, parsing bodies rather than trusting status codes, and giving misconfiguration a louder log than normal operation.&lt;/p&gt;

&lt;p&gt;Very little of this is OneSignal-specific. The durable-first contract, the config-flag migration, and the control-flow inversion apply to any platform SDK your shared Kotlin code cannot see.&lt;/p&gt;

&lt;p&gt;The same habit turns up on the server side of this app, where &lt;a href="https://getstockplus.app/blog/jooq-codegen-from-flyway-migrations" rel="noopener noreferrer"&gt;jOOQ generates its code straight from the Flyway migrations&lt;/a&gt;. Different stack, same question: which artifact are you willing to let the build depend on, and will it tell you when it is wrong?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Behzod Halil. This is the push pipeline behind StockPlus, the Kotlin Multiplatform app it was built for. The &lt;a href="https://getstockplus.app/blog/onesignal-kotlin-multiplatform-push" rel="noopener noreferrer"&gt;original of this post&lt;/a&gt; lives on getstockplus.app.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>kotlinmultiplatform</category>
      <category>android</category>
      <category>ios</category>
    </item>
  </channel>
</rss>
