Skip to content

Bundled features vs. custom

Kumiko ships a set of bundled features — pre-built feature definitions that solve the problems most apps need solved before they get to the business-specific work. Identity, sessions, audit trails, secrets, file storage, notifications, jobs, rate limiting, feature toggles. They live in packages/bundled-features/src/<name>/ and you bring them into your app like any other feature.

import { createAuditFeature } from "@cosmicdrift/kumiko-bundled-features/audit";
import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
await runDevApp({
features: [
createAuditFeature(),
createSecretsFeature(),
myAppFeature,
],
auth: { /* enables config + user + tenant + auth-email-password */ },
});

A bundled feature is not a magic library. It is a feature definition, the same shape your own features have. It declares entities, registers handlers, hooks into other entities, exports translations. Other features can r.requires("audit") to depend on it; if you remove it from your features array, the dependency check at boot tells you which feature needs it.

Three things, in order of how often they matter:

  • You skip the schema work. A bundled feature comes with the right table layout, the right indexes, the right migration history. Replicating that yourself is a week of work that ends with a worse audit trail than the one you could have used.
  • You skip the integration work. audit already hooks into every write through the system-hook layer at priority 1002. secrets already handles the brand-walk in the error serializer to prevent leaks. jobs already does leader election, heartbeat, and retry. None of those are obvious if you build them fresh.
  • You inherit upgrades. Bundled features get patched when the framework finds a problem. A custom audit table you wrote eighteen months ago does not.

The cost is that the schema is what it is. If your audit needs to capture an extra dimension that the bundled feature does not — a tenant-specific classification, an external compliance ID — you cannot just add a column.

The freedom to design exactly the schema you need, without the bundled feature’s migration history or its conventions. For business-specific things — your incidents, your invoices, your inventory — this is the right answer almost without thinking. Bundled features do not have a business-domain version of themselves; the framework does not ship an orders feature.

For cross-cutting concerns — auth, audit, secrets, files, jobs — going custom is the rare answer. The bundled features are designed for the 90 % case. If your case is the 10 % that genuinely differs (a regulated industry’s specific audit format, a pre-existing identity provider you must integrate with, a file storage scheme that must match a legacy contract), you take the framework’s primitives — r.entity, r.hook, r.scheduledJob — and build your own.

A common middle ground: keep the bundled feature, layer extensions on top of it. Two extension surfaces are usually all you need.

Registrar extensions. Many bundled features expose r.extendsRegistrar slots. The audit feature, for example, exposes r.audited("entity") — your own feature opts in for an entity, and audit’s hooks attach to that entity’s writes automatically. You did not modify the audit feature; you declared a relationship.

Hooks on the bundled entities. A bundled feature’s entities are ordinary entities, and your feature can hook into them. r.hook("postSave", "audit-entry", …) runs on every audit write, lets you push it to an external SIEM, and does not require any change to the audit feature itself.

When neither of those is enough, you replace the bundled feature with a custom one. Replacement is uneventful: the dependent features r.requires("audit") against the role, not the implementation. As long as your replacement registers under the same name and provides the same handlers, the rest of the app does not notice.

The split is not “framework vs. user code”

Section titled “The split is not “framework vs. user code””

A bundled feature is in the framework repo, but it has no privileged access. It cannot do anything your code cannot do, and it does not bypass any of the rules — boot validation, tenant isolation, access checks all apply to it the same way. The author of a bundled feature reads the same documentation you do.

This matters when you read bundled-feature source code as a reference. The audit feature’s source is a worked example of how to attach to many entities through registrar extensions. The secrets feature’s source is a worked example of how to use the brand-walk to prevent sensitive data from leaking through error responses. Both are normal features; nothing they do is reserved.

When you need a cross-cutting capability — identity, audit, secrets, files, jobs, notifications — start with the bundled feature. The defaults are battle-tested, the integrations are correct, and the upgrade path is real. Replace it only when you have measured a specific mismatch with your domain.

When you need business behaviour — orders, incidents, customers, inventory — always go custom. There is no bundled feature for your business, and there is no point pretending otherwise.

When you are in between — say, an existing identity system you must integrate with — layer on top before forking off. Extend the bundled feature first, replace it only if the extension surface does not stretch that far.

The bundled features are scaffolding. Use them where they fit; rebuild them where they don’t; ignore them where they are not the question.