Skip to content

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.

  • You’ve read Commands and queries so the pipeline shape is clear.
  • A feature where the default CRUD handler currently runs.

Drop the inline defineEntityCreateHandler and replace it with a typed handler:

src/features/incidents/handlers/resolve.write.ts
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 Admin or OnCall never reaches the function.
  • The whole call runs in a database transaction. If your handler throws, the entire write rolls back — including any appendEvent call 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.

  • Don’t import another feature’s table. A handler in incidents that needs customer data calls ctx.query("customers:detail", { id }), not ctx.db.select().from(customerTable).
  • The name is short. Inside defineFeature("incidents", …), a handler named "resolve" becomes incidents: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.

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.