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 — an ICache instance. RepoDB ships a ready-to-use in-memory implementation, MemoryCache, but ICache is 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:

  1. Cache hit — if an entry already exists under cacheKey and hasn’t expired, RepoDB returns it immediately. The database is never touched.
  2. Cache miss — RepoDB runs the query as usual, stores the result under cacheKey for cacheItemExpiration minutes, 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. ~