Skip to content

Feature-Dateistruktur

Problem

Alles inline in defineFeature() wird schnell unleserlich. Handler, Schemas, Access — alles in einer Datei, 500+ Zeilen.

Loesung: Ein Handler pro Datei

features/jobs/
jobs.feature.ts ← Schlank: Imports + Registrierung (Uebersicht)
handlers/
detail.query.ts ← Ein Handler, komplett self-contained
list.query.ts
start.write.ts
stop.write.ts
retry.write.ts
schemas/
job-run.ts ← Shared Zod Schemas (wiederverwendbar)
job-definition.ts
tables/
index.ts ← Drizzle Table Definitionen

Feature-Datei: Nur Registrierung

// jobs.feature.ts — 20 Zeilen statt 500
import { defineFeature } from "@kumiko/framework";
import { detailQuery } from "./handlers/detail.query";
import { listQuery } from "./handlers/list.query";
import { startWrite } from "./handlers/start.write";
import { stopWrite } from "./handlers/stop.write";
export default defineFeature("jobs", (r) => {
// Entities
r.entity("jobDefinition", { ... });
r.entity("jobRun", { ... });
// Queries
const queries = {
detail: r.queryHandler(detailQuery),
list: r.queryHandler(listQuery),
};
// Commands
const handlers = {
start: r.writeHandler(startWrite),
stop: r.writeHandler(stopWrite),
};
// Nav + Screens
r.nav({ ... });
r.screen({ ... });
return { queries, handlers };
});

Man sieht sofort: 2 Entities, 2 Queries, 2 Commands, Nav, Screen.

Handler-Datei: Self-Contained

Jeder Handler hat alles was er braucht — Schema, Access, Logic:

handlers/detail.query.ts
import { defineQueryHandler } from "@kumiko/framework";
import { z } from "zod";
import { jobRunsTable, jobRunLogsTable } from "../tables";
import { eq } from "drizzle-orm";
export const detailQuery = defineQueryHandler({
name: "detail", // Kurz — Feature-Prefix automatisch
schema: z.object({ runId: z.number() }),
access: { roles: [roles.SystemAdmin] }, // Typisierte Rolle
handler: async (query, ctx) => {
const [row] = await ctx.db // ctx.db statt ctx["db"]
.select()
.from(jobRunsTable)
.where(eq(jobRunsTable.id, query.payload.runId));
if (!row) return null;
const logs = await ctx.db
.select()
.from(jobRunLogsTable)
.where(eq(jobRunLogsTable.runId, query.payload.runId))
.orderBy(jobRunLogsTable.id);
return { ...row, logs };
},
});
handlers/start.write.ts
import { defineWriteHandler } from "@kumiko/framework";
import { z } from "zod";
export const startWrite = defineWriteHandler({
name: "start",
schema: z.object({
jobName: z.string(),
payload: z.record(z.unknown()).optional(),
}),
access: { roles: [roles.SystemAdmin] },
handler: async (event, ctx) => {
const jobDef = ctx.jobRegistry.get(event.payload.jobName);
if (!jobDef) return { isSuccess: false, error: "job_not_found" };
const runId = await ctx.jobRunner.enqueue(
event.payload.jobName,
event.payload.payload ?? {},
{ triggeredBy: event.user.id },
);
return { isSuccess: true, data: { runId } };
},
});

Helper: defineWriteHandler / defineQueryHandler

Identity Functions fuer TypeScript-Inferenz. Geben das Objekt typisiert zurueck:

// Framework exportiert:
export function defineWriteHandler<TSchema extends ZodType>(
def: WriteHandlerDefinition<TSchema>,
): WriteHandlerDefinition<TSchema> {
return def;
}
export function defineQueryHandler<TSchema extends ZodType>(
def: QueryHandlerDefinition<TSchema>,
): QueryHandlerDefinition<TSchema> {
return def;
}

Kein Magic — nur TypeScript Inference. Autocomplete fuer alle Felder.

Registrar akzeptiert Objekte

r.writeHandler() und r.queryHandler() akzeptieren beide Formen:

// Objekt (neu — aus Handler-Datei):
r.writeHandler(startWrite);
// Inline (alt — fuer kleine Handler die keine eigene Datei brauchen):
r.writeHandler("toggle", z.object({ id: z.number() }), handler, { access: ... });

Inline bleibt fuer triviale Handler (5 Zeilen). Eigene Datei fuer alles andere.

Schemas: Shared und wiederverwendbar

schemas/job-run.ts
import { z } from "zod";
export const jobRunSchema = z.object({
jobName: z.string(),
status: z.string(),
payload: z.string().optional(),
error: z.string().optional(),
});
export type JobRun = z.infer<typeof jobRunSchema>;

Schemas koennen von mehreren Handlern importiert werden. Und von Tests.

Zusammenspiel mit Type Safety

Greift perfekt ineinander:

AspektWie
Feature-Prefixname: "start" → Framework macht "jobs.start"
Rollenroles.SystemAdmin statt "SystemAdmin"
Contextctx.db statt ctx["db"] (typisiert)
Cross-Featureimport { handlers } from "@features/jobs"
Returnr.writeHandler(x) gibt typisierte Referenz zurueck

Vorteile

  • Feature-Datei ist eine Uebersicht (20 Zeilen)
  • Handler sind self-contained (Schema + Access + Logic)
  • Handler sind einzeln testbar (importieren, ausfuehren)
  • Handler sind einzeln reviewbar (ein File = ein Handler)
  • Schemas sind wiederverwendbar (Handler + Tests + andere Features)
  • Inline bleibt moeglich fuer triviale Handler