aggregate design
why
a repeated owner-scoped question answered by scanning history grows in cost and latency with the data, and each route silently re-decides the same tradeoff. a declared aggregate makes the cost bounded and constant, the update atomic with the canonical write, and the value rebuildable from facts when it drifts. without that contract the summary is an invisible authority anyone can read but no one owns or repairs.
rule
design aggregates from the query that must stay bounded. an aggregate answers one exact owner-scoped question from a durable row or projection — not loading every related row, reducing it in application memory, and calling the result "current state."
define the aggregate contract
| question | required answer |
|---|---|
| what fact does this summarize? | the canonical source tables/events and their authority. |
| who owns one aggregate? | the exact scope key, such as one account, process, or parent fact. |
| what result is read? | named count, total, status, latest timestamp, or bounded rollup. |
| how does it change? | transaction with the canonical write, or idempotent projector from durable facts. |
| how is it repaired? | explicit rebuild query/job, input watermark/version, and stale-state behavior. |
| which read uses it? | named route/service/query key with its expected cardinality. |
selection process
| candidate approach | failure | selected direction |
|---|---|---|
| count/filter all history on every request | cost and latency grow with unrelated historical rows | index a bounded canonical query or maintain an aggregate. |
| compute a summary in UI state | different clients disagree and refresh cannot recover it | store/project the durable aggregate by its owner. |
| keep an unversioned cached total | invalidation bugs become invisible authority changes | name it derived and record rebuild/invalidation semantics. |
| update an aggregate without idempotency | retries increment/decrement twice | tie update to the canonical write identity or projector identity. |
| place every statistic in one global row | unrelated owners contend and one query becomes a hotspot | scope aggregates to the smallest exact owner. |
implementation shape
an aggregate row has a domain-prefixed semantic name, owner foreign key, named summary fields, source/update timestamp, and unique owner identity. index the exact owner read, not speculative report shapes. for derived aggregates: retain canonical source facts, make the projector replay-safe; the projection may be deleted/rebuilt without losing business truth.
convex aggregate lessons
Convex makes the tradeoff visible: an aggregate data structure answers count, sum, rank, range, or offset questions in logarithmic lookup work instead of loading every source row. the pattern matters beyond Convex — the aggregate is a separately maintained data structure with explicit key/range/namespace semantics, not a convenient collect followed by a reducer.
| lesson | database design consequence |
|---|---|
| maintain source and aggregate together | one mutation/transaction or one idempotent write service owns both changes. |
| namespace by independent owner | separate high-write owners into scoped aggregate spaces when global cross-owner reads are not required. |
| bound reads by prefix/range | read the smallest aggregate key space; avoid global totals when one owner/range is the real question. |
| backfill after live write path is safe | first make new writes aggregate-aware, then rebuild historical data idempotently, then enable aggregate reads. |
| preserve canonical facts | an aggregate is rebuildable denormalization, never the sole evidence of a business event. |
tradeoff: a single global counter or ever-increasing sort key can become a contention/hotspot boundary under frequent writes. scope the aggregate to the shortest exact owner, or choose a deliberate sharding/projector strategy, before optimizing read latency.
verification
- create enough history that a full scan visibly differs from one keyed aggregate or indexed page.
- inspect the query plan: the request must be bounded by key/index/page.
- repeat the same canonical write/delivery: the aggregate must not double-apply.
- rebuild the aggregate from canonical facts and compare result/version.
- force a lag/failure: the interface reports derived freshness honestly, never presents the projection as canonical fact.
good and bad
bad
// route handler for workflow summaryasync function getWorkflowSummary(workflowId: string) {const workflowRuns = await db.select().from(workflowRun).where(eq(workflowRun.workflow_id, workflowId));const completedCount = workflowRuns.filter((run) => {return run.status === WorkflowRunStatusEnum.Completed;}).length;return { completedCount };}
loads every historical row — potentially thousands — into application memory, then filters synchronously for one count. three cascading failures under scale: (1) unbounded rows drain connection pool and memory, (2) response time grows linearly with history depth, (3) repeated calls for the same workflow repeat the full scan and hotspot under read concurrency. the interface delegates all cost and latency to callers; no cache, no boundary, no owner.
good
// schema: aggregate row maintains count with canonical writescreate table workflow_summary (workflow_id text primary key references workflow(id),completed_run_count integer not null default 0,updated_at text not null,unique(workflow_id));// service owns the aggregate update with the writeasync function completeWorkflowRun(runId: string) {await db.transaction(async (tx) => {await tx.update(workflowRun).set({ status: WorkflowRunStatusEnum.Completed }).where(eq(workflowRun.id, runId));await tx.update(workflowSummary).set({ completed_run_count: sql`completed_run_count + 1` }).where(eq(workflowSummary.workflow_id, workflowId));});}// route handler now reads bounded aggregateasync function getWorkflowSummary(workflowId: string) {const summary = await db.select().from(workflowSummary).where(eq(workflowSummary.workflow_id, workflowId));return { completedCount: summary.completed_run_count };}
the owner-scoped aggregate is updated atomically with the canonical write — constant response time regardless of history depth. if stale, an owned rebuild job recomputes it idempotently from facts. reads never scan history; routes never own the cost tradeoff.