Commands and queries
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 },});What runs around a call
Section titled “What runs around a call”Both handler kinds go through a fixed pipeline. The handler body is the business logic; everything else is the framework’s responsibility.
HTTP request ↓JWT auth (Hono middleware) → ctx.user is set ↓Dispatcher resolves the qualified name ↓Zod schema validation → payload is now typed ↓Access check → roles, anonymous, openToAll ↓Validation hooks (write only) → custom checks before any DB write ↓Handler body → your code ↓Lifecycle pipeline (write only): Feature postSave hooks (inTransaction) System hooks by priority: 1000 Search index 1001 SSE broadcast 1002 Audit trail ↓Response, with field-level read filterTwo 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.
Why writes and reads are split
Section titled “Why writes and reads are split”Three things differ between a write and a read, and they line up:
- 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.
- Lifecycle hooks. A write fires
preSave,postSave, audit, search indexing, SSE broadcast. A query fires none of those — there is nothing to broadcast. - 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.
Field-level access, both ways
Section titled “Field-level access, both ways”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 handlerconst 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.writeruns 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.
Handler names follow a convention
Section titled “Handler names follow a convention”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:
| Name | Meaning |
|---|---|
incidents:resolve | Domain command — verb on entity |
incidents:list-open | Domain query — descriptive read |
incident:create | CRUD command — generated by defineEntityWriteHandler |
incident:list | CRUD 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.
Errors come from a small fixed set
Section titled “Errors come from a small fixed set”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 buildingAnything 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.
What this gives you
Section titled “What this gives you”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.
Live example
Section titled “Live example”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.
See also
Section titled “See also”- Events and projections — what a write handler appends to the event log, and what hooks fire around it.
- Multi-tenancy — how
ctxarrives at the handler tenant-scoped. - Auth and permissions — the access checks that run before the handler body.