Use Case 10: Caching + Population (Read-Through, Populated on Read)
Scenario: A service needs fast reads of a User and its related Profile. We want the cache-hit path to skip the DB entirely while still returning a populated document.
๐ฆ What's New?
| Component | Description |
|---|---|
CachedBaseRepository |
Provides the cache layer (cache-first get_by_id, invalidation on update/delete). |
PopulationEngine |
Provides the reference resolution. |
| Composed subclass | A small CachedBaseRepository subclass adds two _populate helpers so cache hits return populated models. |
โ ๏ธ Known limitation: there is no built-in
CachedPopulatingRepositoryin the library. This use case documents the composition. We cache the raw (depopulated) document โ references stayObjectIds until read time, soProfilechanges are reflected on the next fetch (within TTL) andupdate/deleteinvalidation remains correct.
๐ Example
from contextlib import asynccontextmanager
from typing import Any
from bson import ObjectId
from fastapi import FastAPI
from mongo_ops import BaseDocument, ModelRegistry, MongoConnectionManager
from mongo_ops.cache import CacheConfig, CachedBaseRepository, InMemoryCacheBackend, decode_value
from mongo_ops.populate import PopulateRule, PopulationEngine
# 1. Models โ `profile` holds an ObjectId in DB, a Profile in memory.
class Profile(BaseDocument):
avatar_url: str = ""
bio: str = ""
class User(BaseDocument):
username: str = ""
email: str = ""
profile: Profile | None = None
# 2. Engine + rule.
engine = PopulationEngine({})
profile_rule = PopulateRule(field_name="profile", collection_name="profiles")
# 3. Composed repository.
class CachedUserRepository(CachedBaseRepository[User]):
def __init__(
self,
cache_backend: InMemoryCacheBackend,
population_engine: PopulationEngine,
populate_rules: list[PopulateRule],
config: CacheConfig | None = None,
):
super().__init__("users", User, cache_backend, config)
self.population_engine = population_engine
self._populate_rules = populate_rules
async def _populate(self, data: dict[str, Any]) -> dict[str, Any]:
"""Dict-level resolution (mirrors PopulatingRepository._populate)."""
for rule in self._populate_rules:
ref = data.get(rule.field_name)
if ref is None:
continue
if isinstance(ref, str): # JSON round-trip turns ObjectId into hex str
ref = ObjectId(ref)
if isinstance(ref, list):
resolved = []
for item in ref:
item = ObjectId(item) if isinstance(item, str) else item
repo = self.population_engine._repos.get(rule.collection_name)
doc = await repo.get_by_id(item) if repo else None
resolved.append(doc)
data[rule.field_name] = resolved
elif isinstance(ref, ObjectId):
repo = self.population_engine._repos.get(rule.collection_name)
data[rule.field_name] = await repo.get_by_id(ref) if repo else None
return data
async def get_by_id(self, id):
if not self._cache_config.enabled:
return await super().get_by_id(id) # no cache -> raw, unpopulated
cached = await self._cache.get(self._cache_key(id))
if cached is not None:
# Cache hit: decode the RAW doc, then populate before returning.
data = await self._populate(decode_value(cached))
return self.model(**data)
# Cache miss: single DB read; the base class caches the raw (depopulated) doc.
result = await super().get_by_id(id)
if result is None:
return None
return await self.model(**await self._populate(result.model_dump(by_alias=True)))
# 4. Wire-up โ one backend for both the repo and the registry lifecycle.
cache = InMemoryCacheBackend(max_entries=20_000, default_ttl=600)
ModelRegistry.set_cache_backend(cache)
@asynccontextmanager
async def lifespan(_app: FastAPI):
async with MongoConnectionManager.lifespan(
uri="mongodb://localhost:27017", db_name="app_db"
):
# Register the repositories the engine resolves refs against:
# engine.register_repo("profiles", ProfileRepo())
engine.register_repo("users", CachedUserRepository(cache, engine, [profile_rule]))
await ModelRegistry.initialize_all()
await ModelRegistry.initialize_cache()
yield
await ModelRegistry.shutdown_cache()
app = FastAPI(lifespan=lifespan)
Note: replace the placeholder
engine.register_repo("profiles", ...)line with a repository forProfile. A matchingCachedBaseRepository[Profile]("profiles", Profile, cache)keeps profile reads cached too โ e.g.engine.register_repo("profiles", CachedBaseRepository[Profile]("profiles", Profile, cache)).
๐ก Tips
- Cache the raw doc, populate at read time. Ref fields stay
ObjectIds in the cache; JSON round-trips them to hex strings, so_populatemust normalizestr โ ObjectId(shown above). update/deletefrom the base class still invalidate the right key because we never repopulate in the cache.- Set a sensible
default_ttlโ cachedUserentries resolveProfileon each read, so profile edits show up within the TTL (or callinvalidate_cache(user_id)explicitly). - If you want one repo class used app-wide, keep the populate helpers in a mixin shared with regular
PopulatingRepository.