Guide

Composing Queries

Assemble named synchronous reads into a larger view without hiding their combined requirements.

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

  1. Add an itemLabel query that maps itemCount to "2 items", then use it in the record.
  2. Replace the record with a tuple and observe how Query.all preserves positional output.
  3. Move the selection to Packing and verify that the composed strict Collecting read is rejected.