Database Migration Workflow
Liquibase manages schema changes in the application server. Follow this workflow to keep the database and ERD docs in sync.
Quick start
-
Modify JPA entities with the desired changes.
-
Generate a draft changelog:
pnpm run db:draft-changelog -
Review
changelog_new.xmlcarefully – check for destructive operations. -
Rename and move the file to
server/src/main/resources/db/changelog/{id}_changelog.xml. -
Append an
<include>for the new file toserver/src/main/resources/db/master.xml.master.xmllists every changelog explicitly (not via<includeAll/>) so the apply order is a deliberate choice — append your new entry at the end of the list. Two PRs that both add migrations will then conflict inmaster.xmland the authors must agree on order, instead of<includeAll/>silently interleaving the timestamps. -
Regenerate docs:
pnpm run db:generate-erd-docs -
Add a release changeset (
pnpm changeset) — the Liquibase<changeSet>and the release changeset are different things, and a schema change needs both. Keep the summary user-facing; the release notes flag the migration automatically. See the release management guide. -
Commit the migration, the
master.xmledit, the changeset, and the documentation (docs/contributor/erd/schema.mmd).
:::danger Always validate Generated migrations can drop or rename columns unexpectedly. Double-check each changeset before committing. :::
Validation checklist
- Replace the autogenerated
user (generated)author with your GitHub username. - Prefer
renameColumnover drop/add sequences when renaming fields to avoid data loss. - Ensure new sequences start at
1unless you have a data migration plan. - Confirm destructive statements (
dropTable,dropColumn) are intentional and safe. - Run
pnpm run db:generate-erd-docsand inspect the diff before committing.
Safe rename example
<!-- ❌ Avoid drop + add when renaming -->
<dropColumn tableName="user" columnName="first_name"/>
<addColumn tableName="user">
<column name="firstName" type="VARCHAR(255)"/>
</addColumn>
<!-- ✅ Use renameColumn to preserve data -->
<renameColumn tableName="user" oldColumnName="first_name" newColumnName="firstName"/>
Changelog immutability policy
The Liquibase changelog chain is the upgrade path: every production and self-hosted
instance upgrades by replaying exactly these files, in master.xml order. That works only
if history never changes underneath an instance that already ran part of it.
- Released changesets are never edited. A changeset is released once it reaches
main— from that moment it may already be applied to a production database. Editing it causes checksum errors at boot on upgraded instances while fresh installs silently get a different schema. Fix mistakes forward with a new changelog file. (Also note that Liquibase excludes preconditions, contexts, and labels from checksums and silently ignores deleted changesets — so even "harmless" edits can desynchronize installed instances without any error.) master.xmlis append-only. Never remove or reorder existing<include>entries; new changelogs go at the end.- Destructive changes follow deprecate-then-remove across two releases. Renaming or dropping a table/column that shipped in a release happens in two steps: release N stops writing/reading it (and migrates data if needed), release N+1 drops it. A single release must stay bootable against the schema left behind by the previous one.
CI enforces 1 and 2 mechanically (see below); 3 is a review responsibility.
CI validation
Three GitHub Actions checks guard the database (all in ci-quality-gates.yml):
- Migration-chain replay (
Databasegate) – Applies the fullmaster.xmlchain vialiquibase:updateto an empty pg_partman PostgreSQL (the production image). A changelog that doesn't parse or apply fails here. This runs as the first half of the schema-drift check, so it exercises the same empty→head path a fresh install boots through. - Schema-drift + ERD validation (
Databasegate) – After applying the chain, diffs the resulting schema against the JPA entities (drift produces achangelog_new.xml) and against the committed ERD. Reproduce locally withpnpm run db:draft-changelog. - Changelog immutability (
Migrationsgate) – Enforces the policy above with a pure git diff against the merge-base: fails if any released file underdb/changelog/was modified, renamed, or deleted, or if an existingmaster.xml<include>line was removed, reordered, or edited. This catches exactly the edits Liquibase checksums miss (preconditions, contexts, labels, comments, and deleted changesets).
The replay applies every changeset (the Maven plugin sets no context filter), so it validates
that the whole chain is well-formed — it does not reproduce a specific instance's boot (a real
dev/prod boot filters by spring.liquibase.contexts) and, running against an empty database,
does not catch data-incompatible migrations (a NOT NULL add or a CHECK that existing rows
violate). The replay is also forward-only, so <rollback> blocks and onFail="MARK_RAN"
preconditions are never exercised. Those remain a review responsibility, enforced through the
deprecate-then-remove discipline above.
Entity change tips
- Prefer
renameColumnover drop/add pairs to avoid data loss. - Replace
user (generated)in Liquibase files with your GitHub username. - Sequences should start at
1unless explicitly required otherwise.
Example
@Entity
public class User {
@NonNull
private String email; // Newly added field
}
Generates:
<changeSet author="yourusername" id="1749286026779-1">
<addColumn tableName="user">
<column name="email" type="VARCHAR(255)">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
CI flow
Resources
- Liquibase documentation for change set syntax and best practices.
- Spring Data JPA reference for repository naming rules.