Feature-Integration — Kommunikations-Pfade im Vergleich
Ueberblick ueber alle Wege auf denen Features in Kumiko miteinander reden. Leitfaden zur Auswahl: welcher Pfad fuer welches Problem.
Ausgangspunkt: feature-principles.md — “Alles ist ein Feature, maximal entkoppelt”. Dieses Dokument zeigt wie die Entkopplung in der Praxis aussieht.
Prinzip: der Feature-Contract
Ein Feature darf ein anderes nur ueber diese Kanaele ansprechen:
- Handler-Calls ueber
ctx.query/ctx.write(Dispatcher-Bruecke) - Events ueber
ctx.emit+ Subscribe - Lifecycle-Hooks auf Zielfeature-Entities
- Registrar-Extensions (
r.customFields,r.tags, …) - Konfiguration ueber
core-configmit qualifizierten Keys - Framework-Accessors (
ctx.secrets,ctx.tz,ctx.db) — genauer: das ist nicht Cross-Feature, sondern Zugriff auf Framework-Infra
Nicht erlaubt:
- Direkte Imports aus anderen Features (Types, Tables, Helper, Konstanten)
- Handler-Funktionen direkt aufrufen (am Dispatcher vorbei)
- Direkt in fremde Tabellen schreiben (am Field-Access vorbei)
if (featureName === "x")im Framework-Code
Die sechs Mechanismen im Vergleich
| Mechanismus | Synchronitaet | Transaction | Kardinalitaet | Kopplung | Boot-validiert |
|---|---|---|---|---|---|
ctx.query/write (Bridge) | sync | in derselben Tx | 1:1 (A ruft B) | explizit via r.requires("b") | ja |
ctx.emit + Events | async | at-least-once nach Commit | 1:N (A broadcastet) | lose — A kennt B nicht | teilweise (Event-Def) |
| Lifecycle-Hooks auf B | sync | inTransaction oder afterCommit | 1:1 (A haengt sich an B) | A kennt B’s Entity | ja (via r.requires("b")) |
| Registrar-Extensions | boot-time (nicht runtime) | n/a | 1:N deklarativ | B bietet an, A nutzt | ja |
| Konfigurations-Keys | sync read/write | kein Framework-Tx | 1:N (alle koennen lesen) | lose — Vertrag ist Key + Type | ja |
ctx.* Framework-Accessors | sync | n/a | — | zum Framework, nicht zwischen Features | n/a |
Wichtige Unterscheidungen
- Sync in-tx heisst: Fehler im Ziel rollbackt das ganze Set. Fuer “ich brauche das Ergebnis jetzt, Atomicity wichtig”.
- Async at-least-once heisst: Ziel bekommt’s garantiert (Outbox), aber A wartet nicht. Kann mehrfach ankommen → Ziel idempotent.
- Boot-time deklarativ heisst: wird beim Feature-Laden aufgeloest, laeuft nicht bei jedem Request.
Entscheidungs-Baum
Feature A will mit Feature B interagieren.Welches Problem loese ich?
├─ Ich brauche DATEN von B (lesen)││ └── ctx.query("b:query:entity:find", payload)│ ctx.queryAs(systemUser, "b:query:entity:find-admin", ...) wenn privilegiert│├─ Ich will eine AENDERUNG in B AUSLOESEN││ ├─ Muss atomar mit A's eigener Aenderung passieren│ │ └── ctx.write("b:write:entity:create", payload)│ │ → in derselben Transaction, Rollback bei Fehler│ ││ └─ Loose gekoppelt, A will nicht warten│ └── ctx.emit("a:event:something-happened", payload)│ B subscribed via r.onEvent oder r.job({ trigger: { on: ... }})│├─ Ich will REAGIEREN wenn in B etwas passiert││ ├─ B deklariert explizit ein Event│ │ └── r.onEvent("b:event:changed", handler)│ ││ └─ Kein Event, aber ich will an B's CRUD-Lifecycle ran│ └── r.hook("postSave", "b:entity:x", handler)│ (oder "preSave" / "preDelete" / "postDelete")│ A muss r.requires("b") deklarieren│├─ Ich will B's ENTITIES ERWEITERN│ (neues Feld, Tag, Custom-Fields, State-Machine, Audit, ...)││ └── r.extendsRegistrar("myExtension", { ... })│ B opt-int pro Entity via r.myExtension("entityName")│├─ A und B teilen KONFIGURATION││ ├─ Tenant-spezifische Config│ │ └── r.config() mit qualifizierten Keys ("b.setting.foo")│ │ A liest via ctx.config("b.setting.foo")│ ││ └─ Feature-interne Kommunikation ueber State-Sharing│ → oft ein Code-Smell — besser Event oder Call│└─ Ich brauche FRAMEWORK-SERVICE (Crypto, Timezone, Secrets, DB) └── ctx.secrets / ctx.tz / ctx.db / ctx.audit / ... (Nicht Cross-Feature-Kommunikation — sondern Framework-Infra-Zugriff)Detail-Referenz pro Mechanismus
1. Handler-Calls via ctx.query/write
Siehe cross-feature-calls.md — vollstaendig dokumentiert.
Kurzfassung:
- Sync Request/Reply
- Laeuft in derselben DB-Transaction wie der Aufrufer
- Field-Access, Access-Check, Validation-Hooks, Audit greifen wie bei externen Calls
queryAs/writeAsfuer expliziten Identity-Wechsel (System-User etc.)r.requires("b")macht Kopplung explizit, Boot-validiert
Wann nehmen:
- “Ich brauche das Ergebnis, sonst kann ich nicht weitermachen”
- Atomicity wichtig (Fehler in B rollbackt A)
- Privilegierter Lookup (mit
queryAs)
Wann nicht:
- Wenn du’s nicht sofort brauchst → Event nehmen
- Wenn viele Subscriber das brauchen → Event
- Wenn es dein eigenes Feature ist → direkt Handler aufrufen ist eh nicht gemeint (Dispatcher-Bruecke ist nur fuer Cross-Feature)
2. Events via ctx.emit
Siehe outbox.md.
Kurzfassung:
- Feature deklariert Event via
r.defineEvent("name", zodSchema) - Emit via
ctx.emit("qualifiedName", payload)— landet in der Outbox - Subscriber-Seite entweder
r.onEvent(qn, handler)(wenn API da) oderr.job({ trigger: { on: qn } }) - At-least-once, Subscriber muss idempotent sein
- Events tragen
schemaVersion(siehe api-evolution.md)
Wann nehmen:
- A will die Welt informieren, ohne zu wissen wer interessiert ist
- Viele Subscriber koennen eigenstaendig reagieren
- Zuverlaessigkeit > Latenz (Email, Push, Webhook)
- Cross-Process oder Cross-Deployment
Wann nicht:
- Du brauchst Antwort — nimm
ctx.query - Atomicity erforderlich — Events feuern
afterCommit, zu spaet fuer Rollback
3. Lifecycle-Hooks auf fremde Entities
Siehe Entity + Hook-System in Engine-Docs.
Kurzfassung:
- A registriert
r.hook("postSave", "b:entity:x", handlerFn) - Laeuft bei jedem Save von B’s Entity x
- Phase
inTransaction(mit Rollback) oderafterCommit(Side-Effects) - A muss
r.requires("b")deklarieren, sonst Boot-Fehler
Wann nehmen:
- “Immer wenn B gespeichert wird, muss A auch X tun” — z.B. Audit, Search-Index, Cache-Invalidierung
- B hat kein explizites Event dafuer, und man will nicht in B aendern
Wann nicht:
- Wenn B ein Event deklariert → besser Event subscribers
- Fuer cross-feature-Business-Logik — Event/Call sind klarer
- Wenn A mehr Features kennt als noetig — zeichen von schlechter Entkopplung
4. Registrar-Extensions
Siehe registrar-extensions.md.
Kurzfassung:
- Ein Feature (z.B.
custom-fields) bietetr.extendsRegistrar("customFields", {...})an - Andere Features opt-in via
r.customFields("orderEntity") - Fuenf Erweiterungs-Ebenen: onRegister, extendSchema, hooks, extendSearch, uiExtension
- Wird beim Boot aufgeloest, nicht zur Laufzeit
- Saubere 1:N-Beziehung ohne dass das anbietende Feature die Nutzer kennt
Wann nehmen:
- Du baust eine Querschnittsfunktion die auf beliebige Entities angewendet werden soll: Custom-Fields, Tags, Audit, Comments, Likes, Attachments, Importable, Exportable
- Deklarativer Opt-in pro Entity ist sinnvoll
- Fuenf-Ebenen-Muster passt (wenn’s sich auf 1-2 beschraenkt, kann auch Hook + Event reichen)
Wann nicht:
- Wenn’s nur zwischen zwei konkreten Features ist — direkter Event/Call ist einfacher
- Wenn Runtime-Opt-in noetig ist (Registrar-Extensions sind boot-time)
5. Konfigurations-Keys via core-config
Kurzfassung:
- B deklariert
r.config({ keys: { "b.setting.foo": { type: "text", access: { read: [...], write: [...] }}}}) - A liest ueber
ctx.config("b.setting.foo") - Scope: typischerweise Tenant, teils global
- Qualifiziert via Feature-Name im Key
Wann nehmen:
- Gemeinsame runtime-konfigurierbare Werte (Rate-Limit-Overrides, Threshold, Defaults)
- Tenant kann’s selbst setzen (Self-Service-Settings)
- Keine direkten Calls noetig
Wann nicht:
- Wenn’s um Business-Daten geht — gehoert als Entity
- Wenn es gar nicht pro Tenant variiert — hartcoden ist ok
- Fuer Geheimnisse —
core-secretsnutzen, nicht Config
6. Framework-Accessors (ctx.*)
Kurzfassung:
ctx.db,ctx.secrets,ctx.tz,ctx.audit,ctx.metrics,ctx.events,ctx.query,ctx.write, …- Alle vom Framework bereitgestellt, tenant-scoped wo relevant
- Kein Feature-Import, kein
r.requiresnoetig — Framework-Infra
Wann nehmen:
- Immer wenn du DB, Zeit, Secrets, Audit, Metrics brauchst
- Nie selbst-implementieren was ctx.* schon hat
Anti-Patterns — konkrete Beispiele
Direkter Import einer fremden Table
Falsch:
import { customerTable } from "../../customers/db/schema"; // ❌
await ctx.db.select().from(customerTable).where(eq(...));Richtig:
const customer = await ctx.query("customers:query:customer:find", { id });Begruendung: Table-Struktur ist Implementation-Detail des Customers-Features. Field-Access-Regeln werden umgangen. Refactoring des Customers-Schemas bricht Orders.
Feature-Name-Check im Framework
Falsch:
if (feature.name === "orders") { // special handling}Richtig:
// Feature deklariert seine Intent explizit:r.toggleable({ default: true });r.extendsRegistrar(...)
// Framework reagiert auf Deklarationen, nicht auf NamenBegruendung: Der Framework-Code darf keine Feature-Namen kennen. Sonst bricht die Austauschbarkeit.
Handler-Funktion direkt aufrufen
Falsch:
import { createOrderHandler } from "../orders/handlers/create";const result = await createOrderHandler(payload, ctx); // ❌Richtig:
const result = await ctx.write("orders:write:order:create", payload);Begruendung: Dispatcher macht Access-Check, Validation, Hooks, Audit. Direkt-Aufruf umgeht alles davon.
Events fuer Request/Reply missbrauchen
Falsch:
// A emittet Event und wartet dann auf ein "Response-Event" von Bctx.emit("a:request:userData", { userId });const response = await waitForEvent("b:response:userData"); // ❌Richtig:
const userData = await ctx.query("b:query:user:data", { userId });Begruendung: Events sind 1:N Fire-and-Forget. Request/Reply braucht synchrone Bruecke.
Broadcast aus Hook
Falsch:
r.hook("postSave", "order:entity:order", async (ctx, changes) => { // Direkt Redis publishen await redis.publish("order-events", JSON.stringify(changes)); // ❌});Richtig:
r.hook("postSave", "order:entity:order", async (ctx, changes) => { await ctx.emit("orders:event:changed", changes); // Outbox, at-least-once});Begruendung: Direct-Redis verliert bei Crash zwischen Commit und Publish. Outbox loest das (siehe outbox.md).
Config-Key fuer Geheimnis missbrauchen
Falsch:
r.config({ keys: { "mailer.smtpPassword": { type: "text" }}});const pass = await ctx.config("mailer.smtpPassword"); // Klartext in Response moeglichRichtig:
r.secret("mailer.smtpPassword", { scope: "tenant" });const pass = await ctx.secrets.get("mailer.smtpPassword"); // Response-Guard, Per-Read-AuditSiehe core-secrets.md.
Guardrails (Framework-Enforcement)
Lint-Regeln (statisch)
- Cross-Feature-Imports verboten: alles was
../other-feature/importiert → Lint-Fehler. Ausnahme: publike Exports wie Event-Namen-Konstanten wenn wirklich noetig (in der Praxis nie). new Date()verboten in Feature-Code (siehe timezones.md)redis.publish/ direkte Redis-Calls verboten ausserhalb Framework-Infra —ctx.emitnutzenJSON.parse/stringifyohne Zod verboten fuer User-Input
Implementation: Biome-Regeln oder ein ts-morph-Script in scripts/lint-cross-feature.ts.
Boot-Validierung (dynamisch)
r.requires("b")aber Featurebnicht registriert → Boot-Fehlerctx.query("x:y:z:w")Target-Handler nicht registriert → Boot-Fehler (statische Analyse beim Feature-Laden)r.hook("postSave", "b:entity:x")aberr.requires("b")fehlt → Boot-Fehler- Event-Subscriber zu Event das nicht definiert ist → Boot-Fehler
Siehe boot-validation.md.
Test-Helper
bridgeStub()fuer Tests diectxmanuell bauen (siehe cross-feature-calls.md)- Wenn ein Test versehentlich ueber die Bruecke geht, werfen die Stub-Funktionen — sofort sichtbar, Test muss echt bauen statt stubben
Gesamt-Uebersicht als Checklist
Fuer Feature-Autoren die mit anderen Features interagieren muessen:
- Habe ich den richtigen Mechanismus gewaehlt (Entscheidungs-Baum durchgegangen)?
-
r.requiresoderr.optionalRequiresdeklariert? - Kein direkter Import aus dem Ziel-Feature?
- Bei Events: Subscriber ist idempotent?
- Bei
ctx.writein fremdes Feature: Atomicity gewuenscht (Rollback-on-Error)? - Bei Hooks:
inTransactionvsafterCommitbewusst gewaehlt? - Bei
ctx.queryAs/writeAs: privilegierten Scope bewusst gewaehlt? - Bei Config/Secrets: richtiges System (Config fuer Settings, Secrets fuer Credentials)?
Zukunft
Die Trennung in sechs Mechanismen ist bewusst. Wenn einer der Mechanismen zu einem physisch getrennten Layer werden muss (Kumiko-Instanz zu Kumiko-Instanz ueber Netz), bleibt die API von auss Feature-Code betrachtet gleich. Beispiel:
ctx.query("b:query:x:y", ...)ist heute ein In-Process-Call- Wenn B in einen eigenen Service wandert: derselbe Call geht ueber HTTP/gRPC
- Feature-Autor schreibt unveraendert gegen
ctx.query
Das ist der Grund warum kein direkter Import erlaubt ist: die Abstraktion ist genau hier der Wert.