Skip to content

Commands and queries

Kumiko separates requests that change state from requests that only read it. A write handler validates input, checks access, runs in a transaction, and emits the relevant events and hooks. A query handler returns data without changing state or creating write-side effects.

Both handlers use the same dispatcher and access model. Retries are not automatically deduplicated: a client must provide an idempotency signal when a repeated write must be recognised as the same operation.

Kumiko keeps writes and reads on different rails. A write handler changes state; a query handler returns a view of state. They share the same dispatcher, the same registry, the same access model, but they are separately registered, separately named, separately tested, and they go through subtly different pipelines. This is CQRS-light: not full event-sourcing-only writes plus denormalised reads, but enough separation that the two operations don’t contaminate each other.

You write a write handler with r.writeHandler(...) and a query handler with r.queryHandler(...). The body looks similar, schema, access, handler, but what the framework does around it is different.

export const resolveIncident = defineWriteHandler({
name: "resolve",
schema: z.object({ id: z.string(), resolution: z.string() }),
access: { roles: ["Admin", "OnCall"] },
handler: async (event, ctx) => {
// mutate state here
},
});
export const listOpenIncidents = defineQueryHandler({
name: "list-open",
schema: z.object({ severity: z.enum(["low", "medium", "high"]).optional() }),
access: { roles: ["Admin", "User"] },
handler: async (query, ctx) => {
// return data here
},
});

Both handler kinds go through a fixed pipeline. The handler body is the business logic; everything else is the framework’s responsibility.

HTTP request or a cross-feature call JWT auth ctx.user is set Dispatcher resolves the qualified name Zod validation payload is now typed Access check roles, anonymous, openToAll Validation hooks write only, before any DB write Handler body your code Lifecycle pipeline write only, in transaction Search index priority 1000 SSE broadcast priority 1001 Audit trail priority 1002 Response field-level read filter

Two things are worth pointing at:

  • The handler never validates its own input. Zod runs first; the body sees a typed payload or never runs.
  • The handler never enforces access. The pipeline does. A write handler that forgets to check authorisation is not a security bug, there is no place in it where the check would have been.

The pipeline is the same for HTTP requests and for cross-feature calls. A write triggered from another feature’s handler runs the same access check, the same validation, the same hooks. There is no privileged shortcut just because the call originated inside the process.

Zod validation uses its default behaviour: keys in the payload that are not declared on the handler’s schema are silently dropped before the handler body runs. Sending extra JSON, a typo, a leftover field from a different endpoint, a probe, does not fail the request and does not reach your code. Only type mismatches on declared fields produce a validation error.

System-managed columns, id, tenantId, version, insertedAt, modifiedAt, are not a case of stripping. They are never part of the generated input schema in the first place: the schema is built only from the fields you declared on the entity. id is generated server-side, tenantId comes from the caller’s session (JWT or PAT), never from the payload. There is no field name a client can send to overwrite either one.

This holds identically for a JWT session and for a personal access token, the pipeline above runs the same way regardless of how the caller authenticated.

Three things differ between a write and a read, and they line up:

  1. Transactional contract. A write opens a database transaction; a read does not. Write handlers can throw and roll back the whole change; query handlers cannot meaningfully roll back a list.
  2. Lifecycle hooks. A write fires preSave, postSave, audit, search indexing, SSE broadcast. A query fires none of those, there is nothing to broadcast.
  3. Side-effect surface. Events get appended on writes. Read models get updated on writes. A query that wrote to a side channel would be a code smell large enough to grep for.

Mixing these into “operations” that may or may not write would mean every caller has to know the answer. Splitting them at the registrar level means the framework knows.

The handler context exposes transaction intent

Section titled “The handler context exposes transaction intent”

ctx.db is the tenant-scoped handle for the current write transaction. A handler failure rolls back writes made through it, including nested cross-feature writes.

When an external side effect has already happened and its record must survive that rollback, use the separate tenant-scoped ctx.dbOutsideTransaction handle. It uses the unbound pool; it is for recording the fact that happened, not for ordinary business writes or for pretending an external API call is atomic with the handler.

Shared library code should bind observability to its own feature rather than to whichever handler called it:

ctx.metricsFor("ai-foundation").inc("provider_call_total");

metricsFor resolves one stable library-owned metric name for every consumer. An unregistered target is a no-op so an error-path counter cannot turn a handled failure into a new exception; invalid names and metric-type errors remain visible.

Access on Kumiko is two-layered: handler-level and field-level. Handler-level access is the gate (“who can call incidents:resolve?”); field-level access filters individual fields on the way in (write) and on the way out (read).

A phoneNumber field that admins can read and everyone can write, for self-service settings, is a single declaration on the field:

createTextField({
access: {
read: { roles: ["Admin", "Sysadmin"] },
write: { roles: ["Admin", "User"] },
},
});

A user listing the field gets it stripped out unless they have the read role. A user writing to it succeeds regardless. There is no if (user.role === "admin") anywhere in the handler, the framework reads the field’s access declaration and acts.

Cross-feature calls go through the same dispatcher

Section titled “Cross-feature calls go through the same dispatcher”

A handler in feature A that needs to read or change state in feature B does not import feature B. It calls ctx.query or ctx.write with B’s qualified handler name:

