Skip to main content

CQRS in Framework M

Command Query Responsibility Segregation (CQRS) is Framework M's escape hatch for DocTypes whose read workload no longer fits a row-per-document table. The write side stays on the GenericRepository; the read side is separated from it at three escalating levels. Each level is opt-in per DocType, so the default path — one repository, one table — is untouched until you choose otherwise.

LevelSeparationEnabling knob
1 — Read replicaReads hit a second database engineFRAMEWORK_M_DB_REPLICA_URL
2 — Inverted child-table viewsChild rows browsable standalone, reads via read-only routesChild DocType + is_child_table metadata
3 — Custom read modelsReads served by a per-DocType projection, not the tableframework_m.adapters.read_model entry point

Level 1 — Read replica​

The lightest form of CQRS: the same repository, the same SQL, but read operations execute against a dedicated replica engine instead of the primary. Writes still go to the primary.

When FRAMEWORK_M_DB_REPLICA_URL is configured, the lifespan registers the replica as a bind (session.py) and SessionFactory.get_read_session() hands out a session bound to it. Every generic read route — list, get-one, singleton GET — uses the read session and falls back to the primary when no replica is registered:

async with repo_factory.session_factory.get_read_session() as session:
items, total = await repo.list_entities(session, filters=...)

Read-your-own-writes​

A replica can lag the primary, so a user who just wrote a record might not see it on the next read. Framework M covers this with an X-Force-Primary: true request header: the frontend sends it for reads made within 5 seconds of a create/update/delete, and the read route then uses the primary session. The result is method-based read-your-own-writes without any cross-node coordination.

Routing at the router, not the repository​

Replica routing happens at the web/router layer, deliberately not inside GenericRepository. Callers inject a single session into the repository to wrap multi-repo operations in one transaction (the Unit of Work pattern); if the repository reached for a replica session itself, that transaction guarantee would break. Keeping the choice of session in the route preserves the UoW and makes the replica an explicit per-request decision.

Pagination posture (cursor pagination declined)​

The generic read path paginates with offset + limit, and large-table totals use a bounded count: count() runs SELECT count(*) FROM (SELECT 1 … LIMIT threshold+1) and reports the sentinel 10000+ instead of scanning the whole table (see the count_threshold parameter). This offset-plus-bounded-count posture is the chosen performance posture — keyset/cursor pagination is explicitly not implemented. If profiling later shows deep offset pages are a real cost, the migration path is a composite index on (creation, id) so a keyset variant can be added without a schema rethink.


Level 2 — Inverted child-table views​

Child tables (line items, addresses, permissions) are normally only manipulated through their parent document, which owns their validation, lifecycle checks, and domain logic. But read access is another matter: child rows are ordinary tables, so they can be browsed standalone without routing every lookup through the parent.

The generic list route serves child DocTypes read-only:

  • create_meta_router mounts child tables with create_crud_routes(read_only=True) — only list_entities/get_entity (plus singleton GET) exist; POST/PUT/ DELETE/restore are omitted, so writes get 405/404. Writes must go through the parent's endpoints, preserving its validation, submitted-document lifecycle, and domain logic (e.g. recalculating a Purchase Order total when a line item changes).
  • The metadata payload carries is_child_table, so the Desk renders a Parent link column pointing back to the owning document (parent_doctype/parent_id are merged into list/get responses from the DB parent/parenttype columns).
  • An explicit api_resource opt-in still wins over child-table read-only: a DocType that declares both gets full CRUD.

In CQRS terms this is an inverted view: the same rows, presented as their own read surface instead of only as nested fragments of the parent.


Level 3 — Custom read models​

For DocTypes whose read workload no longer fits a row-per-document table, the full escape hatch: a separately maintained, query-optimized projection of the same data, served in place of the GenericRepository.

When to use a read model​

  • The list endpoint needs an exact total (pagination) and the underlying table is too large for a bounded count.
  • The list endpoint should not hit the relational table at all (denormalized aggregates, GROUP BY rollups, column-store, full-text search).
  • You want the read path eventually consistent with the write path rather than transactionally coupled to it.

Do not use a read model when the default repository already serves the DocType fine — the read model adds a projection you must keep in sync.

The protocol​

ReadModelProtocol lives in framework_m_core.interfaces.read_model and has three methods:

