A cache is often described as a faster place to retrieve data, but that description hides an important ownership question: who is allowed to mutate the object returned by the cache? With an in-process cache, the answer can surprise even experienced C# developers. A successful lookup may return the same object graph that was stored earlier, not a private copy created for the current caller.
That distinction turns ordinary filtering code into a data-integrity risk. If a caller replaces a cached entity’s collection with a filtered list, later callers can observe the reduced collection. A request that never asked for a filter may therefore receive incomplete data. The defect is especially difficult to diagnose because the filtering request itself can return a perfectly correct result.
The safe design is to treat cached graphs as read-only snapshots. Build request-specific results through projection, and introduce a serialization boundary when true graph isolation is required. This article explains both techniques, their limits, and a regression-test pattern that detects contamination rather than merely checking whether a filter appears to work.
Why IMemoryCache Can Expose a Shared Reference
IMemoryCache should be treated as an object store, not an automatic cloning service: when a reference-type value is stored and retrieved, callers can receive the same instance. Consequently, mutating a returned object or anything reachable through its graph can alter what subsequent cache lookups observe.
The IMemoryCache API documentation defines the cache operations but does not promise that values are cloned on either insertion or retrieval. That omission matters because most domain models are C# reference types. As the C# reference-types documentation explains, variables of a reference type hold references to their data. Assigning such a variable to another variable copies the reference, not the object graph.
Consider a cached record with a mutable List. Retrieving the record into a local variable does not establish ownership. Neither does assigning that value to another local variable. All of those references can still point to the same record, and its Relations property can still point to the same list.
This is unsafe:
var cached = cache.Get(cacheKey);
if (request.RelationKinds is not null)
{
cached!.Relations = cached.Relations
.Where(r => request.RelationKinds.Contains(r.Kind))
.ToList();
}
return cached;
The new list created by ToList() does not make the operation safe. The assignment writes that new list back into the shared cached record. The cache entry still refers to the record, so every future reader sees the replacement collection. The local variable name cached is a useful warning, but the problem remains even if the variable is named result or response.
The same rule applies to less obvious mutations: setting a navigation property to null, sorting a cached list in place, removing elements, changing a child flag, or normalizing a string property on a nested object. Any write that reaches the shared graph can contaminate later reads.
How In-Place Filtering Creates Cross-Request Contamination
In-place filtering contaminates cached data because it changes the shared source while constructing a request-specific view. The immediate response may be correct, but the cache no longer represents the complete snapshot, so unrelated requests inherit state from whichever filter happened to run earlier.
This failure often appears in code that was written with good intentions. A handler retrieves a complete record, applies an optional filter, and returns the result. The filter expression itself is valid. The mistake is assigning its output to a property on the cached instance instead of constructing a separate response object.
The observable sequence is straightforward:
- Populate the cache with a record containing its complete relation collection.
- Execute a filtered read that replaces the record’s collection with a subset.
- Execute an unfiltered read using the same cache key.
- Observe that the unfiltered read now returns the subset rather than the original collection.
A test that stops after step 2 will miss the defect. It proves only that the requested subset was returned. It does not prove that the shared source remained intact. This is why the essential regression assertion comes after the filtered operation: the next unfiltered retrieval must still expose the complete cached data.
Multiple optional filters can introduce a separate logic error. Repeatedly assigning from the original source can make the final condition overwrite earlier conditions, producing “last filter wins” behavior. Repeatedly assigning to the cached property can both combine the filters incorrectly and corrupt the source. A safer implementation builds one predicate over active conditions and applies it once during projection.
Cache behavior also should not be inferred from the name of a backend alone. An in-process cache makes reference sharing easy to demonstrate, while a serialized cache can create a boundary at some operations. Yet application code may still mutate an object before asynchronous serialization finishes, reuse deserialized instances, or place a local cache in front of a remote store. The durable rule is independent of backend details: code that does not own a cached graph must not mutate it.
Projection Copies Provide Request-Level Isolation
Projection prevents contamination by creating a new response object and a new filtered collection while leaving the cached source untouched. It is the preferred approach when only part of a model must vary by request and the response shape can be constructed explicitly.
A projection makes ownership visible in code. The cached object is read as input; a newly allocated object becomes the output. Optional conditions can be composed into a single predicate, and the filtered elements can be copied into a new list without assigning anything back to the source.
static CachedRecord ProjectRecord(
CachedRecord source,
RelationFilter? filter)
{
bool hasKinds = filter?.Kinds is { Count: > 0 };
bool hasTargets = filter?.TargetIds is { Count: > 0 };
bool Matches(Relation relation) =>
(!hasKinds || filter!.Kinds!.Contains(relation.Kind)) &&
(!hasTargets || filter!.TargetIds!.Contains(relation.TargetId));
return new CachedRecord
{
Id = source.Id,
DisplayName = source.DisplayName,
Relations = source.Relations
.Where(Matches)
.Select(r => new Relation
{
Kind = r.Kind,
TargetId = r.TargetId,
Label = r.Label
})
.ToList()
};
}
This example provides two isolation layers. CachedRecord is newly allocated, so replacing one of its properties cannot alter the cached record. Each Relation is also newly allocated, so changing a relation in the response cannot alter the corresponding child in the cached graph.
Whether child projection is necessary depends on the ownership contract. If relation objects are immutable and consumers cannot modify them, copying only the outer object and list may be sufficient. If children are mutable, exposed to model binding, enriched later, or passed to code with uncertain behavior, project the children too. Explicitly copying the fields used by the response also helps prevent accidental exposure of internal state.
Projection should not be confused with rematerializing the same references. This code allocates a new list but shares every child object:
var response = new CachedRecord
{
Id = source.Id,
DisplayName = source.DisplayName,
Relations = source.Relations.Where(Matches).ToList()
};
Replacing or clearing response.Relations will not affect source.Relations, but changing response.Relations[0].Label can still alter the cached child. The collection is isolated; the nested graph is not. That difference should be captured in tests and documented in the response contract.
Why Shallow Copies Are Not Enough for Nested Collections
A shallow copy isolates only the copied object’s top-level fields; nested reference-type properties can remain shared. For models containing lists, dictionaries, child entities, or mutable metadata, a memberwise or constructor copy can therefore preserve the exact mutation path that caused the cache defect.
Suppose a record is copied with a record with expression, MemberwiseClone, a copy constructor that assigns collections directly, or a mapper configured to preserve nested references. The outer objects may satisfy ReferenceEquals(source, copy) == false, while ReferenceEquals(source.Relations, copy.Relations) == true. Even if a new list is allocated, the list’s elements may still be shared.
Isolation must be considered at every mutable level:
- Is the root response object distinct from the cached root?
- Is each mutable collection distinct from the cached collection?
- Are mutable collection elements distinct where callers may edit them?
- Do nested dictionaries, arrays, or subcollections also require copies?
- Are supposedly immutable objects actually enforced as immutable by their API?
There is no universal requirement to deep-copy every object. Unnecessary copying can complicate code and obscure the intended response shape. The correct boundary follows mutation risk. Immutable value objects can safely be shared. Mutable entities should be projected when they cross into code that may enrich, redact, sort, normalize, or otherwise modify them.
A useful review technique is to trace every assignment and mutating method from the returned value. Search not only for direct property assignment, but also calls such as Add, Remove, Clear, Sort, dictionary index assignment, and updates to children inside loops. The question is always the same: could this write reach an object that remains reachable from the cache entry?
JSON Round-Trip as a Serialization Boundary
A JSON round-trip can create a separate object graph by serializing a value and deserializing it into a new instance. It is useful as an explicit isolation boundary when projecting a large or evolving graph manually would be error-prone, provided the type’s JSON contract is suitable.
With System.Text.Json, the pattern is concise:
using System.Text.Json;
static T JsonClone(T source, JsonSerializerOptions? options = null)
{
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(source, options);
return JsonSerializer.Deserialize(payload, options)
?? throw new JsonException("Deserialization produced null.");
}
The official System.Text.Json serialization guide covers serialization and deserialization patterns and configuration. The same options must represent the intended contract in both directions. Naming policies, converters, ignored properties, polymorphism configuration, reference handling, constructors, and read-only members can all affect the reconstructed graph.
A serialization boundary is not semantically identical to an unrestricted deep-clone operation. Members excluded from JSON will not appear in the clone. Runtime-only state may be lost. Interface-typed or polymorphic properties need deliberate configuration. Cycles and reference preservation require explicit handling. For those reasons, a round-trip should be adopted as a contract-aware boundary, not as a magical copy function.
Projection and JSON cloning solve related but different problems. Projection is precise: it selects the response shape, combines filtering with transformation, and makes copied levels obvious. A JSON round-trip is broad: it reconstructs the serialized graph and can be convenient for defensive isolation at an architectural boundary. Many systems benefit from projection for normal query paths and serialization cloning only where a complete detached graph is specifically required.
Null and Empty Filters Must Mean the Same Thing Deliberately
Optional filters should have an explicit contract; in most query APIs, both null and an empty collection mean “no restriction.” Treating an empty collection as an active predicate can unexpectedly remove every item because no value can belong to an empty set.
Checking only filter.Values != null is therefore often insufficient. An empty but non-null list enters the filtering branch and makes values.Contains(candidate) false for every candidate. The result is an empty response even though the caller supplied no meaningful constraint.
Use a positive “has values” check such as pattern matching with { Count: > 0 }, an Any() helper, or a project-standard HasAny() extension. Then compose all active conditions with logical AND. This produces stable semantics:
- A missing filter object applies no restriction.
- A
nullfilter list applies no restriction. - An empty filter list applies no restriction.
- One populated list applies its corresponding restriction.
- Multiple populated lists must all match unless the API explicitly specifies OR behavior.
The behavior should be tested rather than left to convention. Include cases for null and empty collections alongside single and multiple active filters. Also verify that applying any of those cases leaves the cached source unchanged. Correct predicate semantics and ownership isolation are separate properties; both must hold.
Verification and Reproduction on .NET 9
A focused console harness can reproduce the reference-sharing failure and verify both isolation strategies without involving a production application. The fixed verification environment used here was net9.0 with Microsoft.Extensions.Caching.Memory version 9.0.8.
The harness stored a mutable root object with a nested relation list, retrieved it twice, replayed an in-place collection replacement, and then compared that behavior with projection and a System.Text.Json round-trip. Assertions were printed as PASS or FAIL and configured to terminate with a nonzero exit code if any expectation failed.
The functional verification produced exactly six PASS checks:
- Re-reading the
IMemoryCacheentry producedReferenceEquals == truefor the cached root. - An in-place mutation through one retrieved reference was visible through a later retrieval.
- The projected response had a different root reference from the cached source.
- After projection and response mutation, the cached collection count remained intact.
- The JSON round-trip produced a different object graph from the cached graph.
- Mutating the JSON clone left the cached graph intact.
These checks establish behavior, not speed. This is functional verification, not a benchmark, and it makes no claim about throughput, latency, allocation cost, or which isolation strategy performs better. Any performance decision would require a separate workload-specific benchmark with representative graph sizes, serializer options, access patterns, and runtime settings.
The test’s most important assertion is not that a filtered response has the expected subset. It is that the subsequent unfiltered cache read still has the original complete collection. For nested mutable elements, add another assertion that mutating a projected or cloned child does not change the corresponding cached child. That catches a shallow-copy implementation that passes root-reference and list-count checks while still sharing element instances.
Designing Regression Tests That Detect Contamination
A robust regression test must inspect the cache after the request-specific operation, because contamination is a temporal side effect. Testing only the returned subset validates filtering but cannot reveal whether the filter permanently altered shared state.
Structure the test around two conceptual actors. The first caller requests a restricted view and is allowed to mutate its own response. The second caller requests an unrestricted view and must remain unaffected. This models the cross-request failure directly without depending on web-server concurrency.
At minimum, assert the following:
- Seed a complete object graph and store it under a stable cache key.
- Perform a filtered operation through the same application path used by callers.
- Verify that the filtered response contains only matching items.
- Mutate the returned response at the root, collection, and child levels that the contract permits.
- Retrieve the same key again without filters.
- Verify the original item count, identities or values, and nested state remain intact.
- Confirm reference inequality at every mutable level intended to be isolated.
Add a matrix for filter composition: no filter object, null lists, empty lists, each individual filter, and multiple simultaneous filters. The multiple-filter case should contain data that distinguishes AND from last-wins behavior. For example, include one relation matching only the first condition, another matching only the second, and a third matching both. Only the third should survive when both restrictions are active.
A deterministic single-threaded test is usually enough to prove shared-reference contamination. Concurrency tests can be added for code that performs background writes or serialization, but they should complement rather than replace the basic ownership test. The simplest failing sequence is easier to understand and maintain.
Implementation and Review Checklist
The practical rule is simple: cached values are shared snapshots unless a documented boundary proves otherwise. Review both cache readers and downstream consumers, because a method can return the shared graph without mutating it locally while another layer modifies it later.
Use this checklist when implementing or reviewing cached query paths:
- Treat every value obtained from
IMemoryCacheas read-only. - Do not assign filtered collections back to cached entities.
- Do not redact by setting cached properties or navigation members to
null. - Avoid in-place
Sort,Add,Remove,Clear, dictionary updates, and child-property changes on cached graphs. - Build request-specific results with explicit projection.
- Allocate new nested elements when those elements are mutable downstream.
- Do not mistake a new root object or a new list for full graph isolation.
- Combine multiple active restrictions deliberately, normally with AND.
- Treat null and empty filter collections consistently according to the API contract.
- Use a JSON round-trip only with a reviewed serialization contract and matching options.
- Test a filtered read followed by an unfiltered read from the same key.
- Mutate the returned response in tests and verify the cache remains intact.
- Assert reference inequality at the root, collection, and mutable-child levels required by the contract.
- Keep functional verification separate from benchmarking.
- Document whether callers receive immutable values, projections, or detached serialized graphs.
Shared-cache mutation is not fundamentally a filtering bug or a cache-vendor bug. It is an ownership bug. Once the cache is recognized as the owner of a reusable graph, the design becomes clearer: query code reads the graph, projections create caller-owned views, and serialization can establish a broader detachment boundary. Tests then verify not only the requested result, but also the integrity of the data left behind for the next caller.