// inside an orders handler
const customer = await ctx.query("customers:detail", { id: payload.customerId });
await ctx.write("payments:capture", { orderId: payload.id, amount: payload.total });

Three things follow from this:

  • B’s access checks, validation, and hooks fire on the cross-feature call, exactly as they would for an HTTP call. A is not privileged just because it is in-process.
  • The cross-feature ctx.write runs in the same database transaction as the outer write. If A’s handler later throws, B’s write rolls back too. If B’s handler returns a failure, A’s write rolls back as well.
  • B’s table layout is irrelevant to A. A renames a column in B’s schema, A keeps working, because A never touched B’s columns.

When A genuinely needs to act with elevated privileges, for example, looking up a user’s password hash during login, it uses ctx.queryAs(systemUser, qn, …) or ctx.writeAs(systemUser, qn, …). The privilege escalation is visible in the call site, not implicit. A reviewer reading the handler can see that this query bypasses the current user’s read filter; a regular ctx.query cannot.

A qualified name is <feature>:<handler-name>. The convention exists because the dispatcher and the audit trail both need to find handlers by name, and a flat namespace would collide between features. There are no name collisions across features because the prefix is the feature identifier; there are no collisions inside a feature because the registrar rejects duplicates at boot.

A few stable shapes:

NameMeaning
incidents:resolveDomain command, verb on entity
incidents:list-openDomain query, descriptive read
incident:createCRUD command, generated by defineEntityWriteHandler
incident:listCRUD query, generated by defineEntityQueryHandler

You don’t have to write CRUD handlers by hand for entities that don’t need custom logic, defineEntityWriteHandler and defineEntityQueryHandler build them from the entity definition. The names are predictable, the access rules follow the entity’s defaults, and the audit trail records them under the generated qualified name.

A handler that needs to fail throws a typed Kumiko error: NotFoundError, UnprocessableError, ConflictError, AccessDeniedError, ValidationError, VersionConflictError. The dispatcher catches the throw, maps it to the right HTTP status, builds a structured response (with i18n key, trace ID, and optionally per-field details), and logs it. The handler never assembles a 4xx response itself.

if (!order) {
throw new NotFoundError("order", payload.id);
}
if (order.status === "cancelled") {
throw new UnprocessableError("order.already_cancelled", {
i18nKey: "orders.errors.alreadyCancelled",
});
}
// happy path, no try/catch, no error response building

Anything else thrown, a TypeError, a third-party library error, gets wrapped as InternalError, logged with the full stack and cause chain, and returned to the client as a sanitised 500 with a trace ID. Internal details never leak to the wire; operators can reconstruct the cause from the trace ID.

There is no try/catch around the happy path. The pipeline does it.

Idempotent retries are opt-in, and at-least-once

Section titled “Idempotent retries are opt-in, and at-least-once”

A network hiccup makes clients retry. Kumiko can collapse a retried write to a single execution, but only when the client tells it which requests are “the same”. That signal is a stable request id: either the X-Request-ID header or a requestId in the batch body.

POST /api/batch
X-Request-ID: 7c3e…stable-per-logical-operation

When two requests carry the same id, the dispatcher caches the first one’s result (success or failure) and a concurrent retry waits for that result instead of re-running the handler. If the client does not send a stable id, the framework mints a fresh one per request, so every retry looks like a new operation and the handler runs again. Idempotency is therefore the client’s contract to honour: reuse the id across retries of the same logical operation, generate a new one for genuinely new work.

The guarantee is at-least-once, not exactly-once. The result is cached after the database transaction commits. If the process crashes in the narrow window between commit and cache-write, the in-progress lock expires and a later retry re-executes the whole batch. The event log has no per-request dedup index (a single request may legitimately append many events), so a re-execution can append duplicate domain events. Design domain writes to tolerate this: make them naturally idempotent (upsert-by-key, conditional state transitions) rather than assuming a request body runs exactly once.

The CQRS-light split, the pipeline shape, and the cross-feature bridge add up to one practical property: the handler body is just the business decision. Everything else, auth, validation, access, transactions, hooks, errors, audit, happens around it, declaratively. Reading a handler tells you what the business rule is, not how the framework moves bytes.

The cost is the indirection: you cannot just import { ordersTable } from another feature, you have to call its handler. For a one-off script that’s overhead. For an app that has to absorb new features and split codebases over years, the indirection is the point.

A custom write handler: read-modify-write with optimistic locking. The business rule (clamped increment 1–100) is the body; everything else runs around it:

r.writeHandler(
"counter:increment",
z.object({ id: z.uuid(), amount: z.number().min(1).max(100) }),
async (event, ctx) => {
const current = await counterExecutor.detail({ id: event.payload.id }, event.user, ctx.db);
if (!current) {
return failNotFound("counter", event.payload.id);
}
const newCount = (current["count"] as number) + event.payload.amount;
// Read-modify-write on counter: use the version we just read so two
// concurrent increments don't clobber each other's changes.
return counterExecutor.update(
{
id: event.payload.id,
version: current["version"] as number,
changes: { count: newCount, lastIncrementedBy: `user:${event.user.id}` },
},
event.user,
ctx.db,
);
},
{ access: { roles: ["Admin", "User"] } },
);

Full source: samples/recipes/custom-handlers.