MethodPurpose
project(event)Apply a domain event to the projection (idempotently)
query(...)Serve the optimized read; returns ReadQueryResult
rebuild()Rebuild the projection from event history

query() is what the web layer calls. It takes list[FilterSpec] (operators preserved — EQ/LT/LIKE/IN), an order_by list (-field for descending), and limit/offset; it returns a ReadQueryResult:

from framework_m_core.interfaces.read_model import ReadQueryResult

result = await read_model.query(
filters=[FilterSpec(field="amount", operator=FilterOperator.GT, value=100)],
order_by=["-total"],
limit=20,
offset=0,
)
# result.items -> list[dict], result.total -> exact count for pagination

Worked example: per-DocType invoice analytics​

The Invoice DocType keeps its transactional table. A separate read model serves the monthly-total list view.

1. The projector — keep the read model in sync​

A projector subscribes to the DocType's lifecycle events (doc.created, doc.updated, doc.deleted) and updates the projection. Make it idempotent: reprocessing the same event must produce the same state (the event bus may redeliver).

# analytics/invoice_read_model.py
from framework_m_core.interfaces.event_bus import Event
from framework_m_core.interfaces.read_model import (
ReadModelProtocol,
ReadQueryResult,
)


class InvoiceAnalytics(ReadModelProtocol):
"""Denormalized monthly totals per customer, kept in sync via events."""

def __init__(self, session_factory) -> None:
self._session_factory = session_factory

async def project(self, event: Event) -> None:
doctype = event.data.get("doctype") if event.data else None
if doctype != "Invoice":
return
async with self._session_factory.get_session() as session:
if event.type == "doc.deleted":
await self._delete_row(session, event.data)
else:
await self._upsert_row(session, event.data) # idempotent

async def query(
self,
filters: list[FilterSpec] | None = None,
order_by: list[str] | None = None,
limit: int = 100,
offset: int = 0,
) -> ReadQueryResult:
# Analytics projection: GROUP BY customer + month, exact COUNT for total
async with self._session_factory.get_session() as session:
rows, total = await self._run_query(
session, filters, order_by, limit, offset
)
return ReadQueryResult(items=rows, total=total)

async def rebuild(self) -> None:
# Truncate the projection table and replay the Invoice event history.
...

def register(self, bus) -> None:
"""Subscribe the projector to the events it cares about."""
bus.subscribe("doc.created", self.project)
bus.subscribe("doc.updated", self.project)
bus.subscribe("doc.deleted", self.project)

2. Register it for the DocType​

Read models are registered per DocType through the framework_m.adapters.read_model entry-point group. The framework instantiates the class once at startup (ep.load()()), so the class constructor must take no required arguments — resolve dependencies (session factory, event bus) from the DI container or module-level globals.

# pyproject.toml in your app
[project.entry-points."framework_m.adapters.read_model"]
Invoice = "analytics.invoice_read_model:InvoiceAnalytics"

Any DocType without a matching entry point is untouched and keeps hitting the GenericRepository.

3. How the read path routes​

When the web layer serves GET /api/v1/Invoice, meta_router asks the RepositoryFactory for a read model first:

GET /api/v1/Invoice
└─ repository_factory.get_read_model("Invoice")
├─ registered ─► read_model.query(filters=rls_filters, order_by, limit, offset)
│ └─ ReadQueryResult ─► {items, total, has_more, count_capped}
└─ not found ─► GenericRepository (default behavior, unchanged)

Routing notes:

  • Precedence. A registered read model replaces the repository for the list endpoint — the repository is only used when no read model exists.
  • RLS still applies. The user/tenant filters from apply_rls_filters() pass through unchanged as list[FilterSpec], so row scoping is the read model's job too.
  • Bypasses bind/fan-out. A read model is a single global projection, so ?bind=<tenant> and cross-tenant fan-out are skipped for that DocType.
  • Exact pagination. total comes from the read model, so has_more = offset + len(items) < total and count_capped is always False.
  • Write path is unchanged. POST/PUT still go through the GenericRepository; the read model is updated asynchronously by its projector and may lag the write (eventual consistency).

Level 3 and eventual consistency​

The read-model branch bypasses read-replica session routing entirely — a read model is its own eventually-consistent data source, so X-Force-Primary has no effect there. If a DocType needs read-your-own-writes, the projector/write path must provide it (e.g., a synchronous first-write projection), not the read side.


Related: