query cost
why
an unbounded read is silent until tables grow: at 100 rows an N+1 loop takes 50ms, at 50k rows it takes 25 seconds and the screen dies. naming cost at design time — indexes touched, rows scanned, round trips — is the only moment the fix is cheap; after ship the defect is found by a 3 a.m. alert instead of by reading the query.
rule
every durable read names its cost before it ships: which indexes it hits, how many rows it can touch, how many round trips the answer needs. a read whose cost grows with table size — not page size — is a defect, caught at design time by reading the query, not at incident time by an alert.
cost model
| read shape | acceptable cost |
|---|---|
| indexed point lookup | O(1) round trip, one row. |
| cursor page over indexed order | O(page size) rows, one round trip. |
| aggregate projection | bounded by the aggregate's declared window, computed from durable facts. |
| per-item hydration inside a loop | rejected — batch into one read or denormalize the fact. |
the N+1 shape is the canonical violation: a list read followed by a per-row lookup for a related fact. the fix is contract-first — the page response embeds the related projection, or the relation is denormalized into the row at write time per database design. "fast enough on 100 rows" is not a defense; the table grows.
write-side and read-side responsibilities
| concern | owner |
|---|---|
| indexes matching the stable orders and filters | schema migration, per schema migrations. |
| bounded response shape | the API contract, per interface-shaped api. |
| batching related reads | the domain service composing the projection. |
| count accuracy | declared: exact count is a cost decision, not a default — approximate counts are named in the contract when chosen. |
do not
- ship a read that filters an unindexed column over a growing table.
- hydrate related facts per-row in a loop when a batch read or embedded projection exists.
- hide an expensive read behind a small-looking endpoint; the cost belongs in the slice.
- add an index without naming the query it serves; orphan indexes have their own cost.
verification
for each list operation in the contract: read its generated SQL or query plan, name the index used, record the plan in the slice. insert an order-of-magnitude more rows in a non-production environment and confirm page latency stays flat in page size. count round trips per screen load; every screen is a bounded, named number of queries.
good and bad
bad
const rows = await listOrders(cursor); // 1 queryfor (const row of rows) {row.customer = await getCustomer(row.id); // N more queries}
cost grows with page size; latency climbs silently as data accumulates until the screen times out under normal traffic. the fix under load is an emergency rewrite instead of the one-line batch a design review would have caught.
good
const rows = await listOrders(cursor); // 1 queryconst customers = await getCustomers(rows.map(id)); // 1 batched query
one page read plus one batched hydration — bounded round trips regardless of table size. latency stays flat, capacity planning is arithmetic over named queries, and design review rejects an expensive read by reading the contract instead of discovering it in an incident.