Skip to content

Read-only document API

When a service only reads an aggregate — a projection, a lookup, a reporting view owned by someone else — give its spec write=None. No command port is registered, so the API can only query, and the wiring stays minimal.

The runnable version lives at examples/recipes/read_only/just run brings up ephemeral Postgres, seeds a couple of rows, and serves the read API.

A read-only spec

write=None is the whole opt-in. The read model still inherits id, rev, and timestamps from ReadDocument:

class ArticleRead(ReadDocument):  # inherits id, rev, created_at, last_update_at
    title: str
    body: str


ARTICLE_SPEC = DocumentSpec(name="articles", read=ArticleRead, write=None)

Wire it read-only

Register the document under ro_documents (not rw_documents) with a PostgresReadOnlyDocumentConfig — it carries only the read relation, no write tables or bookkeeping, and no transaction route is needed:

# Read-only config: just the read relation — no write side, no bookkeeping.
ARTICLE_PG = PostgresReadOnlyDocumentConfig(read=("public", "articles"))


def build_runtime(pg: PostgresClient, *, dsn: str) -> ExecutionRuntime:
    deps = DepsRegistry.from_modules(
        PostgresDepsModule(client=pg, ro_documents={"articles": ARTICLE_PG}),
    )
    lifecycle = LifecyclePlan.from_modules(
        PostgresLifecycleModule(client=pg, dsn=dsn, config=PostgresConfig()),
    )
    return ExecutionRuntime(deps=deps.freeze(), lifecycle=lifecycle.freeze())

Data gets in elsewhere

A read-only doc has no command portctx.document.command(spec) won't resolve. The writer is another service (or a migration); the example seeds rows directly through the client just to be self-contained.

Query routes

Resolve ctx.document.query(spec) and call its read methods:

@asynccontextmanager
async def lifespan(app: FastAPI):
    pg = PostgresClient()
    dsn = os.environ.get("POSTGRES_DSN", "postgresql://forze:forze@localhost:5432/forze")
    _rt.set_once(build_runtime(pg, dsn=dsn))
    async with _rt.get().scope():
        await pg.execute(SCHEMA)
        await seed(pg)
        yield


app = FastAPI(title="Articles API (read-only)", lifespan=lifespan)
register_exception_handlers(app)


@app.get("/articles/{article_id}")
async def get_article(article_id: UUID) -> ArticleRead:
    # `get` raises not_found (→ 404) on a miss; use `find` for the None variant.
    return await ctx().document.query(ARTICLE_SPEC).get(article_id)


@app.get("/articles")
async def list_articles(limit: int = 20, offset: int = 0) -> list[ArticleRead]:
    page = await ctx().document.query(ARTICLE_SPEC).find_many(
        pagination={"limit": limit, "offset": offset}
    )
    return list(page.hits)

The query port gives you the full read surface — pick by how you want misses handled:

Method Returns On miss
get(id) the document raises not_found → 404
get_many(ids) a list raises not_found if any is missing
find(filters) the document or None returns None
find_many(filters, pagination, sorts) a CountlessPage (.hits) empty page
find_page(...) a Page (adds .count) empty page
count(filters) an int 0

Filters and sorts use the query DSL; pagination is {"limit": …, "offset": …}.