Skip to content

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 CachedPopulatingRepository in the library. This use case documents the composition. We cache the raw (depopulated) document โ€” references stay ObjectIds until read time, so Profile changes are reflected on the next fetch (within TTL) and update/delete invalidation remains correct.


๐Ÿš€ Example

Python
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 for Profile. A matching CachedBaseRepository[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 _populate must normalize str โ†’ ObjectId (shown above).
  • update/delete from the base class still invalidate the right key because we never repopulate in the cache.
  • Set a sensible default_ttl โ€” cached User entries resolve Profile on each read, so profile edits show up within the TTL (or call invalidate_cache(user_id) explicitly).
  • If you want one repo class used app-wide, keep the populate helpers in a mixin shared with regular PopulatingRepository.