Financial regulator
Bank product comparison API
- Year
- 2023
- Role
- Sole backend engineer
- Outcomes
- p95 of 82 ms on shared hosting
Context
The National Bank of Tajikistan publishes deposit terms for every bank in the country. Until then an analyst assembled the comparison by hand: fourteen websites, rates copied into a spreadsheet, a PDF refreshed once a week. The business problem was not "build an API" but to take a weekly manual chore off a person and give the banks one contract instead of fourteen different exports.
Constraints
- Shared hosting with no access to PHP configuration and no long-lived processes
- PHP 7.4, no framework, no composer — every line vendored in the repository
- Cron limited to one run every fifteen minutes
- Fourteen banks as sources, each with its own export format
Solution
The key decision was to separate reads from writes in time. A user request never talks to a bank; it reads a snapshot prepared in advance.
The file cache is not an optimisation here but the way to fit the budget: on shared hosting, opening a MySQL connection costs more than reading a file, and that is what shaped the response format.
function cache_get(string $key, int $ttl): ?array
{
$path = CACHE_DIR . '/' . hash('xxh3', $key) . '.json';
$stat = @stat($path);
if ($stat === false || (time() - $stat['mtime']) > $ttl) {
return null;
}
$raw = @file_get_contents($path);
return $raw === false ? null : json_decode($raw, true);
}I wrote normalisation as fourteen separate parsers behind a shared interface. That honestly reflected reality, but every format change at a bank meant a code change and a release. The better shape would have been a declarative field-mapping table stored alongside the data.
Architecture
Key decisions
Where to keep the collected snapshot
What I chose
A JSON file cache invalidated by mtime, with MySQL as the source of truth and the fallback path on a miss.
What I rejected
Redis or memcached: neither exists on the shared plan, and asking for one means changing the procurement perimeter.
Why
The only things I controlled were the filesystem and the code. A solution requiring a new service is not a solution here — it is a request for approval. The file cache produced the same 82 ms without a single email.
How to refresh data without long-lived processes
What I chose
A cron collection pass every fifteen minutes, entirely separate from the user request.
What I rejected
Refresh-on-demand when the cache goes stale: the first unlucky visitor would wait for a walk across fourteen banks.
Why
Separating reads from writes in time is the only way to hold p95 when the sources answer in seconds. Freshness costs at most fifteen minutes, and for deposit rates that is nothing.
What was hard
Problem
Four banks served HTML instead of an export, and their markup changed without warning.
Solution
The parser for those sources validates the structure before reading it, and on a mismatch it does not fail — it marks the source stale. The response goes out with previous data and a flag, not a 500.
Problem
The host killed any process running longer than thirty seconds, and a full walk across all banks did not fit.
Solution
The collector visits four banks per run and remembers its position. A full circle takes an hour, which fits the freshness requirement.
Outcomes
- p95
- 82
- requests per second
- 140
- uptime over 12 months
- 99.95