Write a custom write handler
The default defineEntityCreateHandler writes the row and appends a
created event. When you need derived fields, validation across records, or
side effects to other entities, write your own defineWriteHandler instead.
Prerequisites
Section titled “Prerequisites”- You’ve read Commands and queries so the pipeline shape is clear.
- A feature where the default CRUD handler currently runs.
- An entity definition and its executor. The custom handler below reads and
updates the entity through
createEntityExecutor; it does not write the projection table directly.
The code
Section titled “The code”Drop the inline defineEntityCreateHandler and replace it with a typed
handler:
import { createEntityExecutor, defineWriteHandler,} from "@cosmicdrift/kumiko-framework/engine";import { z } from "zod";import { failNotFound, UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";import { incidentEntity } from "../schema/incident";
const { executor: incidentExecutor } = createEntityExecutor("incident", incidentEntity);
export const resolveIncident = defineWriteHandler({ name: "resolve", schema: z.object({ id: z.uuid(), resolution: z.string().min(10), }), access: { roles: ["Admin", "OnCall"] }, handler: async (event, ctx) => { const incident = await incidentExecutor.detail({ id: event.payload.id }, event.user, ctx.db); if (!incident) { return failNotFound("incident", event.payload.id); } if (incident["status"] === "resolved") { return writeFailure( new UnprocessableError("incident_already_resolved", { i18nKey: "incidents.errors.alreadyResolved", }), ); }
return incidentExecutor.update( { id: event.payload.id, version: incident["version"] as number, changes: { status: "resolved", resolution: event.payload.resolution, }, }, event.user, ctx.db, ); },});Register it in the feature body:
import { resolveIncident } from "./handlers/resolve.write";
export const incidentsFeature = defineFeature("incidents", (r) => { // … entity + default handlers … r.writeHandler(resolveIncident);});What the framework does around your handler
Section titled “What the framework does around your handler”You wrote the business logic. Everything else runs around it:
- Zod validates the payload before the handler body. The body sees
a typed
event.payload. - The role check runs before the handler body. A user without
AdminorOnCallnever reaches the function. - The whole call runs in a database transaction. If your handler throws, the
entire write rolls back, including any
ctx.events.appendcall you made earlier. - After the handler returns, postSave hooks fire (audit, search index, SSE broadcast), then the response goes out.
You don’t write a try/catch. The dispatcher catches KumikoError
subclasses (NotFoundError, UnprocessableError, …) and maps them to
the right HTTP status with a structured response. Anything else gets
wrapped as InternalError with the stack in the log and a sanitised
500 in the response.
Common gotchas
Section titled “Common gotchas”- Don’t import another feature’s table. A handler in
incidentsthat needs customer data callsctx.dispatch.query("customers:detail", { id }), notctx.db.select().from(customerTable). - Handler names are qualified by the dispatcher. A positional handler
named
"counter:increment"in thecountersfeature becomescounters:write:counter:increment. Use the same short-name convention as the surrounding feature. - Use the executor for entity writes. It performs optimistic locking,
field ownership checks, event creation, and projection updates. Use
ctx.events.appendfor a separate domain event declared withr.defineEvent.
Live example
Section titled “Live example”Increment with optimistic locking, the canonical
read-modify-write shape, with failNotFound for the 404 case:
r.writeHandler({ name: "counter:increment", schema: z.object({ id: z.uuid(), amount: z.number().min(1).max(100) }), handler: async (event, ctx) => { const current = await counterExecutor.detail({ id: event.payload.id }, event.user, ctx.db); if (!current) { return failNotFound("counter", event.payload.id); }
// detail() returns the entity's own row type; count is nullable because the field is not required. const { count, version } = current; const newCount = (count ?? 0) + 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, changes: { count: newCount, lastIncrementedBy: `user:${event.user.id}` }, }, event.user, ctx.db, ); },Full source: samples/recipes/custom-handlers.
See also
Section titled “See also”- Commands and queries, the full pipeline.
- Events and projections, what
ctx.events.appenddoes and how projections see it.