Negative Caching in C#: Cache Your Misses, Not Just Your Hits

I run a public API at MySafeInfo that anyone can call, no signup required, with API keys for heavier use. A while back, I looked at where the database was spending its time. More key lookups than I expected were for keys that did not exist. Typos, long expired keys, and bots guessing at random. The valid keys were cached and cost nothing. The garbage went straight to SQL Server, every single time.

The cause was one innocent looking line in my caching code. You have probably written this method yourself:

public async Task<ApiKey?> GetKeyAsync(string key)
{
    if (_cache.TryGetValue(key, out ApiKey? cached))
        return cached;

    var apiKey = await _repository.GetKeyAsync(key);

    if (apiKey is not null)
        _cache.Set(key, apiKey, TimeSpan.FromMinutes(5));

    return apiKey;
}

See it? Only successful lookups get cached. When the repository comes back empty, the method skips the cache and returns null, which means the next request for that same bad key does the whole trip again. A cache like this protects you from your best traffic and leaves you wide open to your worst.

The fix is called negative caching: store the miss too. IMemoryCache is happy to hold a null, so the change is small:

public async Task<ApiKey?> GetKeyAsync(string key)
{
    if (_cache.TryGetValue(key, out ApiKey? cached))
        return cached; // may be null, and that is fine

    var apiKey = await _repository.GetKeyAsync(key);

    var options = new MemoryCacheEntryOptions()
        .SetAbsoluteExpiration(apiKey is null
            ? TimeSpan.FromMinutes(1)
            : TimeSpan.FromMinutes(5))
        .SetSize(1); // pairs with the size limit below

    _cache.Set(key, apiKey, options);

    return apiKey;
}

Now a bad key costs one database trip per minute instead of one per request. Notice the misses get a shorter lifetime than the hits, and there is a reason for the asymmetry. A cached hit going slightly stale is usually harmless. A cached miss going stale is a customer who just created a key and gets told it does not exist. Keeping the negative TTL short means new keys start working within a minute while the database still sleeps through the bot noise. Tune both numbers to your own traffic; the asymmetry is the part that matters.

Negative caching solves repeated misses, but it cannot help when a client invents a new key for every request. The short negative TTL handles repeated bad keys. A size limit protects the cache from the other kind of traffic: an endless stream of unique junk.

Be careful where you apply that limit. Microsoft's guidance on limiting cache size warns that setting SizeLimit on the shared cache registered by AddMemoryCache means every entry in that cache must specify a size, including entries created by code you may not control. I use a dedicated cache for API keys instead, and _cache in the fixed version above is that instance:

public sealed class ApiKeyCache
{
    public MemoryCache Cache { get; } = new(
        new MemoryCacheOptions
        {
            SizeLimit = 10_000
        });
}

// in Program.cs
builder.Services.AddSingleton<ApiKeyCache>();

Give each cache entry a size of 1, which simply tells the cache to count it as one item. With a limit of 10,000, this dedicated cache can hold up to 10,000 entries. Valid keys and cached misses both count. Once the cache is full, new entries are not added, so it cannot grow without limit. This is a count of entries, not a 10,000-byte limit.

What happens when the cache reaches 10,000 entries? New entries are simply not added until existing entries expire or are removed. The request still works because it falls back to the database, but that particular result receives no caching benefit. The size limit protects memory; rate limiting is still needed to protect the database from a client sending a constant stream of unique random keys.

None of this is new. That is the point. Negative caching is a familiar pattern in DNS, HTTP, and other systems that answer the same questions all day. But it is easy to overlook in application code because that null check feels perfectly reasonable when you write it. Take a look at your own caching code. If you find that innocent little if statement, check what it is costing you.

Two Ways to Mask PII in SQL Server (and When Each One Fits)

If you develop against SQL Server long enough, you will eventually find production data sitting in a dev or test environment. Real names, real emails, real phone numbers, all on the least protected servers you own. I worked through this with a client not long ago, and it turned out to be a good chance to sort out something that trips a lot of people up: there are two different problems hiding under the word "masking," and they have two different solutions.

