How a shared cache quietly breaks module boundaries
In a modular monolith, code boundaries are usually enforced: modules call each other through interfaces, and a linter or code review stops one module from importing another's internals. The cache rarely gets the same treatment. Every module holds the same Redis client, and nothing stops the billing module from reading user:42 because it knows the users module stores a profile there.
That shortcut turns Redis into an undeclared shared database. The users module believes it owns that key's shape, its lifetime, and when it gets deleted. In practice, any module that reads the key depends on all three. Suppose the users team renames a field in the cached JSON, or stops caching inactive accounts. Their tests pass. Billing then reads a missing field, or misses the cache and falls back to a query nobody tuned for that load. The failure appears in another team's module, often only under production traffic. Nothing in the codebase linked the two.
The fix is to give the cache the same treatment as code. Each module owns an explicit cache namespace. Only that module writes or deletes keys inside it. The module also publishes its invalidation rules as part of its public contract: what gets cached, what events evict it, and how stale a reader should expect it to be. Any other module that needs the data goes through the owning module's interface.
This guide is for teams running several modules against one shared Redis. It matters most if you expect to extract some of those modules into separate services later. Once a module moves out, an undeclared dependency on its keys is no longer a coupling problem. It is a production outage.
BEFORE: the cache is an undeclared shared database
+-------------+ +-------------+
| users | | billing |
| module | | module |
+------+------+ +------+------+
| writes user:42 | reads user:42
| | (hidden dependency)
v v
+----------------------------------------------------------+
| shared Redis: user:42 invoice:7 ... (no owners) |
+----------------------------------------------------------+
AFTER: each module owns a namespace and publishes its rules
+---------------------------+ getProfile(42) +-------------+
| users module |<-----------------| billing |
| public contract: | | module |
| - what is cached | +------+------+
| - which events evict it | |
| - expected staleness | |
+------------+--------------+ |
| writes/deletes users:* only | billing:* only
v v
+----------------------------------------------------------+
| shared Redis |
| [ users:* owned by users ] [ billing:* by billing ] |
+----------------------------------------------------------+
Before, billing reads the users module's Redis keys directly; after, each module writes only its own namespace and other modules go through its interface and published invalidation contract.
Key naming that encodes ownership: :::
The first defense is a key format that makes ownership visible: billing:v3:invoice:8812. The module prefix answers "whose data is this?" for anyone reading a MONITOR trace or a memory report, and it scopes later namespace operations to one module. The schema-version segment lets a module change its serialized shape without coordinating with anyone. When the invoice payload gains a field, bump v3 to v4 and deploy. New code reads and writes v4 keys, and old v3 entries age out through their TTLs. There is no flush and no window where new code deserializes old bytes.
Only the owning module's code should construct these Redis keys. If orders formats billing:v3:invoice:{id} itself, it has copied billing's internal layout, and the next version bump silently breaks it. Put key construction behind a small per-module cache client. Each module instantiates it with its own prefix and version, and other modules never get a handle to it. The client exposes typed methods like get_invoice(id). It builds the key, serializes and deserializes values in one place, and applies the TTL.
Treat TTLs as a safety net for invalidation bugs you haven't found yet. Set each entity's TTL from its staleness tolerance. A feature-flag lookup might tolerate seconds, a country list hours. Add random jitter, for example ±10%, so keys written together don't expire together and stampede the database. The client should require a TTL, and "no expiry" should be a named option that shows up in code review.
import json
import random
import redis
NO_EXPIRY = object() # explicit, greppable opt-out; every use needs a review comment
class BillingCache:
"""Owned by billing. Other modules never receive an instance."""
_PREFIX = "billing"
_VERSION = "v3" # bump when a payload's shape changes; old keys age out via TTL
def __init__(self, client: redis.Redis):
self._r = client
def _key(self, entity: str, id_: int | str) -> str:
return f"{self._PREFIX}:{self._VERSION}:{entity}:{id_}"
def _set(self, key: str, value: dict, *, ttl) -> None: # ttl is required
data = json.dumps(value)
if ttl is NO_EXPIRY:
self._r.set(key, data)
return
# +/-10% jitter so keys written together don't expire together
self._r.set(key, data, ex=max(1, round(ttl * random.uniform(0.9, 1.1))))
def _get(self, key: str) -> dict | None:
raw = self._r.get(key)
return None if raw is None else json.loads(raw)
# Typed methods. Each entity's TTL comes from its staleness tolerance.
def get_invoice(self, invoice_id: int) -> dict | None:
return self._get(self._key("invoice", invoice_id))
def set_invoice(self, invoice_id: int, invoice: dict) -> None:
self._set(self._key("invoice", invoice_id), invoice, ttl=300)
def delete_invoice(self, invoice_id: int) -> None:
self._r.delete(self._key("invoice", invoice_id))
A per-module Redis cache client that is the only place billing keys are built, serializes values with JSON, requires a jittered TTL on every write, and makes 'no expiry' an explicit named option.
Namespace-level invalidation with generation counters instead of key scans
When a module needs to drop everything it has cached for an entity type, such as after a bulk import or a pricing rule change, the obvious approach is to delete keys by pattern. On production Redis that approach causes problems. KEYS billing:* walks the entire keyspace in one call, and because Redis executes commands on a single thread, every other client waits until the call finishes. SCAN with MATCH avoids that stall by returning results in batches, but it introduces two other issues. A large keyspace takes many round trips to cover. The operation is also not atomic: keys written during the scan may or may not be returned, so readers can repopulate entries you have already deleted, and you have no single point at which the invalidation is complete.
A generation counter avoids deletion entirely. Store an integer per module or per entity type, for example billing:gen:invoice, and include its value in every key: billing:v3:invoice:g17:8812. To invalidate, run INCR on the counter. This is a single atomic step, and from that point new lookups build g18 keys. The g17 keys still exist, but no code path can construct their names, so they expire through the TTLs required in the previous section.
| Approach | Blocks server | Atomic cutover | Cost per lookup |
|---|---|---|---|
KEYS + DEL |
Yes | No | None |
SCAN + DEL |
No | No | None |
| Generation counter | No | Yes | One extra counter read |
The extra read on every lookup is the main cost. You can cache the counter in process memory for a short period, such as one second, to remove most of those reads. The trade-off is that readers may keep using stale entries for up to that period after an invalidation, so each invalidation rule should state this window explicitly. Abandoned generations also continue to use memory until their TTL expires. Size maxmemory with headroom for these keys, particularly for namespaces that invalidate frequently.
import time
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
GEN_KEY = "billing:gen:invoice" # no TTL: if it vanished, it would restart at 0 and reuse old key names
LOCAL_TTL = 1.0 # max staleness window after an invalidation, in seconds
_local = {"gen": None, "fetched_at": 0.0}
def current_gen() -> int:
# Cache the counter in process memory to skip the extra Redis read on most lookups.
now = time.monotonic()
if _local["gen"] is None or now - _local["fetched_at"] > LOCAL_TTL:
_local["gen"] = int(r.get(GEN_KEY) or 0)
_local["fetched_at"] = now
return _local["gen"]
def invoice_key(invoice_id: int) -> str:
# The generation is part of the name, so older generations cannot be addressed.
return f"billing:v3:invoice:g{current_gen()}:{invoice_id}"
def get_invoice(invoice_id: int, load_from_db) -> str:
key = invoice_key(invoice_id)
cached = r.get(key)
if cached is not None:
return cached
value = load_from_db(invoice_id)
# Every entry has a TTL, so abandoned generations free their memory on expiry.
r.set(key, value, ex=3600)
return value
def invalidate_all_invoices() -> None:
# One atomic INCR instead of KEYS or SCAN plus DEL. Other processes
# switch to the new generation within LOCAL_TTL.
r.incr(GEN_KEY)
_local["gen"] = None # this process switches immediately
A Python redis-py sketch that adds a generation counter to invoice cache keys. It caches the counter locally for one second and invalidates every invoice entry with a single INCR, so no keys are scanned or deleted.
Invalidation rules as a public contract, including cross-module views
Each module publishes its invalidation rules next to its public API. The rules list which writes delete which keys and which writes bump which generation counter. Treat this list as a contract. If a write path is not listed, there is no guarantee that it invalidates anything. Other modules never delete keys outside their own prefix, even when they know the key format. A foreign delete couples them to another module's schema version and key layout, and the owner can no longer change either safely. Instead, a module learns about changes in one of two ways: it subscribes to the owner's domain events, or it calls an invalidation method that the owner exposes.
Worked case: order summary with customer name
An order summary shows order lines alongside the customer's display name. The orders module serves the view, so it owns the entry under the orders: prefix. It invalidates the entry on its own writes. It also subscribes to the customers module's CustomerRenamed event. Some customer changes may emit no event at all, such as a bulk import that writes directly to tables. To cover those gaps, give the entry a short TTL as a backstop.
| Mechanism | Who triggers it | Failure mode |
|---|---|---|
| Owner's own writes | Orders module | Bugs in unlisted write paths |
| Upstream domain event | Customers module | Missed or reordered delivery |
| Short TTL | Cache expiry | Stale data until expiry |
Events can be lost or arrive out of order, and either case leaves a stale entry behind. Write down the staleness bound for this view. When event delivery fails, the bound is the backstop TTL.
import json
import redis
r = redis.Redis(host="localhost", port=6379)
# Orders invalidation contract (published next to the orders public API):
# place_order, update_order_lines -> delete orders:summary:{order_id}
# customers.CustomerRenamed event -> delete every summary for that customer
# Unlisted customer writes (e.g. bulk import) -> no event; TTL backstop only
# Staleness bound if events are missed or reordered: SUMMARY_TTL_SECONDS.
SUMMARY_TTL_SECONDS = 300 # illustrative value; choose per view
def summary_key(order_id: str) -> str:
return f"orders:summary:{order_id}" # orders owns the orders: prefix
def index_key(customer_id: str) -> str:
return f"orders:summaries_by_customer:{customer_id}"
def cache_summary(order_id: str, customer_id: str, summary: dict) -> None:
pipe = r.pipeline()
pipe.set(summary_key(order_id), json.dumps(summary), ex=SUMMARY_TTL_SECONDS)
# Record which summaries embed this customer's name so the event handler can find them.
pipe.sadd(index_key(customer_id), order_id)
pipe.expire(index_key(customer_id), SUMMARY_TTL_SECONDS)
pipe.execute()
def on_order_written(order_id: str) -> None:
r.delete(summary_key(order_id)) # owner's own write path, listed in the contract
def on_customer_renamed(event: dict) -> None:
# Handler for the customers module's domain event. It deletes only orders: keys
# and never touches customers:* keys, even though their format is known.
order_ids = r.smembers(index_key(event["customer_id"]))
if order_ids:
r.delete(*(summary_key(oid.decode()) for oid in order_ids))
# A reader that fetched the old name before this ran can still repopulate a
# stale entry afterwards; the TTL above is what bounds that window.
The orders module owns a composite summary entry under its own prefix, invalidates it on its own writes and on the customers module's CustomerRenamed event, and documents the TTL backstop as the staleness bound for missed or reordered events.
Ordering writes and invalidation around transaction commit
The usual default is to write the database first and then delete the cache key, not overwrite it. Deleting is idempotent, and the next reader rebuilds the entry from committed state. Writing the cache in place lets two concurrent writers land their database commits and cache writes in opposite orders. That leaves the older value cached with no expiry in sight.
Delete-after-write still has a race. A reader misses the cache and loads row version 1. A writer then commits version 2 and deletes the key. The reader, delayed by a GC pause or slow network, writes version 1 back. The cache now holds stale data until the TTL expires. The window is narrow, but under enough traffic it will happen. There are three common mitigations:
- Delayed second delete. The writer deletes again after a short interval that is longer than a typical read-and-populate. This shrinks the window but doesn't close it, and the delay is a guess.
- Versioned values. Store a row version or update timestamp with the entry. Populate with a conditional write that refuses to replace a newer version. This closes the race at the cost of an atomic compare on every fill.
- Invalidate only after commit. If you delete inside the transaction, a rollback can still evict a valid entry, and a crash between commit and delete leaves stale data behind. An after-commit hook fixes the first problem. An outbox row written in the same transaction and processed by a relay fixes both, because a committed change always leaves a durable invalidation record.
None of these gives you linearizable reads. For balances, inventory reservations, or permission checks that must reflect the latest write, read from the database. Making a cache strongly consistent costs more than it saves.
Operational guardrails and preparing for extraction
Attribution and enforcement
Because every key starts with its owning module, you can attribute memory without per-module instances. Run a periodic job that walks the keyspace with SCAN, samples keys, calls MEMORY USAGE on them, and aggregates by prefix. Redis reports hits and misses only for the whole instance, so per-module hit rates have to come from counters in each module's cache client, labeled by namespace. Together these show which module is evicting everyone else's data.
Prefixes only help if nobody bypasses them. Add a lint rule or code-review check that flags raw key strings or direct Redis client calls outside a module's cache client. Without that check, one handwritten orders:... key in the billing module recreates the hidden coupling the namespaces were meant to remove.
Schema version bumps
When a module changes its serialized format, it bumps the schema version in its key prefix at deploy time. Old and new instances then read and write separate keys and never see each other's format. The cost is a cold cache for that module. During a rolling deploy, misses roughly double and both key sets occupy memory until the old keys expire or are evicted. Schedule these bumps with that load in mind.
What Redis won't protect
A shared instance does not enforce prefixes. A careless script, or one FLUSHDB, wipes every module at once. Restrict destructive commands through ACLs, and limit ad-hoc access to the cache.
Extraction
Owned namespaces and event-based invalidation keep each module's cache usage contained. To move a module to its own Redis instance or logical database, you change one cache client's connection settings. If the module later becomes a separate service, other modules already invalidate through its published events, not its keys, so their code does not change.
Alternatives, when to avoid this, and the impact on cost and engineering time
Each option trades structure against overhead in a different way:
| Approach | Ownership clarity | Invalidation | Operational cost | Fits when |
|---|---|---|---|---|
| Free-for-all shared cache | None; any module reads any key | Ad hoc and easy to miss | Lowest | Prototypes, one small team |
| Per-module namespaces, one instance | Key prefix names the owner | Generation counter per namespace | Low | Most modular monoliths |
| Separate instances or logical databases | Enforced by connection | Scoped flush per instance or database | Higher: more connections, config and monitoring | Modules with different memory or eviction needs |
| Short TTL only, no explicit invalidation | Not needed | Expiry only | Low, but more database reads | Data that tolerates seconds of staleness |
Strict ownership adds indirection. In a small codebase, routing every invalidation through the owning module's API can feel like ceremony. Separate instances multiply connection pools, alerting and failover configuration. TTL length trades database load against staleness. A session lookup and a pricing table have different tolerances, so a single global TTL policy usually fits neither of them.
The structure saves time in four places:
- Fewer hidden-consumer breakages. No other module reads your keys directly.
- Cheaper format changes. Bumping the schema version replaces a migration.
- Faster debugging. Every key is attributed to one owner.
- Smoother extraction. The contract already exists before you split the service.
Skip the extra structure when any of these apply:
- One team owns the whole codebase.
- Cached data is read-mostly and staleness-tolerant, so short TTLs are enough.
- The cache holds only a few entity types.
- No module is a realistic candidate for extraction.
In those cases, a key prefix convention and sensible TTLs are enough.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monolith cache invalidation, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.