Schema migrations run automatically at deploy, so every migration must be expand/contract
Context
commerce-api owns the commerce database schema; commerce-worker shares the same MAIN database but does not migrate it. Under the Coolify build-and-swap model (ADR 0015) a deploy produces a new image and swaps the container while the previous release is still serving. Migrations therefore cannot be a manual, separately-timed step — they have to run as part of the deploy — which means the database is changed around the same moment the old code is still reading it.
ADR 0003 recorded this constraint for the discount work (migrations at image-build time). This ADR generalises it as the platform rule and records the current mechanism.
Decision
Run migrations automatically as part of every deploy, before the new code serves, and require every migration to be expand/contract.
- Automatic, at container boot (and historically at build). The commerce-api container entrypoint runs
mix ecto.migratebeforemix phx.serveron every start; the Dockerfile also runs a build-timemix ecto.migrate. Boot-time migration is the reliable one — a build-time migration "only updated whichever DB was reachable during the build, which isn't necessarily prod."Ecto.Migratoris idempotent, so running on every boot is safe. A migration that fails (e.g. DB unreachable) exits non-zero and the container refuses to start — the intended fail-fast, since a half-migrated app on an old schema would corrupt data. - commerce-api is the sole migrator. commerce-worker runs only
phx.server; it never migrates the shared schema, so there is one writer of schema changes. - Expand/contract is mandatory. Because the previous release keeps serving while (or just before) the schema changes, a destructive change — dropping or renaming a column the old code still reads — breaks live production immediately. So: add columns/tables in one release; drop superseded ones only in a later release, once no running code touches them. (This is why the discount work kept the redundant
usage_countcolumn rather than dropping it in the same release — ADR 0003.)
Consequences
- Positive: deploys are self-contained — schema and code ship together, no out-of-band migration step to forget or mis-time; a bad migration fails the deploy instead of booting a broken app.
- Neutral / to revisit: a build-phase migration can migrate prod even if the deploy later fails (the image export step is the fragile part), so
schema_migrationsmay already record a change after a "failed" deploy — diagnose DB state before assuming a failed deploy left the schema untouched. - Cost / discipline: every schema change is a two-release dance for anything destructive; column drops, renames and NOT-NULL tightenings must be sequenced across releases. This is a permanent constraint on how migrations are written, enforced by review.
Alternatives considered
- A manual / gated migration step, run at a chosen time: rejected — it decouples schema from the release and invites drift and forgotten steps; running migrations in the deploy keeps them lockstep with the code, at the price of the expand/contract discipline.
- Blue/green or maintenance-window migrations that avoid the overlap: not adopted — the build-and-swap model always has the old release serving during the change; expand/contract makes overlap safe without a maintenance window, whereas stop-the-world migrations would mean downtime on every schema change.
- Letting each service migrate: rejected — two writers of the shared schema would race and disagree; commerce-api is the single migrator and the worker only reads the schema it is given.