Skip to content

Cache reads with Redis

Reading the same document over and over shouldn't hit the database every time. Attach a cache to its specification and wire a Redis backend: reads serve from Redis, writes invalidate. The handlers don't change — caching is pure wiring.

The runnable version of this recipe lives at examples/recipes/cache_reads/just run brings up ephemeral Postgres + Redis, runs it, and tears it down.

Cache the aggregate

The Product is an ordinary document. The only caching-related line is the cache= on its specification:

class Product(Document):
    name: str
    price: int


class ProductCreate(CreateDocumentCmd):
    name: str
    price: int


class ProductUpdate(BaseDTO):
    name: str | None = None
    price: int | None = None


class ProductRead(ReadDocument):
    name: str
    price: int
product_spec = DocumentSpec(
    name="products",
    read=ProductRead,
    write=DocumentWriteTypes(
        domain=Product, create_cmd=ProductCreate, update_cmd=ProductUpdate
    ),
    cache=CacheSpec(name="products"),  # reads cached, writes invalidate
)

CacheSpec(name="products") is the whole opt-in; its TTLs default sensibly.

Wire Postgres + Redis

Reads cache only once a cache backend is registered for the spec's CacheSpec.name. Register the Redis cache next to the Postgres document module — the "products" key is the same logical name on both sides:

def build_runtime(
    pg: PostgresClient,
    redis: RedisClient,
    *,
    pg_dsn: str,
    redis_dsn: str,
) -> ExecutionRuntime:
    deps = DepsRegistry.from_modules(
        PostgresDepsModule(
            client=pg,
            rw_documents={"products": PRODUCT_PG},
            tx={"products"},
        ),
        # caches keyed by CacheSpec.name — this is the whole "cache reads" step
        RedisDepsModule(
            client=redis,
            caches={"products": RedisCacheConfig(namespace="app:products")},
        ),
    )
    lifecycle = LifecyclePlan.from_modules(
        PostgresLifecycleModule(client=pg, dsn=pg_dsn),
    ).with_steps(redis_lifecycle_step(dsn=redis_dsn))

    return ExecutionRuntime(deps=deps.freeze(), lifecycle=lifecycle.freeze())

Postgres stores the documents; Redis answers the repeat reads.

What happens on read and write

The scenario builds the same typed DocumentFacade used by the CRUD examples; only the dependency wiring adds caching:

registry = build_document_registry(product_spec).freeze()


def products(
    ctx: ExecutionContext,
) -> DocumentFacade[ProductRead, ProductCreate, ProductUpdate]:
    return DocumentFacade(
        ctx=ctx,
        registry=registry,
        namespace=product_spec.default_namespace,
    )


async def cache_scenario(ctx: ExecutionContext) -> ProductRead:
    facade = products(ctx)
    cache = ctx.cache(product_spec.cache)  # pyright: ignore[reportArgumentType]

    product = await facade.create(ProductCreate(name="Widget", price=10))

    await facade.get(DocumentIdDTO(id=product.id))  # miss → fills the cache
    if await cache.get(str(product.id)) is None:  # cached now
        raise RuntimeError("expected the read to fill the cache")

    await facade.update(
        DocumentUpdateDTO(
            id=product.id,
            rev=product.rev,
            dto=ProductUpdate(price=12),
        )
    )
    fresh = await facade.get(DocumentIdDTO(id=product.id))  # repopulates, new value
    if fresh.price != 12:
        raise RuntimeError("expected the cached read to see the new price")

    return fresh
  • The first get misses, loads from Postgres, and populates Redis.
  • Repeat gets are served from Redis.
  • An update invalidates the entry; the next get repopulates it with the new value — so a cached read is never stale.

Going further

Read-through is the floor. The cache contract layers stampede protection with background early refresh (serve the still-valid entry, refresh off the request path), an in-process L1 (with W-TinyLFU admission and Redis push invalidation), and adaptive TTLs on top — each a one-line addition to the CacheSpec, covered in Caching reads.

Run it

cd examples/recipes/cache_reads
just run