DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on AI-assisted

Project the Response, Not the Cache

A response can be correct for one caller and still leave the application in a worse state for everyone who follows.

This is an easy trap in ASP.NET Core when a result filter, formatter, or middleware needs to change a few fields before a response leaves the server. Localization is a useful example: a DTO contains source-language text, the current request asks for another language, and the pipeline replaces selected strings before serialization.

The first response looks right. The implementation may even be pleasantly small.

The danger is ownership. If the returned DTO came from an in-process cache, the response pipeline may be holding the same object instance that another request, background job, or command path will read later. What looked like response formatting was actually a write to shared state.

The broader lesson is this: request-specific presentation should be a projection, not a mutation.

The Bug Hides Behind a Successful Response

Imagine a cached catalogue item with a description in the system’s source language. A request asks for a translated version. The tempting implementation walks the object graph and assigns the translated string to the DTO property.

The response is translated correctly. A test that checks only that response passes.

Now consider the next reader. If the cache returns the same instance, the cached item is no longer in its authoritative source form. A source-language request may receive translated text. A command that maps the DTO back into another model may persist presentation text as source data. A background process may observe a value chosen for somebody else’s request.

Nothing about the first HTTP response reveals this. The defect is temporal: the observable failure belongs to a later reader.

That is why “the output is correct” is not a sufficient assertion when inputs can be shared.

Draw the Ownership Boundary Explicitly

A useful model is to separate three things:

  1. The cached or domain DTO is authoritative input.
  2. Request context selects presentation rules.
  3. The HTTP payload is response-owned output.

Once those roles are explicit, several safe implementations become available. You can map into a dedicated response model. You can clone the graph and change the clone. Or you can project replacement values at the serialization boundary.

In the last approach, the traversal discovers which string properties need different values but never assigns those values to the source objects. The serializer then substitutes the replacements while creating a JSON tree owned by the response. Nested objects, collections, readonly wrappers, and init-only properties keep their wire shape because the original serializer contract still defines the payload.

The important property is not the particular API used. It is that no request-specific operation writes into caller-owned or cache-owned objects.

This idea also applies to redaction, unit formatting, enrichment, API-version shaping, and field masking. If the transformation varies by caller or request, it probably belongs in an output projection.

Preserve the Common-Case Fast Path

Projection has a cost. It may require an additional JSON materialization, replacement lookup, and short-lived allocations. That cost should be acknowledged rather than hidden behind a correctness slogan.

The practical response is to make the cheap path obvious. When the request already uses the source representation, pass the original result through untouched. Only requests that need transformation pay for projection.

Failure behavior also matters. If the projection cannot handle an unusual graph, the pipeline should not leave a half-mutated object behind. A safe fallback retains the original result and logs the failure according to the application’s policy. Whether returning the untransformed payload is acceptable depends on the feature and data classification; sensitive redaction may need to fail closed instead.

Security-sensitive payloads and error results may also deserve explicit exclusion. Presentation machinery should not accidentally inspect tokens, secrets, or payload shapes that were never part of its contract.

Test the Second Reader

The highest-value test is a sequence, not a snapshot:

  1. Put a source-language object into the real cache implementation used by the host.
  2. Request a transformed response and verify its output.
  3. Read the cached object again and verify that it is unchanged.
  4. Issue a source-language request and verify that it still receives the source value.

Run that sequence through the real result filter and serializer options, not only a mocked translation service. The ownership bug lives in the composition between cache identity, result handling, graph traversal, and serialization.

Add focused cases for ordinary object results and explicit JSON results, nested collections, readonly response wrappers, init-only properties, error responses, exempt payloads, and projection failure. These tests establish both the intended transformation and the things it must never change.

A mutation-resistance assertion is especially valuable: keep a reference to the original object and prove its properties retain their exact values after the response executes.

The Trade-Off Is Isolation Versus Local Simplicity

In-place mutation is easy to understand in one method and can avoid an extra materialization. Its cost appears elsewhere: temporal coupling, language or user context leaking between requests, and tests that pass individually while failing in sequence.

Response projection adds code, allocations, and serializer-specific reasoning. In return, it makes ownership visible. The cache remains authoritative, requests remain isolated, and failures cannot leave shared objects partially transformed.

That is a trade I will usually take. I would still measure the transformed path with representative payload sizes and keep the source path cheap. But performance work should begin after the ownership contract is correct.

The practical rule is short: if a value is shared, treat it as immutable. If presentation depends on the request, produce response-owned output. And when you test it, always ask what the next reader sees.

Top comments (0)