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.fetchOne("incident", {
id: 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.updateMany(
"incident",
{
status: "resolved",
resolvedAt: new Date(),
resolution: event.payload.resolution,
},
{ id: event.payload.id },
);
await ctx.appendEvent({
aggregateId: event.payload.id,
aggregateType: "incident",
type: "incident:resolved",
payload: {
resolution: event.payload.resolution,
resolvedAt: new Date(),
},
});
},
});

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.

HTTP request or a cross-feature call JWT auth ctx.user is set 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 index priority 1000 SSE broadcast priority 1001 Audit trail priority 1002 Response field-level read filter
  • 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.