Skip to content

Features and composition

In Kumiko, a feature is the unit that declares one area of application behaviour. It can register entities, handlers, events, hooks, jobs, screens, translations, and configuration through the registrar it receives at boot.

Features do not import each other’s private code or data. They communicate through framework contracts such as queries and writes, events, hooks, registrar extensions, and shared accessors. This makes features replaceable, but adds ceremony when a small piece of code needs to cross a feature boundary.

[Login block] [Cart block] [Notify block]
| | |
+----- only through shared, standard doors -----+
(no reaching into each other's insides)

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. Shared infrastructure primitives include the dispatcher, lifecycle pipeline, and tenant database context. Reusable application capabilities live in bundled features that other features can require.

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/engine";
export default defineFeature("incidents", (r) => {
r.requires("user", "audit");
const incident = r.entity("incident", incidentEntity);
r.writeHandler(resolveIncident);
r.queryHandler(listOpenIncidents);
r.defineEvent("incident-resolved", schema, { piiFields: "none" });
r.hook("postSave", { allOf: incident }, indexInSearch);
r.translations({ keys: { /* … */ } });
r.nav({
id: "incidents",
label: "incidents.nav.list",
screen: "incidents:screen:incident-list",
});
r.screen(incidentListScreen);
});

resolveIncident and listOpenIncidents carry their own input schemas, access rules, and handler functions.

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 and validation schema.
r.writeHandler / r.queryHandlerA typed command or query at the dispatcher.
r.defineEventA typed event with schema and version, appendable via ctx.events.append.
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 bundles.
r.jobBackground work executed by the jobs runtime; triggers live in the job options.

Everything else, including custom fields, tagging, comments, and 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 when no remaining feature requires it and the app still boots. The boot validator reports the dependency closure if something still points at it. There are no module-level imports between features, so nothing else is statically linked to its private implementation.
  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), or through a deliberately public package export when the feature offers one. 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.dispatch.query / ctx.dispatch.writeSync, in-transaction1:1 request/reply“I need the result before I can continue”
Events (ctx.events.append + projections / jobs)Async, after commit1:N fan-out“I want to inform the world; I don’t care who listens”
Lifecycle hooks (r.hook)In transaction or after commit1: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.services.secrets, ctx.tz, …)Syncn/aThis is infrastructure, not cross-feature traffic

Each channel has a separate transactional contract. A ctx.dispatch.write that fails will roll back its caller. An event appended with ctx.events.append that fans out asynchronously 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") records a dependency that may be absent. Use it when another declared integration or extension is active only when its provider is mounted. It does not add a runtime registry.has(...) branch to the defineFeature callback; composition remains fixed after boot.

Registrar extensions. Bundled concerns expose named extension points. Other features attach with the constants and wiring helpers documented by that bundle. The extension can contribute hooks, search behavior, or UI metadata at boot. This is how concerns such as custom fields and tags reach many entities without each one reimplementing them.

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.
  • Bundled feature (packages/bundled-features/src/) if it can be expressed through defineFeature and many apps need it: identity, audit, files, jobs, secrets, config. Bundled features look exactly like app features. Other features can require them, and an app can omit features it does not need.
  • App feature (your codebase) for everything else: the actual business logic that makes your app what it is.

The fault line between framework infrastructure and bundled features is the most common place to get this wrong. The default answer is “bundled 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.crud("task", taskEntity, {
write: editorWrite,
read: openRead,
verbs: { delete: false, restore: false },
});
r.writeHandler(defineEntityDeleteHandler("task", taskEntity, adminWrite));
r.writeHandler(defineEntityRestoreHandler("task", taskEntity, adminWrite));
});

Full source: samples/recipes/basic-entity.