Skip to content

Features and composition

A Kumiko feature is the smallest unit of business behaviour the framework knows about. Everything else — entities, write handlers, query handlers, projections, scheduled jobs, navigation entries, translations, screens, configuration keys — is something a feature declares about itself.

The framework itself has no business semantics. Auth, multi-tenancy, audit, search, jobs, file storage — all of those are implemented as features that ship with Kumiko, not as hidden code in the engine. If something looks like it lives “in the core”, it is either a shared infrastructure primitive (the dispatcher, the lifecycle pipeline, the database context) or a core feature that other features can requires(). There is no third option.

This is the single design choice that pulls the rest of Kumiko’s shape after it.

A feature is a single function passed to defineFeature() that receives a registrar r:

import { defineFeature } from "@cosmicdrift/kumiko-framework";
export default defineFeature("incidents", (r) => {
r.requires("identity", "audit");
r.entity("incident", { fields: { /* … */ } });
r.writeHandler(resolveIncident);
r.queryHandler(listOpenIncidents);
r.defineEvent("incident.resolved", schema);
r.hook("postSave", "incident", indexInSearch);
r.translations({ en: { /* … */ } });
r.nav({ id: "incidents", screen: "incident-list" });
r.screen(incidentListScreen);
});

The body of the function is read once at boot. The registrar collects every declaration into a registry, the registry is validated, and only then does the framework start serving requests. There is no runtime mutation of feature state — feature definitions are values, not running objects.

What a feature can declare falls into a small fixed set:

DeclarationWhat it adds
r.entityA typed read model with derived DB table, validation schema, list/edit screens.
r.writeHandler / r.queryHandlerA typed command or query at the dispatcher.
r.defineEventA typed event with schema and version, emittable via ctx.appendEvent or ctx.emit.
r.hookA function that runs at a specific lifecycle phase of a target entity.
r.relationA typed link between two entities (one-to-many, many-to-many).
r.screen, r.navUI surfaces — list/edit screens, sidebar entries.
r.translationsi18n bundle for this feature’s keys.
r.config, r.secretTenant- or system-scoped settings (provided by the config and secrets core features).
r.job, r.scheduledJobBackground work (provided by the jobs core feature).

Everything else — including custom fields, tagging, comments, soft delete, audit trails — arrives through registrar extensions (see below). The list above is fixed; the list of extensions is not.

A traditional module system says: this folder is the boundary, code inside it can do what it wants, code outside it imports from a public API. Kumiko’s feature system says something stricter: a feature has no public API of its own — only the contracts the framework defines on its behalf.

That sounds restrictive. It buys three things:

  1. Pluggability. A feature can be removed from the feature list and the rest of the app keeps booting. There are no module-level imports between features, so nothing else is statically linked to it.
  2. Replaceability. Two features can implement the same contract (an Identity feature using JWT, another using OAuth). The choice happens at the boot site, not throughout the codebase.
  3. Refactor cost stays local. Renaming an internal helper inside a feature cannot break another feature, because no other feature could ever have imported it.

The cost is real: cross-feature work has to go through one of the six communication channels (next section). For one-off code that will never be reused, this feels heavy. For an app that has to live for years and accept new feature combinations, the restriction is what makes change tractable.

Two features that need to interact must use one of these channels. Direct imports of another feature’s tables, helpers, types, or constants are forbidden — the lint rule catches them, the boot validator catches what the lint rule misses.

ChannelSync?ShapeWhen to reach for it
ctx.query / ctx.writeSync, in-transaction1:1 request/reply”I need the result before I can continue”
Events (ctx.emit, r.onEvent)Async, after commit1:N broadcast”I want to inform the world; I don’t care who listens”
Lifecycle hooks (r.hook)Sync at save time1:1, attached to a target entity”Whenever B saves, I need to run X”
Registrar extensionsResolved at boot1:N, declarative”I want to add a cross-cutting capability to many entities”
Configuration keysSync read/write1:N readers”Tenants should tweak this without code changes”
Framework accessors (ctx.db, ctx.secrets, ctx.tz, …)Syncn/aThis is infrastructure, not cross-feature traffic

