AI agents
Key points

Key points

  1. 1Run the same contract test suite twice, once with the read-through cache disabled and once with it enabled, then diff the response payloads field by field.
  2. 2A cache hit must return every field a miss returns, plus a hit flag and a freshness stamp, so a consumer can tell which read it got.
  3. 3If the contract does not assert on those extra fields, a stale cache entry will satisfy every assertion and pass as a fresh read.
  4. 4The cache adds metadata (freshness, source revision, validation outcome) that lives below the contract, so the contract is the only place that can force it into view.

Why the cache sits below the contract

A contract test answers one question: does the provider still return the response this consumer relies on? The narrow scope is the point. Contract tests touch only the interface layer, run in milliseconds, and stay deterministic because they carry no dependencies (Total Shift Left). That narrowness is also the blind spot. A read-through cache sits between the service and its store, and the service's response is assembled either from the store or from the cache. Both paths go through the same serialization code, so both look structurally identical to a schema check.

Read-through means the application always reads from the cache first, and the cache itself loads missing data from the backend on a miss (ITU Online). The application code no longer contains the cache-fill logic, which is the pattern's main benefit and the reason a contract test cannot see it. When the contract was written against the no-cache behavior, it captured the store's fields. The cache path adds fields or, worse, serves an older version of the same fields.

The general lesson predates caches. Adam Wathan's screencast on contract tests exists because test doubles drift from real implementations: the double's API falls out of sync, interfaces give a false sense of security, and tests make better contracts than interfaces (Adam Wathan). A read-through cache is a test double you shipped.

The two-run experiment

The test is easy to describe and easy to get wrong. The procedure:

  1. Start the service with the cache disabled. Record every response body for the contract scenarios you care about (a fetch by ID, a fetch after a write, a fetch of a deleted record).
  2. Start the same service with the cache enabled, cold. Repeat the scenarios until you also produce a hit: the same read at least twice.
  3. Diff the payloads. Structural differences (new fields, missing fields) and value differences (same field, different value) are both findings.
  4. Classify each finding as intended (the cache is allowed to add this) or a leak (the contract never agreed to it).

The trap appears at step 3. Suppose the no-cache service returns a balance of 12.40, and the cache-enabled service returns 12.40 from a hit after the balance was updated to 9.10. A schema-level assertion passes, because the shape is legal. A contract test that only checks shape has no opinion about which value is correct. This is the same failure mode contract testing was built to catch, moved one layer down: "schema valid" is weaker than "compatible," because a schema tells you what shapes are legal while a contract test tells you whether the behaviors a consumer relies on are still honored (Steve Kinney).

The worked example with numbers: an account endpoint has a 30-second TTL and a hit rate of 80% during a load test. If each scenario issues 1,000 reads, roughly 800 of them will be served from cache. A contract suite that only exercises a cold cache sees zero hits and therefore zero stale reads. That is why the second run matters and why the first read in each scenario is not the one that fails.

What a cache hit must carry

Four fields turn an invisible hit into a checkable one. This is the core decision rule of the article: a hit and a miss must be the same response type, and the hit adds fields rather than replacing them.

FieldPurposeExample assertion
Hit or miss flagTells the caller which path served the responsecache.status is hit or miss, never absent
Freshness stampTime the entry was written to cache, not the time of the responsecache.cachedAt is an ISO 8601 timestamp not later than now
Source revisionVersion of the record in the store when it was cachedcache.sourceRevision equals the store's revision at write time
Validation outcomeWhether the entry was revalidated or served stalecache.validation is fresh, revalidated or stale

Why the freshness stamp and not just a time to live? Because a TTL is a promise, not a proof. The common failure is caching tool output without a freshness contract, and nobody defines how long the result is trustworthy, which is not caching but wishful thinking (Thinking Loop). The stamp puts the actual age in the payload so the consumer can decide.

Why source revision? Because freshness time and data freshness are different things. An entry can be two seconds old and already wrong if a write landed after it was cached. Tying the cached copy to the store's revision number gives the contract something concrete to compare across runs.

