A read model is a table built from events, under app/read/<read-model>/. It is the read side of
the app: commands never read it, and queries never touch the event store.
The view
Section titled “The view”view.ts declares the table; projections fill it; queries read it.
import type { View } from "./+types/view";
export const fields = ({ f }: View.FieldsArgs) => ({ orderId: f.string().primaryKey(), customerId: f.string().index(), status: f.string(), total: f.number(), paidAt: f.date().optional(),});Projections
Section titled “Projections”A projection projections/<aggregate>/<event>.ts reacts to that aggregate’s event, or exports
on for several of that aggregate’s events:
import type { Projection } from "./+types/order-placed";
export const project = async ({ event, table }: Projection.Args) => { await table.upsert({ orderId: event.aggregateId, customerId: event.payload.customerId, status: "placed", total: event.payload.total, });};Queries
Section titled “Queries”A query queries/<name>.ts has an optional payload, an optional repository that reads
through table or client, and a handler that shapes the result and may call other queries:
import type { Query } from "./+types/get-order";
export const payload = ({ z }: Query.PayloadArgs) => z.object({ orderId: z.uuid() });
export const repository = ({ table, orderId }: Query.RepositoryArgs) => table.findOne({ orderId });
export const handler = ({ repositoryData }: Query.HandlerArgs) => repositoryData;Payload fields with a .default() are optional for whoever calls the command or query and
always present in the handler: callers see the schema’s input type, handlers its output type.
The root of a read model is read like an aggregate’s, without events: view.ts, the ports, and
any other module or directory the projections and queries share, which the generator leaves
alone. A projection or a query put in the wrong place gets a
warning.
A read model’s port is declared and implemented as an aggregate’s
(ports), and only the handler of its queries receives it:
a query that completes its rows with something from outside, an exchange rate or a profile from the
identity provider.
import type { Query } from "./+types/get-order-in-usd";
export const repository = ({ table, orderId }: Query.RepositoryArgs) => table.findOne({ orderId });
export const handler = async ({ repositoryData, rates }: Query.HandlerArgs) => repositoryData && { ...repositoryData, totalUsd: repositoryData.total * (await rates({ from: "EUR", to: "USD" })), };A query only reads, so it needs no idempotencyKey, and a port that fails fails the query. The
config chooses its implementation in the same ports section, ports: { orderSummary: { rates: "ecb" } }, and a read model’s port cannot take one of the
reserved names.
repository reads the storage and gets no ports, and neither do the projections: a projection
commits exactly once per batch with its checkpoint, replays its whole history on a rebuild and
runs in the command’s request under read-your-writes, and a call to the outside fits none of
those. Your projection should not call anyone has the
reasons in full.
Data from outside belongs in the event, fetched when the command or the policy decides, or in the query, fetched when it is read. An index kept in another store, such as Typesense or Elasticsearch, is fed by a policy instead: see keeping an external index.