Per-customer date formats in a multi-tenant SaaS
Every customer saw dates in one country's convention, and half of them were reading operational records that could be off by months.
What we did not have
Customers could not choose how dates were shown to them.
The product was built for one market, and that market's date convention was written into the code rather than stored as a setting. There was no single place that decided how a date should look — the decision was repeated in hundreds of places across four codebases.
The problem
The product served customers in several countries, and every date was displayed in one country's format.
- 03/07/2026 means two different things depending on where you are. Half the customer base was reading dates that could be off by months.
- These were operational records — appointment, treatment and follow-up dates. A misread date is not a cosmetic issue; it has real-world consequences.
- It was blocking sales. "Can we see dates our way?" came up in prospect conversations, and the answer was no.
The work turned out to be far larger than changing a format string. Date formats were written in seven different token languages across the four stacks — the same format has to be expressed differently for the backend, two front-end libraries, a date picker, a chart library and the database itself. 132 of those format strings lived inside database aggregation queries, across 66 files, so dates were being formatted before the application ever saw them. The same files that displayed dates also stored them, and one wrong classification would silently corrupt stored data. On top of that, every existing customer had to see absolutely no change, provably, on release day.
How I solved it
- Made one setting the single source of truth, stored per customer, and generated seven translation tables from it — one per token language. After that, no format literal survives anywhere in the codebase.
- Classified every single call site as display or storage. Stored values, API payloads, machine-readable strings and sort keys are untouchable. Only what a human reads changes. That rule, applied without exception, is what kept a 515-file change from ever touching data.
- Designed the feature to be invisible until a customer opts in. Every fallback returns the original behaviour, so existing customers were provably unaffected — which made a single release safe instead of a staged rollout.
- Pinned outbound data feeds to fixed formats. Five feeds are machine-parsed by other companies. If one silently switched convention nothing would error anywhere; the data would just arrive wrong. Each is pinned with a comment saying why it must never follow the customer's setting.
- Separated the displayed value from the sort key. Formatting a date inside a database query turns it into text, and sorting after that sorts text. In a day-first format, "sort by date" quietly becomes "sort by day of the month" — a pre-existing bug, invisible in the original format, guaranteed to surface with the first international customer.
- Made input parsing format-aware too, not just output, so a user typing a date in their own convention is read correctly.
- Chose the pragmatic architecture over the clean one. A central context service was the textbook answer and the wrong one for a legacy codebase mid-refactor. Per-request caching in the existing helper gave about 90% of the benefit for about 10% of the blast radius.
- Wrote the rules down — ten numbered date-handling rules that became the reference for the project, plus 152 comments explaining why a decision must stay, aimed at whoever touches this next.
- Restructured delivery around the product, not the repositories. After a discovery pass invalidated several assumptions in the original design, I split the work into 26 feature-area plans, each spanning all four codebases and each gated by a human test pass before the next began.
The results
Shipped in a single release, four pull requests, the same day — with no staged rollout and no feature flag needed.
- Scope
- 4 repositories, 515 files, +6,737 / −3,796 lines, 231 commits
- Build and testing time
- 30 days, one developer
- Rollbacks or reverts
- 0
- Data corruption or data-loss incidents
- 0
- Date-related production errors in the 30 days after release
- 0
- Impact on existing customers
- Zero change, by design
Found and fixed before any feature code was written: four latent bugs that were invisible in the original format and would each have surfaced with the first international customer, including the database sorting issue above.
Left behind: the main front-end application had no test runner and no tests when I started. It now has both.