Zum Inhalt springen

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:

  1. Handler-Calls ueber ctx.query / ctx.write (Dispatcher-Bruecke)
  2. Events ueber ctx.emit + Subscribe
  3. Lifecycle-Hooks auf Zielfeature-Entities
  4. Registrar-Extensions (r.customFields, r.tags, …)
  5. Konfiguration ueber core-config mit qualifizierten Keys
  6. 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

MechanismusSynchronitaetTransactionKardinalitaetKopplungBoot-validiert
ctx.query/write (Bridge)syncin derselben Tx1:1 (A ruft B)explizit via r.requires("b")ja
ctx.emit + Eventsasyncat-least-once nach Commit1:N (A broadcastet)lose — A kennt B nichtteilweise (Event-Def)
Lifecycle-Hooks auf BsyncinTransaction oder afterCommit1:1 (A haengt sich an B)A kennt B’s Entityja (via r.requires("b"))
Registrar-Extensionsboot-time (nicht runtime)n/a1:N deklarativB bietet an, A nutztja
Konfigurations-Keyssync read/writekein Framework-Tx1:N (alle koennen lesen)lose — Vertrag ist Key + Typeja
ctx.* Framework-Accessorssyncn/azum Framework, nicht zwischen Featuresn/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 / writeAs fuer 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) oder r.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) oder afterCommit (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) bietet r.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-secrets nutzen, 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.requires noetig — 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:

features/orders/handlers/create.ts
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:

framework/pipeline/dispatcher.ts
if (feature.name === "orders") {
// special handling
}

Richtig:

// Feature deklariert seine Intent explizit:
r.toggleable({ default: true });
r.extendsRegistrar(...)
// Framework reagiert auf Deklarationen, nicht auf Namen

Begruendung: Der Framework-Code darf keine Feature-Namen kennen. Sonst bricht die Austauschbarkeit.

Handler-Funktion direkt aufrufen

Falsch:

features/a/handler.ts
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 B
ctx.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 moeglich

Richtig:

r.secret("mailer.smtpPassword", { scope: "tenant" });
const pass = await ctx.secrets.get("mailer.smtpPassword"); // Response-Guard, Per-Read-Audit

Siehe 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.emit nutzen
  • JSON.parse/stringify ohne 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 Feature b nicht registriert → Boot-Fehler
  • ctx.query("x:y:z:w") Target-Handler nicht registriert → Boot-Fehler (statische Analyse beim Feature-Laden)
  • r.hook("postSave", "b:entity:x") aber r.requires("b") fehlt → Boot-Fehler
  • Event-Subscriber zu Event das nicht definiert ist → Boot-Fehler

Siehe boot-validation.md.

Test-Helper

  • bridgeStub() fuer Tests die ctx manuell 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.requires oder r.optionalRequires deklariert?
  • Kein direkter Import aus dem Ziel-Feature?
  • Bei Events: Subscriber ist idempotent?
  • Bei ctx.write in fremdes Feature: Atomicity gewuenscht (Rollback-on-Error)?
  • Bei Hooks: inTransaction vs afterCommit bewusst 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.