Queries compose before they are placed. Build small synchronous reads that name domain facts, assemble them into the shape a consumer needs, and let Motive carry their combined requirements to the perspective that selects the result.
Compose a view from smaller truths.
A useful read model can be assembled from smaller domain queries. Composition changes the shape of the answer while preserving every requirement needed to produce it.
1 · Keep one calculation
One query owns every detail.
A single Query.gen can read the complete active path and return exactly the view the caller needs. Its contract is sound, but none of the smaller facts can be named or reused on their own.
import { Query, State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";
const FinishPacking = Schema.TaggedStruct("FinishPacking", {});
const PackingListInput = Schema.Struct({
orderId: Schema.String,
warehouse: Schema.String,
initialItems: Schema.Array(Schema.String),
});
const packingViewQuery = Query.gen(function* () {
const { orderId } = yield* PackingList;
const { warehouse } = yield* Packing;
const { items } = yield* Collecting;
return `${orderId} · ${warehouse} · ${String(items.length)} items · packing`;
});
export class PackingList extends State.Compound<PackingList>()("PackingList", {
orderId: Schema.String,
}) {
static states = States.make(() => [Packing]);
}
export class Packing extends State.Compound<Packing>()("Packing", {
warehouse: Schema.String,
}) {
static states = States.make(() => [Collecting, Ready]);
}
export class Collecting extends State.Atomic<Collecting>()("Collecting", {
items: Schema.Array(Schema.String),
}) {
static transitions = Transitions.make(this, ({ on }) => [
on(FinishPacking, Ready),
]);
}
class Ready extends State.Atomic<Ready>()("Ready") {}
export const PackingListChart = Statechart.make(PackingList, {
input: PackingListInput,
init: ({ input }) => [
new PackingList({ orderId: input.orderId }),
new Packing({ warehouse: input.warehouse }),
new Collecting({ items: input.initialItems }),
],
});
export const packingView = PackingListChart.at(Collecting).select(packingViewQuery);
2 · Compose the view
Small queries assemble into one contract.
Query.map names each projection, Query.of contributes a constant, and Query.all assembles the record. The composed query returns the same view and retains the union of every strict read.
import { Query, State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";
const FinishPacking = Schema.TaggedStruct("FinishPacking", {});
const PackingListInput = Schema.Struct({
orderId: Schema.String,
warehouse: Schema.String,
initialItems: Schema.Array(Schema.String),
});
const phase = Query.of("packing");
export class PackingList extends State.Compound<PackingList>()("PackingList", {
orderId: Schema.String,
}) {
static states = States.make(() => [Packing]);
}
const orderId = Query.map(PackingList, ({ orderId }) => orderId);
export class Packing extends State.Compound<Packing>()("Packing", {
warehouse: Schema.String,
}) {
static states = States.make(() => [Collecting, Ready]);
}
const warehouse = Query.map(Packing, ({ warehouse }) => warehouse);
export class Collecting extends State.Atomic<Collecting>()("Collecting", {
items: Schema.Array(Schema.String),
}) {
static transitions = Transitions.make(this, ({ on }) => [
on(FinishPacking, Ready),
]);
}
const itemCount = Query.map(Collecting, ({ items }) => items.length);
const packingViewQuery = Query.map(
Query.all({ phase, orderId, warehouse, itemCount }),
({ phase, orderId, warehouse, itemCount }) =>
`${orderId} · ${warehouse} · ${String(itemCount)} items · ${phase}`,
);
class Ready extends State.Atomic<Ready>()("Ready") {}
export const PackingListChart = Statechart.make(PackingList, {
input: PackingListInput,
init: ({ input }) => [
new PackingList({ orderId: input.orderId }),
new Packing({ warehouse: input.warehouse }),
new Collecting({ items: input.initialItems }),
],
});
export const packingView = PackingListChart.at(Collecting).select(packingViewQuery);
One large query can still be correct
The first frame reads the entire active Packing List path in one Query.gen. It returns a useful
operational view and precisely requires PackingList + Packing + Collecting.
There is no semantic defect in that query. The pressure to compose appears when orderId,
warehouse, or itemCount is also useful in another answer. Copying those reads into every view
would make each consumer redefine the same domain facts.
Compose values, not state access
The second frame names one query for each fact. Query.map projects a state facet into the value
the rest of the application cares about. Downstream code composes those values without receiving a
state accessor or learning how the facet is stored.
phase comes from Query.of("packing"). A constant query has no state or event requirements, so
it can participate in the same composition vocabulary without pretending that the constant lives
in chart data.
Query.all names the product
Query.all accepts a readonly tuple or record of query inputs. The record form used here preserves
the field names, so the resulting query reads as a small synchronous view model:
Query.all({ phase, orderId, warehouse, itemCount });
The final Query.map formats that record. Mapping changes the answer, not the requirements. The
composed query therefore produces the same text as the original generator and becomes absent at
the same Collecting perspective boundary.
Requirements accumulate through composition
Composition never hides a read. orderId requires PackingList, warehouse requires Packing,
and itemCount requires Collecting; phase requires nothing. Query.all carries the union
PackingList + Packing + Collecting into the final query.
That union is checked when the query is placed. Moving this selection to Packing would still be
rejected because composing itemCount did not weaken its strict Collecting requirement.
The same law applies to optional and event requirements: the result carries every capability used by any constituent query.
Choose the shape that matches the calculation
Query.of(value)contributes a constant with no requirements.Query.map(query, f)transforms one answer without changing its requirements.Query.zip(left, right)retains both answers as a readonly pair.Query.zipWith(left, right, f)combines two answers directly.Query.all(inputs)assembles a tuple or named record from many inputs.Query.flatMap(query, f)chooses a following query from the first answer and carries the union of both stages' requirements.
flatMap is sequencing, not a permission escape. Even when the first answer determines which
query runs next, the composed type must describe the requirements of the whole calculation so its
placement is valid for every path.
Use Query.gen when a calculation reads most clearly as a sequence. Use the combinators when
named intermediate queries are useful independently or when the product shape itself explains the
domain.
Test the composition
- Add an
itemLabelquery that mapsitemCountto"2 items", then use it in the record. - Replace the record with a tuple and observe how
Query.allpreserves positional output. - Move the selection to
Packingand verify that the composed strictCollectingread is rejected.