Caching in RepoDB — Skip the Database for Tables That Rarely Change
Most applications have at least one table that barely ever changes — countries, currencies, statuses, categories, timezones — yet it gets queried on almost every single request. RepoDB has a second-level cache built directly into Query, QueryAll, and ExecuteQuery for exactly this case: pass a cacheKey and a cache instance, and every call after the first one is served from memory instead of the database.
The problem: querying a table that never changes
A Status or Country table is usually tiny and almost read-only, but it still sits behind every request that needs it — an order form populating a dropdown, an API response resolving a foreign key into a display name, a validation check against a set of allowed values. Every one of those calls pays the full price of a database round trip: opening a command, sending the SQL, waiting on the network, and materializing the result — for data that hasn’t changed since the last time you asked.
The 2nd-level cache, built in
No external caching library, no extra NuGet package, no wrapper class to write. Query, QueryAll, and ExecuteQuery all accept the same two arguments:
cacheKey— a string identifying the result set. Setting this alone does nothing by itself.cache— anICacheinstance. RepoDB ships a ready-to-use in-memory implementation,MemoryCache, butICacheis just an interface, so you can plug in your own (a distributed cache, for example) without changing a single call site.
Both arguments have to be set together — cacheKey tells RepoDB what to cache, cache tells it where.
How it works
The behavior is a plain cache-aside pattern, handled entirely inside the operation:
- Cache hit — if an entry already exists under
cacheKeyand hasn’t expired, RepoDB returns it immediately. The database is never touched. - Cache miss — RepoDB runs the query as usual, stores the result under
cacheKeyforcacheItemExpirationminutes, and returns it.
Every subsequent call with the same cacheKey — from anywhere in the app, on any thread — is served from step 1 until the entry expires.
Why the gain is so large
For a small, static table, the row count was never the expensive part. The cost lives in the connection/command setup, the round trip across the network, and the database resolving and executing the statement — all of which a cache hit skips entirely in favor of a single in-memory dictionary lookup. That’s why serving a static-table read from a warm cache instead of the database routinely cuts response time by more than 90%: a network hop and a query execution cost orders of magnitude more than reading a value that’s already sitting in memory.
Setting up the cache
cache should be one shared, long-lived instance — not a new MemoryCache() created inside every method call, which would defeat the purpose since nothing would ever be reused. Register it once, typically as a singleton:
services.AddSingleton<ICache>(new MemoryCache());
Using it with Query
using (var connection = new SqlConnection(connectionString))
{
var activeCountries = connection.Query<Country>(c => c.IsActive == true,
cacheKey: "ActiveCountries",
cache: cache);
}
The first call executes the SQL and caches the result. Every call after that — anywhere in the app — returns instantly from memory until ActiveCountries expires.
Using it with QueryAll
QueryAll fetches every row in a table, which is exactly the shape of most lookup tables:
using (var connection = new SqlConnection(connectionString))
{
var allStatuses = connection.QueryAll<Status>(
cacheKey: "AllStatuses",
cache: cache);
}
Using it with ExecuteQuery
Raw SQL — a stored procedure, a hand-tuned statement, a join — gets the same treatment:
using (var connection = new SqlConnection(connectionString))
{
var activeCurrencies = connection.ExecuteQuery<Currency>(
"SELECT * FROM [dbo].[Currency] WHERE IsActive = 1;",
cacheKey: "ActiveCurrencies",
cache: cache);
}
Controlling how long an entry lives
cacheItemExpiration (in minutes, defaulting to 180) controls the entry’s lifetime:
var allCountries = connection.QueryAll<Country>(
cacheKey: "AllCountries",
cacheItemExpiration: 60 * 24, // refresh once a day
cache: cache);
If the table is genuinely static, a long expiration is fine. If it changes occasionally — a new Status gets added once in a while — pick an expiration that matches how often it actually changes, or call cache.Remove("AllStatuses") right after a write to that table so the next read picks up the change immediately instead of waiting out the clock.
Good candidates for this pattern
- Country / Region / Currency / Timezone tables
- Status / Category / Type-style reference tables
- Feature flags or app-wide configuration rows
- Anything read on nearly every request but written rarely, if ever
When to skip it
- Tables with frequent writes — you’ll spend more effort invalidating the cache than the cache saves you
- Per-user or per-tenant data, where the number of distinct cache keys can explode into the thousands
- Anywhere strict, always-fresh consistency is a hard requirement
Learn more
See Cache for the full set of cache providers — including plugging in your own ICache implementation — and Query, QueryAll, and ExecuteQuery for the complete parameter list.
Please support us
Give a ⭐ to our Github repository |
Follow me at X / Twitter |
Connect with us at our official Teams Channel
Apache License 2.0 — Copyright © 2026 by Michael Camara Pendon / Create a Request
~ Conceptualized and written by me. Reviewed and checked by AI. Refined and gatekept by me. ~