At Systems Limited I worked on the Visa by Package dashboards: four for investors and five for admins. None of their data is ours. It lives in another system, which exposes it through its own APIs. Our job was to put those numbers on screen quickly, and keep them honest.
Why not call their APIs from the frontend
The quickest version is to let each dashboard call the APIs it needs straight from the browser. It works in a demo, and it costs you in four ways:
- Round trips. A dashboard that needs data from several endpoints makes several requests, each over the visitor's own network.
- Coupling. Every screen now depends on another team's contracts. When those change, the frontend changes too.
- Exposure. Whatever the browser can call, anyone can call, with whatever credentials that takes.
- No shared cache. Each visitor fetches the same data again.
One endpoint per dashboard
Instead, our ABP.io backend centralises the calls to the other system and exposes a single API per dashboard. The endpoint is shaped for its screen: it fans out to the external APIs in parallel, combines the answers, and returns exactly what the dashboard renders. The frontend makes one call and knows nothing about where the data came from.
Here is the shape of one of them, simplified and with the names changed:
public async Task<InvestorOverviewDto> GetInvestorOverviewAsync(Guid investorId)
{
return await _cache.GetOrAddAsync(
$"investor-overview:{investorId}",
async () =>
{
// Fan out to the external system in parallel, not one call after another.
var applications = _external.GetApplicationsAsync(investorId);
var summary = _external.GetSummaryAsync(investorId);
var payments = _external.GetPaymentsAsync(investorId);
await Task.WhenAll(applications, summary, payments);
return InvestorOverviewDto.From(applications.Result, summary.Result, payments.Result);
},
() => new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60)
});
}
Why the TTL is short on purpose
A cache can go stale in two ways, and you have to pick one. Either you invalidate entries when the data changes, or you let them expire. Invalidation needs to know when the data changes, and here the data belongs to another system that doesn't tell us. So expiry is the only honest option, and the TTL becomes the promise you make to the person looking at the screen: this is never more than one TTL old.
A long TTL makes that promise meaningless. A short one, seconds to a minute rather than hours, keeps the numbers close to live while still collapsing a burst of loads into a single round of upstream calls. Pick the number from the screen, not from the cache: how stale can this dashboard be before someone makes a wrong decision from it?
Key by what the screen shows
- Include the viewer in the key when the data is theirs. An investor's dashboard must never be served from another investor's entry.
- Include the filters that change the result, and nothing that doesn't, or every entry becomes a miss.
- Cache the shaped response, not the raw external payloads. That's what the screen asks for, so that's what a hit should return.
Mind the moment it expires
When a popular entry expires, every load that arrives before it's rebuilt is a miss. ABP's GetOrAddAsync already serialises the factory for a key within an instance, which covers most of it. If you run many instances against a slow upstream, that's the point to look at a distributed lock or serving the stale value while one caller refreshes it.
The takeaway
Put an endpoint between your screens and someone else's system, shape it for the screen, and cache it for about as long as the screen can afford to be wrong. The frontend gets one fast call, the other system gets far fewer, and the data stays within a known distance of the truth.