Write a custom write handler
The default defineEntityCreateHandler writes the row and appends a
created event. When you need more — derived fields, validation across
records, side effects to other entities — you 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.
The code
Section titled “The code”Drop the inline defineEntityCreateHandler and replace it with a typed
handler:
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";import { z } from "zod";import { NotFoundError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
export const resolveIncident = defineWriteHandler({ name: "resolve", schema: z.object({ id: z.string(), resolution: z.string().min(10), }), access: { roles: ["Admin", "OnCall"] }, handler: async (event, ctx) => { const incident = await ctx.db.read("incident", event.payload.id); if (!incident) { throw new NotFoundError("incident", event.payload.id); } if (incident.status === "resolved") { throw new UnprocessableError("incident.already_resolved", { i18nKey: "incidents.errors.alreadyResolved", }); }
await ctx.db.update("incident", event.payload.id, { status: "resolved", resolvedAt: ctx.now(), resolution: event.payload.resolution, });
await ctx.appendEvent(incidentResolved, event.payload.id, { resolution: event.payload.resolution, resolvedAt: ctx.now(), }); },});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
appendEventcall 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.query("customers:detail", { id }), notctx.db.select().from(customerTable). - The
nameis short. InsidedefineFeature("incidents", …), a handler named"resolve"becomesincidents:resolve. Don’t write the prefix yourself — it gets added. - Events you append are typed. Use a
r.defineEvent(name, schema)reference, not a string literal — the boot validator rejects events the feature did not declare.
Live example
Section titled “Live example”Increment with optimistic locking — the canonical
read-modify-write shape, with failNotFound for the 404 case:
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”- Commands and queries — the full pipeline.
- Events and projections — what
appendEventdoes and how projections see it.