Why validation outcome? Because serving stale while revalidating is a legitimate strategy and a dangerous one depending on the endpoint. Teams that serve stale content during revalidation know they need that distinction in the response; consumer engineering teams should ask for it too. If a read path can return stale, the contract must say so, or a consumer will treat a deliberately stale response as fresh.

Asserting on those fields in the contract test

The contract should be written once and run against both configurations. The mechanics differ by tool, but the shape of the assertions is the same.

If you use a consumer-driven tool like Pact, each interaction declares a minimal expected response, and the provider later proves it can satisfy that interaction. That means the cache metadata has to be in the expectation, not bolted on afterward. The contract for a read becomes:

{
  "given": "an account exists with revision 7",
  "uponReceiving": "a second read of the account",
  "request": { "method": "GET", "path": "/accounts/42" },
  "response": {
    "status": 200,
    "body": {
      "id": 42,
      "balance": 12.40,
      "cache": {
        "status": "hit",
        "cachedAt": "2026-01-01T00:00:00Z",
        "sourceRevision": 7,
        "validation": "fresh"
      }
    }
  }
}

The provider side then has to produce a hit for this interaction, which is the assertion that catches a cache path that silently omits sourceRevision. For spec-driven workflows, the OpenAPI document gains a cache object with required properties, and conformance tests check the running implementation against it. Pact's own model treats the contract as an expected request and a minimal expected response, not just a JSON schema, which is why the cache object belongs inside the expected body rather than in a separate schema file (Steve Kinney).

Two assertion rules do most of the work:

  • Exact field set on a miss. A fresh read must return the store fields plus the cache block with status: miss and a null or zero revision. If a miss omits the block, the contract has already drifted.
  • Version comparison on a hit. After a write, a read must return either the new revision (correct invalidation) or validation: stale (explicitly allowed staleness). Returning the old revision as fresh is the failure the two-run experiment exists to find.

A comparison of the two configurations shows why a single run is not enough:

BehaviorCache disabledCache enabled, coldCache enabled, hit
Fields returnedStore fieldsStore fields plus cache blockStore fields plus cache block
Source revisionLatestLatestPossibly older than store
Contract passes if it ignores cache blockYesYesYes, even when stale

The last row is the entire argument. The contract passing on a stale hit is not a tool defect; it is a contract that never asked the question.

Where reads should not be cached at all

Not every endpoint needs this treatment, and adding cache metadata to a write path is noise. The decision rule is the four questions every cache entry should be able to answer: what is the acceptable staleness window, who invalidates the entry and when, what happens when the cache is cold, and what happens when the cache is wrong (Adam the Developer). If a read endpoint cannot answer the fourth question, it should not be behind a read-through cache, and the contract test should assert that no cache fields appear.

The write policy matters here too. A write-through cache writes to both cache and storage, which keeps them consistent but costs performance; a write-back cache writes only to the cache and flushes later, which is faster but risks losing data if the cache fails before the flush (Study.com). For a read-through layer in front of a SQL store, the cache is usually populated on read, so the relevant question is invalidation, not write policy. Cornell's cache notes make the clean statement of the trade-off: write-through is slower but cleaner because memory is always consistent, while write-back is faster but becomes complicated when multiple consumers share memory (Cornell CS 3410). In application terms, the shared memory is your consumer.

The flags analogy holds up. Cache lines carry metadata bits alongside the data: a valid bit that says whether the line holds real data, a dirty bit that says the line is newer than memory, and a lock bit that prevents replacement. When the processor starts up, every line is invalid, and clearing the cache is just setting those bits to invalid (Intermation). Freshness stamp, source revision, hit flag and validation outcome are the same idea at the API layer: the bits that let a reader tell what it is holding. General cache design treats these decisions as trade-offs to be made deliberately, not defaults to inherit (ETH Zürich lecture).

Keeping the contract honest over time

Once the cache fields are in the contract, the provider team owns them. A few practices keep the two-run experiment from decaying into a one-time audit.

