Skip to content

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.

  • 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.

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

src/features/incidents/handlers/resolve.write.ts
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 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 ctx.events.append 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.

HTTP request or a cross-feature call Resolve caller JWT, PAT, or anonymous 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 consumer after commit SSE consumer after commit Event audit trail written with entity events Response field-level read filter
  • Don’t import another feature’s table. A handler in incidents that needs customer data calls ctx.dispatch.query("customers:detail", { id }), not ctx.db.select().from(customerTable).
  • Handler names are qualified by the dispatcher. A positional handler named "counter:increment" in the counters feature becomes counters: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.append for a separate domain event declared with r.defineEvent.

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.