cratis-performance-review · diff
git:20261013.615e6a9 to git:20261016.8d8ee96
5 added, 2 removed. Audit A to A.
---
name: cratis-performance-review
description: Perform a focused scalability review of changed code in a Cratis application — Chronicle observers and replay, read-model query shape, command and query payloads, .NET enumeration, and React render cost — and report findings by risk. Use when asked to check for performance or scalability problems. Do not use for ordinary implementation and do not override the authoritative paging guidance.
license: MIT
---
# Cratis performance review
Most performance findings in a Cratis application are shape problems, not
hot-loop problems: a projection that has to re-read, a query that materializes
before it filters, a payload nobody uses. They are cheap to fix while the change
is open and expensive once data has grown behind them.
Two things make this stack different. **A projection must be able to replay the
entire history** — a cost that is invisible at development volumes and fatal at
production ones. And **an event is permanent**, so an oversized event is a
storage and replay cost that never goes away.
## Verified product sources
| Package | Purpose |
| --- | --- |
| `Cratis.Chronicle` | Observers, projections, reducers, reactors, replay |
| `Cratis.Arc.Core` | Query return shapes and server-side paging |
| `@cratis/components` | `DataTable` paging surface |
Paged MongoDB observations re-read page and total on each change; see the
read-model query reference for the distinction from unpaged retained sets.
Reverify against the owning product repository before asserting a framework
behavior this file does not already state.
## Route near misses
- General correctness and maintainability: use `cratis-code-review`. Its
performance section is the pass-by list; this skill is the focused one. When
both have run, do not restate the same finding twice.
- Adding paging to a query rather than judging one: use
`cratis-arc-query-paging`. That skill is authoritative on the mechanism; this
one does not override it.
- Security or data exposure: use `cratis-security-review`. An over-fetching
payload is a finding for both — say which lens produced it.
## Step 1 — Chronicle and event sourcing
- **A projection joins on events, never on a read model.** A read-model join
forces a re-read the projection engine cannot optimize, and it makes the
projection depend on another observer's position.
- **A reactor does not re-query the event log inside its handler.** The event it
received already carries what it needs. (Reactor dispatch is by the handler's
first parameter type — the method name is free, so look for the event-typed
parameter, not for a method called `On`.)
- **A new projection can replay all earlier events without failing.** Ask it
explicitly: at production volume, does this projection complete a full replay?
- **Events are small.** No large blob and no base64 payload embedded in an
event; put the content elsewhere and carry a reference.
- No eager load of a whole event sequence without paging or filtering.
- AutoMap is on by default. Hand-mapping every property is both a maintenance
and a cost problem — and, since AutoMap runs anyway, it does not save the work
it appears to.
## Step 2 — Read models and their store
- Queries filter on indexed fields. An unintentional full-collection scan is the
most common finding here.
- - **A list that can grow returns `IQueryable<T>`**, so Arc applies server-side
- paging and sorting. Materializing and then slicing in memory reads the whole
+ - **A list that can grow returns a shape Arc pages** — `IQueryable<T>` for a
+ LINQ-native provider, `collection.Observe(...)` for MongoDB — so Arc applies
+ server-side paging and sorting. A query that writes its own `Skip`/`Take` or
+ `OrderBy` for the client's page or sort is a finding, as is MongoDB
+ `AsQueryable()` (translation failures at request time). Materializing and then slicing in memory reads the whole
collection every request.
- No N+1: one query returns what the caller needs.
- A count is a count. Hydrating the collection to measure its length is the
same finding as an unpaged list, wearing different clothes.
- Prefer slim projection-backed summaries over repeatedly assembling broad
collections. Query composition is supported; one collection per query is not
a framework requirement. Bound each input and measure recomputation.
- Embed bounded related state, not growing histories; page timelines separately.
Evaluate variants for divergent lifecycle shapes, not every status field.
- Preserve reducer admission and ordering guards when proposing a projection
conversion; an unconditional setter may resurrect or prematurely create state.
- Count passive models as inventory, not a smell. Assess history length and read
frequency against consistency/security needs. `IMongoCollection<T>` cannot
read passive computed state; never materialize secrets to make that query work.
- Distinguish retained `Observe` state and in-memory emissions from explicit
recompute queries. Narrow source filters and inspect page-refill/membership
lookups; do not claim every change re-queries the entire collection. See the
**cratis-chronicle-read-model** query reference for versioned behavior.
## Step 3 — Commands and queries
- A response payload carries only fields the client uses.
- Command validators are synchronous and in-memory. Validation is on the hot
path of every attempt, including the ones that will be rejected — an I/O call
there is paid on every request.
- No `await Task.Run(() => syncWork)` wrapping for work that is naturally
asynchronous.
## Step 4 — .NET
- No `.ToList()` before `.Where()`. Filter before materializing.
- An `IEnumerable<T>` is not enumerated more than once — materialize once when
it must be reused.
- Large-object logging uses the destructuring form only at `Debug` level, so a
production log level does not pay to serialize it.
## Step 5 — React
- A `DataTable` over a growable collection uses lazy loading and a paginator
rather than rendering every row.
- No inline object or array literal passed as a prop: it is a new identity every
render and defeats memoization downstream.
- `useEffect` dependencies are correct — neither missing nor over-broad. An
over-broad dependency array is a re-run per render, which reads as a
correctness bug and behaves as a performance one.
- Components over large collections hold stable references or are memoized.
- No `JSON.parse(JSON.stringify(x))` deep cloning.
## Step 6 — Report
Open with one line:
> **Performance review: No issues / Minor findings / Blocking issues found**
Group findings by the section that produced them, and classify each:
| Risk | Meaning |
| --- | --- |
| **High** | Measurable degradation at moderate load — fix before merge |
| **Medium** | Degrades under load or as data grows |
| **Low** | Minor inefficiency |
Close with a per-section summary table.
**Say what the finding costs and at what scale.** "This is slow" is not
actionable; "this scans the whole collection on every page load, so it is fine
at hundreds of rows and not at tens of thousands" is. And **name what you did
not measure** — a review of code shape is not a benchmark, and reporting it as
one overstates the evidence.
## What breaks
- **A shape finding is reported as a measurement.** Reading code tells you the
shape; only running it tells you the cost. Say which one you have.
- **The paging advice contradicts the paging skill.** `cratis-arc-query-paging`
is authoritative on the mechanism. Report the missing paging; do not invent an
alternative to it.
- **A replay cost is judged at development volume.** A projection over a hundred
seeded events proves nothing about a replay over a production history.
- **Every finding is High.** The classification is the value; when all findings
are urgent, none are.
## How it is proven
The build and specifications are green before the report is written; each
finding cites the file and line and states the scale at which it bites; and the
report says plainly that it reviewed code shape rather than measured behavior,
naming anything that would need a benchmark to settle.