Problem one: hiding data from people who query production. Maybe support staff need to look up an order but should not see the customer's full phone number. SQL Server has a built-in feature for this called Dynamic Data Masking. You declare a mask on the column, and users without the right permission see the masked version when they query it:

ALTER TABLE dbo.Customer
ALTER COLUMN Email ADD MASKED WITH (FUNCTION = 'email()');

ALTER TABLE dbo.Customer
ALTER COLUMN Phone ADD MASKED WITH (FUNCTION = 'partial(0, "xxx-xxx-", 4)');

The important thing to understand is that the real data is still there. Dynamic Data Masking changes what a query returns, not what the table stores. Privileged users see everything, and if you back up that database and restore it to dev, every bit of PII comes along for the ride. It is a presentation layer, and for its intended job it works well.

Problem two: sanitizing copies of the data. This is the dev and test refresh scenario, and it needs a different tool: static masking, where you actually overwrite the sensitive values after the restore. On the project I mentioned, we used Microsoft Purview to help identify which columns held sensitive data, then wrote plain T-SQL string masking to scrub them. Nothing fancy, and that is the point:

UPDATE dbo.Customer
SET FirstName = LEFT(FirstName, 1) + REPLICATE('x', LEN(FirstName) - 1),
    LastName  = LEFT(LastName, 1) + REPLICATE('x', LEN(LastName) - 1),
    Email     = LEFT(Email, 1) + 'xxxxx@example.com',
    Phone     = TRANSLATE(Phone, '123456789', '000000000');

None of those choices are accidental. Keeping the first letter and the original length means the data still looks and sorts like data, so your UI testing stays honest. The emails all land at example.com, a domain the IETF reserved for testing, so nothing you do in dev can ever reach a real inbox. And TRANSLATE zeroes out the phone digits while leaving the dashes and parentheses alone, so formatting code still has something realistic to chew on. One version note: TRANSLATE arrived in SQL Server 2017, so on older instances you would chain REPLACE calls instead.

To be clear, masking the strings in a dev copy is not encryption, and it is not bulletproof anonymization. It is a practical way to make sure a stray screenshot, a shared dev connection, or a lost laptop does not expose your customers. For most teams, that covers the real risk.

Whichever route fits your situation, check your work. After a masking pass I like to query for anything that slipped through; however you structure it, the idea is simple: search for emails not at example.com, digits where digits should not survive, and columns the classification pass may have missed.

Put the scripts in source control, run them as part of every refresh, and start with the columns that would hurt the most if they leaked.

What Mister Rogers Still Teaches

Some of my best childhood memories have a cardigan and a pair of blue sneakers in them. I grew up with Mister Rogers, and I never really outgrew him. I still read about Fred Rogers from time to time, and occasionally I pull up an old clip. The one I return to most did not take place in the Neighborhood at all. It happened in Washington and lasts about seven minutes.

On May 1, 1969, Rogers appeared before the Senate Subcommittee on Communications. The Nixon administration had proposed reducing a twenty-million-dollar public broadcasting request to ten million. Senator John Pastore, the chairman, had never seen Rogers's program and seemed ready to move things along.

Rogers did not try to match Washington's usual style. He spoke plainly about his program, the children who watched it, and the importance of helping them understand difficult feelings. Near the end, he recited the words of a song he had written about anger: what children can do with the mad that they feel.

When he finished, Pastore said, "I think it's wonderful. I think it's wonderful. Looks like you just earned the $20 million." Congress later authorized the full amount.

You can watch the complete exchange on YouTube. I recommend it. Rogers never raises his voice, never hurries, and never tries to overpower the man across from him. He simply explains why the work matters. By the end, Pastore is listening differently.

That exchange changed the way I think about Fred Rogers. It is easy to remember the soft voice and miss how formidable he was. He was an ordained minister who regarded television as his ministry. He earned a degree in music composition and wrote hundreds of songs. His program may have looked effortless, but it was anything but casual. The words, the pacing, and even the silences were considered carefully.

His gentleness was not passivity. He knew what he wanted to say, and he did not let the setting turn him into someone else.

Two books helped me understand that side of him, and both are on my reading log. The Good Neighbor by Maxwell King tells the larger story of his life and shows how much discipline supported the calm we saw on television. The Simple Faith of Mister Rogers by Amy Hollingsworth explores the faith behind his work.

Rogers rarely spoke explicitly about God on the air, but he described the space between the television set and the person watching as "holy ground." That explains something about the care he brought to the program. He was not merely producing children's television. He believed what happened in that space mattered.

Rogers once told a young journalist that "deep and simple is far more essential than shallow and complex." I return to that line often. I think about it as a dad of three, when I write software, and when I try to talk honestly about faith. Complexity can make us sound impressive. Volume can make us feel persuasive. Neither guarantees that we are saying anything worthwhile.

Fred Rogers has been gone for more than twenty years. I can still watch those few minutes in Washington and learn something from him. That may be the most remarkable part of his work: he is still teaching.

Why I Still Choose Dapper Over Entity Framework in 2026

I have spent nearly thirty years building software on the Microsoft stack and have worked with .NET since its early days. Whenever I start a new application, I eventually face the same question: how should it talk to the database?

For a long time, my answer has been Dapper and parameterized T-SQL rather than Entity Framework. That choice is not based on a belief that Entity Framework is slow.

Dapper is fast and stays close to raw ADO.NET, but EF Core has improved substantially over the years. Microsoft also provides extensive guidance for finding and fixing performance problems. For most applications, the difference between the two is unlikely to be the deciding factor.

I choose Dapper because I want control over the SQL.

I have spent a large part of my career working directly in SQL Server, so writing a query does not feel like plumbing that ought to be hidden from me. I want to see the statement the database receives. I want to examine its execution plan, understand how it uses the available indexes, and tune it when necessary. With Dapper, the query in my code is the query I investigate when something runs slowly.

That is a better fit for the way I work than starting with LINQ and then examining the SQL generated from it. EF Core provides tools for doing that, of course, but it adds a translation step that I usually do not need.

Dapper originated at Stack Overflow and was built to solve the sort of data-access problems Stack Overflow encountered in production. Its role is deliberately limited: it executes SQL and maps the returned rows to objects. It does not try to manage the database for me.

I also like that Dapper is small enough to understand. When a library sits between an application and its data, being able to follow what it does is valuable. That has become more important to me, not less, as my projects have grown older.

None of this makes Entity Framework a bad choice. A team that prefers LINQ, designs its schema alongside its object model, and relies on code-first migrations may be more productive with EF Core. It provides useful features that Dapper intentionally does not.

Most of the systems I work on have a different history. Their databases tend to outlive the applications that use them. This year, for example, I rewrote a web application originally built in 2013 and moved it to .NET 10. The production database did not need to change. A new application was simply taking its place in front of an existing schema, and Dapper handled that arrangement without trying to redesign it.

That is why I continue to use it. Dapper is not the most ambitious part of the architecture, and I do not want it to be. I know what SQL it will execute, I know where to look when a query misbehaves, and I can replace the application without asking the database to follow it.

For a data-access layer, boring and predictable are useful qualities. After this many years, they are the qualities I value most.

Why I Stopped Blogging (and Why I'm Back)

My last post here was January of 2018. Seven years is a long time between posts, so this one comes with a little explaining.

The short version is that life got full. I got married. Two more kids came along, which brings us to three. My walk with Christ kept deepening. I kept reading, a lot, and I kept building things. I rewrote MySafeInfo from the ground up, and I launched My Family Devotion, a free devotional for families to read aloud together.

Not all of it was easy. In February of 2023 I was diagnosed with kidney cancer. I had surgery that March, and I have been cancer free ever since. I will not go into the details, but I cannot tell that part of the story without gratitude. God carried my family and me through it.

Somewhere in all of that, blogging fell off the list. But I never stopped having things I wanted to say. So I am picking back up, with a few changes. Posts will be short. Some will be about programming, since that is how I have spent nearly thirty years of my working life. Some will be about apologetics and theology, since that is where my heart lives now. And some will just be about a book worth reading or an idea worth chewing on.

To everything there is a season, and a time for every purpose under heaven. (Ecclesiastes 3:1)

This is a new season. Glad you are here for it.