This week our list of supported billing currencies — eight of them, previously hardcoded across three Python modules that had to be edited in lockstep — moved into a database table an operator can edit from the admin. Adding a currency used to mean a code change, a migration, and a deploy. Now it's a row.
The uncomfortable part of this refactor is never the new table. It's the moment your code stops knowing the config and starts trusting the database to be there — correct, migrated, and reachable. And there are more moments where it isn't than you'd think.
The problem with "just read it from the DB"
Config that lives in constants gets read in places a database query can't safely go:
- At import time. Something, somewhere, builds a help string or a token set from the list when the module loads. On a fresh environment that happens before migrations have created the table.
- In tests that don't touch the database. Our fast test tier deliberately blocks DB access; hundreds of tests format currency amounts.
- On hot paths. These helpers run on every chat message we format. "One small query" per message is how ORMs quietly get slow.
And this is billing code. A currency symbol that falls back to the wrong thing doesn't crash — it renders a wrong invoice.
Keep a copy in code — then make drift loud
Our answer was to keep the hardcoded list anyway. After the refactor the same eight rows exist in three places, on purpose:
- The seed migration — literal values frozen inline, because a data migration that imports app code stops being a snapshot.
- A
_SEEDtuple in the module — the fallback served, uncached, whenever the table can't answer: mid-migration, no-DB tests, a broken connection. - The database rows — the live truth the admin edits.
Three copies of the same data is normally the cause of drift — it's exactly what we were refactoring away from. What makes it safe is that none of the copies is trusted to match by convention. They're bound by asserts:
- the seed migration asserts, after inserting, that it seeded exactly the expected set — no more, no fewer;
- a test compares the DB rows to
_SEEDfield by field — symbol, tax flag, FX mode, every attribute; - another test asserts every consumer (formatting, FX, pricing) serves the same set.
Any of the three copies changes without the others → a loud failure names the drift. The fallback isn't a "best effort" degraded mode; it's byte-identical to the seeded truth, so the path taken when the database is unreachable can't silently diverge from the one taken when it answers. That property — not the table — is what made this refactor genuinely behavior-preserving.
The runtime shape is one in-process cache over the table: a single query per process, invalidated by a signal when a row changes. Hot paths never notice the database exists.
Don't delete the drift test — promote it
We already had a guard test from the hardcoded era: it failed loudly if the three modules' lists ever diverged (they had, once). The tempting move during the refactor is to delete it — "there's only one source of truth now, nothing left to drift."
That's backwards. The refactor added copies (seed, fallback, table), so the guard has more to bind, not less. It survived, rewritten: where it used to compare three constants, it now pins the seeded rows to the code fallback and to what every consumer actually serves. Guard tests should be refactored like code, not retired with it.
A small Django bonus: callable choices
The last hardcoded remnant was the model field — billing_currency had a static choices list, meaning every future currency would need a migration anyway. Django 5 accepts a callable for choices: point it at a function that reads the cached table, and the dropdown and validation stay live without ever generating another migration for a new row. One final AlterField, and that treadmill ends.
The whole pattern cost us one deliberate copy in code and two asserts. What it bought: config an operator edits without a deploy, and a system that behaves identically on the one day the database can't answer.