Run both configurations in CI. The same contract file runs against a cache-disabled instance and a cache-enabled instance in the same pipeline. If a hit path stops emitting cache.status, one of the two jobs fails.

Test against something that behaves like the real store. Much of the confidence in these tests depends on the store's behavior under caching, and test doubles are usually a pale imitation. The EF Core team's guidance is direct: a database double may compare strings case-sensitively while the production system compares them case-insensitively, and only tests against the real system uncover that (Microsoft Learn). The same is true of a cache double: an in-memory dictionary will not reproduce eviction, TTL expiry or a concurrent invalidation.

Use the real store where the cache interacts with it. Jimmy Bogard's argument against in-memory test databases applies cleanly here: teams that swap in a fake end up writing the integration test anyway, and the fake's API does not match the real one's, so it can return results that do not relate to production behavior (Jimmy Bogard). A containerized real store, spun up by the test harness, gets you realistic invalidation without the fake's failure modes.

Publish expectations, not guesses. Contract tests exist because mocks drift; a consumer-driven contract generated from a running consumer and verified against the provider is the defense against that drift (PactFlow). The cache block belongs in that contract for the same reason every other field does.

Keep the test fast. Contract tests are milliseconds per test because they only touch the interface. Adding a hit path and a post-write read to each scenario multiplies the count but not the runtime, which is why this extra coverage is affordable where a full end-to-end suite with cache flushes would not be (Total Shift Left).

If your service calls several kinds of model as part of the read path (embeddings, a reranker, a summarizer) and the cache sits in front of all of them, the response shape is the only place a caller can see what happened. Quanta Inference (https://quantaintelligence.ai/inference) puts text, image, video, audio and embedding models behind one OpenAI-compatible key, which makes it practical to test the whole read path against a single contract rather than one per provider.

FAQ

FAQ

Why can't I just rely on the TTL to prevent stale reads?

A TTL caps how long an entry can live, but it says nothing about whether a write landed after the entry was cached. An entry two seconds old can be wrong if the store changed at second one. The freshness stamp and source revision in the payload let the contract compare what the cache returned against the store's actual state, which a TTL alone can never do.

Do I need both runs in CI, or is running with the cache enabled enough?

Run both. The cache-disabled run establishes the baseline field set and values, and the cache-enabled run proves the hit path preserves them. If you only run enabled, a missing field on the hit path looks normal until a consumer depends on it; if you only run disabled, you never exercise a hit at all.

What if the cache serves a legitimately stale response during revalidation?

Then the contract must say so explicitly, and the response must carry validation: stale. Serving stale data is a valid strategy for some endpoints, but the consumer has to be able to tell. The contract test asserts that a stale response is labeled, not that stale responses never happen.

Does this apply if the cache is in front of a different kind of model or service?

Yes. The pattern is about where the cache sits, not what it caches. Any layer between the engine and the store that can answer without consulting the store adds the same blind spot. The fields may differ, but the rule holds: a hit must be distinguishable from a miss in the payload the contract sees.

How do I keep the contract from becoming bloated with cache fields?

Only endpoints behind a read-through cache carry the block, and only for scenarios where a hit is possible. Write paths and uncached reads assert that no cache fields appear. That keeps the contract as narrow as the interface it describes.

Sources

Sources

  1. API Contract Testing: What It Is, How It Works and Tools
  2. What Is Read-Through Cache?
  3. Preventing API Drift with Contract Tests
  4. API Contract Testing | Enterprise UI
  5. Agent Caches That Quietly Go Stale | by Thinking Loop
  6. Your Caching Strategy Is Not a Strategy
  7. Cache Write Policies: Pros & Cons - Video - Study.com
  8. PDF Caches (Writing) - Cornell CS
  9. Ep 077: Cache Write Policies, Flag Bits, and Split Caches - YouTube
  10. L23: Cache Design, Management, and Tradeoffs (Spring 2026)
  11. Choosing a testing strategy - EF Core
  12. Avoid In-Memory Databases for Tests
  13. The Case for Contract Testing: Cutting Through API ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles