Minh Nguyen
All work

Case study — County of Orange

Replacing a 20-year-old property tax system

886,000 bills. $9.7 billion. A deadline set by statute.

Role
Technical lead, then sole owner
When
Summer 2024 — present
Stack
Angular 20 · .NET 9 · SQL Server · Azure DevOps

The problem

Orange County collected property taxes through a classic ASP application written around 2004. It worked, in the sense that money arrived. It also:

  • didn't function usefully on a phone
  • let a taxpayer pay the same parcel bill twice — pay in November, forget, pay again in December, and nothing stopped you
  • gave taxpayers no way to view or print what they'd already paid
  • had little to no accessibility
  • handed confirmation emails entirely to the payment vendor, so the county controlled neither their content nor their delivery
  • required a developer from a different county department to change anything

That last point matters more than it looks. The Treasurer-Tax Collector could not modify their own application. Opening or closing the payment window for a fiscal year meant asking another county department for a developer.

The constraints

The deadline was statutory. California property tax is delinquent after December 10 and April 10. Those dates are set in law. A software project does not move them, and missing one means taxpayers cannot pay a bill that is about to accrue a penalty.

The business logic was undocumented and unowned. The tax calculation lived in SQL scripts written by other county departments — routinely over a hundred lines, encoding rules those departments set. The people who wrote them were gone. Nobody remaining could explain how they worked.

The database rebuilds itself nightly. Another county department reloads the tax bill database on its own schedule. That process was theirs, it predated us, and it had to survive the rewrite untouched.

Two developers, six months. One working only on the frontend, and me on everything else — frontend, backend, database, and the build and release infrastructure. Summer 2024 to a December go-live.

What I decided

I owned the architecture and the migration strategy.

A .NET Core API with a separate .NET Core host serving the Angular application. One public tier, one internal tier, a clear boundary between them — which turned out to matter more than expected once payments came in-house.

Reuse the legacy database. Do not rewrite the legacy logic. This was the most important call and the least impressive-sounding. Those hundred-line SQL scripts encode property tax law for 886,000 bills. I could not verify a rewrite was correct, because there was nobody left to verify it against. So I didn't rewrite them — I moved them behind stored procedures. Identical behavior, but now there's a seam. When a rule changes, there's one place to change it.

The instinct is to rewrite the ugly thing. The ugly thing was calculating people's tax bills.

The frontend

Angular 16 at launch, since migrated to Angular 20, running zoneless. Signals, standalone components, and the current control flow syntax throughout.

Responsive, built from scratch — no component library. Reactive forms on the two paths that matter, parcel lookup and payment, with validation on both the client and the server, because the client's opinion about a payment is not authoritative.

No NgRx. This application looks up a bill and pays it. A state management library would have been a second architecture to maintain in exchange for nothing.

Paying exactly once

Under the previous payment processor, the county didn't control the payment flow at all — no callbacks, no confirmation emails, no transaction records on our side. When the county changed processors in December 2025, all of that became ours. So the duplicate payment problem the legacy system had, we now had to actually solve.

Four independent layers, each catching a different failure:

  1. Before submitting — check both our own records and the processor's, via their API, that no payment already exists for this parcel bill.
  2. At the callback — every transaction carries an order number we generate and send with the request. The callback lands on the public API for validation, then goes to the internal service, which enforces order-number uniqueness on write. That's a unique constraint on the column, not a check-then-insert in application code, so a duplicate callback can't win a race — it fails the insert. On a normal day you'd never see the difference. On a 10,000-transaction deadline day, you might.
  3. On a schedule — a job queries the processor for payments where the gateway timed out after the taxpayer had already paid, so nobody gets charged twice for one bill.
  4. At end of day — pull every successful transaction from the processor and confirm each one exists in county records.

Layers one through three prevent problems. Layer four assumes the first three failed and catches what got through. It's the one that earned its keep.

Cutting over, twice

Both cutovers were scheduled in the same narrow window: late December, after the December 10 delinquent date, when traffic is at its lowest. Twice, the riskiest action was taken in the calendar's quietest moment. That was deliberate.

Legacy to new, December 2024

A hard flip, executed inside an existing nightly maintenance window, so the cutover cost no availability of its own. We tested against a QA environment mirroring production, logged payment details before and after they left our servers, and accepted callbacks only from authenticated, authorized sources. The legacy application stayed up on a separate domain as a rollback path. We never needed it.

That one went cleanly.

Changing payment processors, December 2025

That one didn't.

What went wrong

The new processor's callback format was inconsistent and undocumented. Most callbacks were fine. Some arrived missing fields — payer email, first and last name. Our server threw on the malformed ones and refused to process them, which meant the payment was captured at the gateway, the transaction never reached county records, and the taxpayer got no confirmation email.

I found it by watching logs for the exceptions in the days after deployment.

The fix came from a decision made months earlier for an unrelated reason. Because we generate our own order number, send it with every request, and log the taxpayer and parcel details against it before the request leaves our system, we already held everything the response was missing. I patched the callback handler to backfill from our own records, cross-check against whatever the callback did send, and reconcile the missing transactions by order number. Then we pushed the processor to fix the format.

The order number was designed as an idempotency key. It turned out to be a recovery key. One identifier, two jobs — and the second one was the one that mattered.

Nobody lost money. Some people didn't get their receipt, and that was on us.

Where it stands

  • The legacy system is fully retired. Not running alongside. Not serving a few remaining routes. Off. Most migrations of twenty-year-old systems never finish; this one did, in about six months, with two developers, against a deadline nobody controls.
  • Four statutory deadline surges, no downtime. December 2024, April 2025, December 2025, April 2026 — handled with k6 load testing and response caching on the lookup endpoints.
  • Taxpayers can use it on a phone, view and print payment history, submit feedback, and pay by eCheck with no service fee.
  • Duplicate payments are prevented by design rather than by luck.
  • Confirmation emails are ours now — content and delivery both.
  • Support comes from the county instead of a vendor queue.

I've been the sole developer on it since phase one ended: support, maintenance, and every new feature.

What I'd tell you in an interview

Stated plainly

Accessibility followed county standards. There was no formal audit and no VPAT, and I'm not going to claim one.

It's a plain SPA with no server-side rendering. For a public site the obvious question is why. The answer is that nothing here is content anyone finds through search — every page that matters sits behind a parcel lookup. If the feedback and announcement surfaces grow into anything content-shaped, that calculation changes.

NextTax Bill Admin — the internal service behind it