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.
What a feature declares
Section titled “What a feature declares”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:
| Declaration | What it adds |
|---|---|
r.entity | A typed read model with derived DB table, validation schema, list/edit screens. |
r.writeHandler / r.queryHandler | A typed command or query at the dispatcher. |
r.defineEvent | A typed event with schema and version, emittable via ctx.appendEvent or ctx.emit. |
r.hook | A function that runs at a specific lifecycle phase of a target entity. |
r.relation | A typed link between two entities (one-to-many, many-to-many). |
r.screen, r.nav | UI surfaces — list/edit screens, sidebar entries. |
r.translations | i18n bundle for this feature’s keys. |
r.config, r.secret | Tenant- or system-scoped settings (provided by the config and secrets core features). |
r.job, r.scheduledJob | Background 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.
Why features instead of modules
Section titled “Why features instead of modules”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:
- 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.
- Replaceability. Two features can implement the same contract (an
Identityfeature using JWT, another using OAuth). The choice happens at the boot site, not throughout the codebase. - 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.
The six ways features talk to each other
Section titled “The six ways features talk to each other”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.
| Channel | Sync? | Shape | When to reach for it |
|---|---|---|---|
ctx.query / ctx.write | Sync, in-transaction | 1:1 request/reply | ”I need the result before I can continue” |
Events (ctx.emit, r.onEvent) | Async, after commit | 1:N broadcast | ”I want to inform the world; I don’t care who listens” |
Lifecycle hooks (r.hook) | Sync at save time | 1:1, attached to a target entity | ”Whenever B saves, I need to run X” |
| Registrar extensions | Resolved at boot | 1:N, declarative | ”I want to add a cross-cutting capability to many entities” |
| Configuration keys | Sync read/write | 1:N readers | ”Tenants should tweak this without code changes” |
Framework accessors (ctx.db, ctx.secrets, ctx.tz, …) | Sync | n/a | This 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.
Three places code can live
Section titled “Three places code can live”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 throughdefineFeature— 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 throughdefineFeatureand most apps will need it: identity, audit, files, jobs, secrets, config. Core features look exactly like app features. Other features canrequiresthem. 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.
What this gets you
Section titled “What this gets you”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.
Live example
Section titled “Live example”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.
See also
Section titled “See also”- Schemas as data — how entity declarations stay inspectable.
- Multi-tenancy — how features inherit tenant isolation without per-feature wiring.
- Lifecycle and hooks — how the feature graph is validated at boot.