Modernizing Government Software Without Breaking It
Government software has a reputation, and mostly it's deserved: dense forms, decades of accumulated rules, and a very low tolerance for downtime. At Catalis I work on Tax & CAMA — a suite that helps local governments run billing, collections, and property assessment across all 50 US states. When a system moves millions in annual tax revenue, "let's rewrite it" is rarely the right answer.
The interesting engineering problem isn't writing new code. It's replacing old code while the plane stays in the air.
Modernize the edges first
The safest place to start is the surface. Legacy back-ends often outlive three generations of front-ends, so wrapping a stable API in a modern React interface delivers visible value with almost no risk to the money-moving core. Users feel the upgrade immediately; the ledger logic doesn't change at all.
- Put a clean API boundary in front of the legacy data layer, even if it's just a thin façade at first.
- Rebuild one workflow end-to-end in React before touching the next — a vertical slice, not a horizontal layer.
- Keep the old screen reachable until the new one has run in production long enough to trust.
Treat data migrations as first-class features
Schema changes in this world are not chores you sneak into a Friday deploy. They get their own tickets, their own tests, and their own rollback plan. A reversible migration with a verified backup is worth more than any clever refactor.
In government software, boring deployments are the whole goal. Excitement means something went wrong.
Build reusable cores, not one-off screens
Across 50 states you see the same shapes over and over: a searchable roll, an assessment detail, a workflow with approvals. Investing early in a small set of reusable core components paid for itself many times — new modules dropped in weeks instead of months, and the UI stayed consistent without a design police force.
// A projection-only read keeps the assessment grid fast — we never
// materialize the full entity graph just to render a table row.
var rows = await db.Assessments
.Where(a => a.CountyId == countyId && a.TaxYear == year)
.Select(a => new AssessmentRow(a.Id, a.ParcelNumber, a.Owner, a.MarketValue))
.AsNoTracking()
.ToListAsync(ct);Modernization isn't a heroic rewrite. It's a hundred small, reversible decisions that each leave the system a little better than you found it — and never leave it worse.
Enjoyed this?
Let's talk about building something together.