Each channel has a separate transactional contract. A ctx.write that fails will roll back its caller. An emitted event that lands on the outbox cannot. Picking the right channel is mostly a question of which transactional behaviour you need, not which one is “nicest”.

Composition: requires, optional, extensions

Section titled “Composition: requires, optional, extensions”

Features compose along three axes.

Hard dependencies. r.requires("audit") says: my feature does not boot without the audit feature. The dependency is checked at boot, not at runtime; the boot validator fails loudly if the target is missing. Hard dependencies are the right tool when removing the target would silently break correctness — for example, when you add hooks to its entities.

Optional dependencies. r.optionalRequires("search") says: I integrate with search if it is loaded; otherwise I work without it. The feature body uses if (registry.has("search")) … to fork. Optional dependencies are how you keep a feature removable while still benefitting from neighbours that happen to be present.

Registrar extensions. A feature can offer a new registrar method to others by calling r.extendsRegistrar("customFields", { onRegister, extendSchema, hooks, extendSearch, uiExtension }). Other features then say r.customFields("incident"), and Kumiko wires the extension’s schema, hooks, search behaviour, and UI fragments into the target entity at boot. This is how cross-cutting concerns (custom fields, tags, comments, audit) reach many entities without each one having to opt into them in code.

Registrar extensions resolve at boot, not at runtime. Once the feature graph is loaded, the set of registrar methods is fixed. This is deliberate: a stable registrar surface makes the AST of feature definitions analysable, which is what enables the schema-driven designer and the AI feature builder.

When you add behaviour to a Kumiko app, it goes in one of three places. The choice is not a matter of taste:

  • Framework infrastructure (packages/framework/src/engine/, pipeline/, db/) if it cannot be expressed through defineFeature — for example the dispatcher, the lifecycle pipeline, the tenant DB context. These run for every request regardless of which features are loaded. Adding new framework infrastructure is rare; the bar is high.
  • Core feature (packages/framework/src/features/) if it can be expressed through defineFeature and most apps will need it: identity, audit, files, jobs, secrets, config. Core features look exactly like app features. Other features can requires them. They remain removable for apps that genuinely don’t need them.
  • App feature (your codebase, or packages/bundled-features/) for everything else — the actual business logic that makes your app what it is.

The fault line between framework infra and core feature is the most common place to get this wrong. The default answer is “core feature”: framework infrastructure is the last resort, not the first. If something feels like it has to live in the engine, it usually means an extension point is missing.

The constraint that everything is a feature sounds like a slogan. In practice it produces a concrete property: the same surface that the framework reads to validate the app is the surface a designer or an AI builder reads to edit it. A feature definition is data — typed, AST-walkable, serialisable for review. It is not a class hierarchy with hidden state, and it is not a graph of imports that has to be traced.

Every other concept in this section follows from that.

A minimal feature wiring one entity end-to-end with the framework’s default CRUD helpers — no hand-written Zod schemas, no per-verb plumbing:

export const taskFeature = defineFeature("tasks", (r) => {
r.entity("task", taskEntity);
// Writes append CRUD-style events onto the task stream and update the
// projection row in the same TX (the executor inside the helper takes care
// of both). Custom logic? Replace any single line with an explicit
// r.writeHandler.
r.writeHandler(defineEntityCreateHandler("task", taskEntity, editorWrite));
r.writeHandler(defineEntityUpdateHandler("task", taskEntity, editorWrite));
r.writeHandler(defineEntityDeleteHandler("task", taskEntity, adminWrite));
r.writeHandler(defineEntityRestoreHandler("task", taskEntity, adminWrite));
// Reads served from the projection table.
r.queryHandler(defineEntityListHandler("task", taskEntity, openRead));
r.queryHandler(defineEntityDetailHandler("task", taskEntity, openRead));
});

Full source: samples/recipes/basic-entity.