← All work

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.

cache/file.php
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

The two loops never meet: the slow one collects, the fast one serves. 14 banks → collector; collector → normalize; normalize → mysql; client → router; router → cache.file; cache.file → mysql (miss)miss14 bankscollectornormalizemysqlclientroutercache.file
The two loops never meet: the slow one collects, the fast one serves

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

Stack