Skip to content

Chapter 54: Modules and Providers

Modules are where Kitsune applications keep their cross-cutting work.

The previous chapter covered the shell and runtime — the architectural plumbing. This chapter covers what runs inside the plumbing. A module is a unit of capability that installs into the runtime; a provider is a stable service a module exposes for other modules to inject. Together they’re the architecture’s primary unit of composition.

The pattern was introduced in Chapter 35 (From Components to Capabilities). This chapter develops the production version — the patterns the Kitsune team has converged on for building, testing, and operating modules in real applications.

The full module interface:

import { defineKitModule } from '@kitsune/core'
interface KitModule {
name: string
dependsOn?: string[]
events?: Record<string, EventHandler>
commands?: Record<string, CommandHandler>
providers?: Array<{ token: ProviderToken<any>; value: any }>
onInstall?: (ctx: ModuleContext) => Promise<void> | void
onStart?: (ctx: ModuleContext) => Promise<void> | void
onStop?: (ctx: ModuleContext) => Promise<void> | void
onUninstall?: (ctx: ModuleContext) => Promise<void> | void
}
interface ModuleContext {
runtime: Runtime
inject<T>(token: ProviderToken<T>): T | undefined
}

Each field is optional except name. The simplest module is just a name and one event handler:

const helloModule = defineKitModule({
name: 'hello',
events: {
'app.started': () => console.log('Hello from the hello module')
}
})

The most complex module has all the fields — declared dependencies, event subscribers, command handlers, exposed providers, lifecycle hooks. Most production modules sit somewhere in the middle.

A short walkthrough of how to build a typical capability module.

Analytics module:

import { defineKitModule } from '@kitsune/core'
interface AnalyticsOptions {
provider: { track: (name: string, props: Record<string, unknown>) => void }
enabled?: boolean
}
export function analyticsModule(options: AnalyticsOptions) {
return defineKitModule({
name: 'analytics',
events: {
'*': (event) => {
if (options.enabled === false) return
if (event.context?.private) return
// Translate the application event into the provider's vocabulary
const name = toAnalyticsName(event.type)
const props = buildAnalyticsProps(event)
options.provider.track(name, props)
}
}
})
}
function toAnalyticsName(eventType: string): string {
// "profile.saved" -> "Profile Saved"
return eventType
.split('.')
.map((s) => s.replace(/_/g, ' '))
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(' ')
}
function buildAnalyticsProps(event: AppEvent): Record<string, unknown> {
const props: Record<string, unknown> = {}
if (event.context?.surface) props.surface = event.context.surface
if (event.context?.feature) props.feature = event.context.feature
if (event.context?.entity) {
props.entity_type = event.context.entity.type
props.entity_id = event.context.entity.id
}
// Add filtered payload (redaction applied)
if (event.payload) Object.assign(props, filterProps(event.payload))
return props
}

The module is a factory function (takes options, returns a KitModule). The wildcard subscriber observes every event and translates to the analytics provider. The translation logic is centralized; modifying it changes the analytics shape everywhere.

Notifications module:

import { defineKitModule } from '@kitsune/core'
export const NOTIFICATIONS = createProviderToken<{
show(message: string, options?: ToastOptions): string
dismiss(id: string): void
}>('notifications')
export function notificationsModule() {
let container: HTMLElement | null = null
return defineKitModule({
name: 'notifications',
onInstall: ({ runtime }) => {
container = document.createElement('div')
container.id = 'kit-notifications-container'
container.style.cssText = 'position:fixed;bottom:1rem;right:1rem;z-index:9999;'
document.body.appendChild(container)
runtime.provide(NOTIFICATIONS, {
show: (message, options) => showToast(container!, message, options),
dismiss: (id) => dismissToast(container!, id)
})
},
onUninstall: () => {
container?.remove()
container = null
},
commands: {
'notification.show': (command) => {
const { message, ...options } = command.payload as any
return { id: showToast(container!, message, options) }
},
'notification.dismiss': (command) => {
dismissToast(container!, (command.payload as any).id)
return { ok: true }
}
}
})
}

The module owns the notification UI (a container appended to the body). It exposes the notifications service as a provider for other modules to inject directly. It also handles notification.show and notification.dismiss commands for components that prefer the command surface. The module is self-contained; cleanup removes the container when uninstalled.

Providers are typed services modules expose to each other. A provider token carries the service’s type signature:

import { createProviderToken } from '@kitsune/core'
export const USER = createProviderToken<{
current(): UserInfo | null
isSignedIn(): boolean
signIn(token: string): Promise<UserInfo>
signOut(): Promise<void>
}>('user')
interface UserInfo {
id: string
email: string
displayName: string
roles: string[]
}

A user module provides the value:

const userModule = defineKitModule({
name: 'user',
providers: [
{
token: USER,
value: {
current: () => /* read from storage */ null,
isSignedIn: () => /* check */ false,
signIn: async (token) => /* sign in */ ({ id: '...', email: '...', displayName: '...', roles: [] }),
signOut: async () => { /* clear storage */ }
}
}
]
})

Other modules inject by token:

const auditModule = defineKitModule({
name: 'audit',
dependsOn: ['user'],
events: {
'profile.saved': (event) => {
const user = runtime.inject(USER)?.current()
recordAudit('profile.saved', {
actor: user?.id,
target: event.context.entity.id,
timestamp: Date.now()
})
}
}
})

The injection is type-safe — the token’s generic parameter flows through runtime.inject<T>() so the returned value is correctly typed. The audit module knows the user service has a current() method without importing the user module.

Providers in Kitsune are shell-scoped. Each shell has its own provider registry. Modules in different shells can register the same token with different values, and the modules in each shell see the local registration.

The lifetime matters. A provider is registered when its module installs. It’s unregistered when its module uninstalls. Modules that inject providers should handle the case where the provider isn’t yet available (the typical case is the dependency-graph guarantee: if module A depends on module B, then B is installed before A starts, so A’s onStart can inject B’s providers safely).

For long-lived providers that should always be available, the convention is to declare them as part of the application’s core module set. The application’s bootstrap registers them first; everything else can rely on them.

A few common patterns the Kitsune team has converged on.

Provider-with-events-and-commands. A module both exposes a provider and observes events and handles commands. The provider gives other modules direct access; the events let the module react to architecture-wide changes; the commands let other modules request specific actions. The notifications module above is this pattern.

Translator module. A module that observes events in one vocabulary and re-emits them in another. The analytics module is a translator (application events → analytics provider’s vocabulary). The audit module is a translator (application events → audit records). Translators are often pure functions of their input events.

Orchestrator module. A module that handles a multi-step flow — observes one event, calls commands, emits derived events. The save-orchestrator from Chapter 52 is this pattern. The orchestrator centralizes the flow’s logic; the components that trigger the flow don’t have to know about the steps.

Adapter module. A module that bridges Kitsune’s interfaces to an external system. A router adapter attaches a boundary at the document root with the current route’s context, and updates the boundary when navigation happens. A user adapter hydrates the user state from local storage on start and exposes the USER provider.

Sentinel module. A module that watches for problematic patterns and emits alerts. A slow-handler sentinel observes the diagnostic stream and emits warnings when handlers exceed a time budget. A permission sentinel watches command dispatches and rejects ones the current user isn’t authorized for.

Each pattern is a small module. Applications compose from these primitive patterns to produce their specific behavior.

Modules are the easiest part of the architecture to test because they’re pure logic against typed interfaces. The @kitsune/testing helpers make the pattern concise:

import { test, expect } from 'vitest'
import { createTestRuntime } from '@kitsune/testing'
import { analyticsModule } from './analytics-module.js'
test('analytics translates event names', async () => {
const tracked: any[] = []
const runtime = createTestRuntime()
await runtime.install(analyticsModule({
provider: { track: (name, props) => tracked.push({ name, props }) }
}))
runtime.emit({ type: 'profile.saved' })
expect(tracked).toHaveLength(1)
expect(tracked[0].name).toBe('Profile Saved')
})
test('analytics skips events in private boundaries', async () => {
const tracked: any[] = []
const runtime = createTestRuntime()
await runtime.install(analyticsModule({
provider: { track: (name, props) => tracked.push({ name, props }) }
}))
runtime.emit({
type: 'payment.attempted',
context: { private: true }
})
expect(tracked).toHaveLength(0)
})

The runtime is created in isolation, the module installs, the event fires, the assertion runs. No DOM. No shell startup. The test is fast and self-contained.

For modules that depend on other modules’ providers, the test installs the dependency or provides a fake:

test('audit module records actor from user provider', async () => {
const records: any[] = []
const runtime = createTestRuntime()
// Provide a fake user
runtime.provide(USER, {
current: () => ({ id: 'user_42', email: '...', displayName: 'Test', roles: [] }),
isSignedIn: () => true,
signIn: async () => ({} as any),
signOut: async () => {}
})
await runtime.install(auditModule({
record: (action, data) => records.push({ action, data })
}))
runtime.emit({
type: 'profile.saved',
context: { entity: { type: 'profile', id: 'p_1' } }
})
expect(records[0].data.actor).toBe('user_42')
expect(records[0].data.target).toBe('p_1')
})

The pattern is the same. Real module, fake dependency, assertion against the captured outputs.

For applications using bundlers with hot module replacement (Vite, Webpack), modules can be hot-reloaded during development. The pattern:

if (import.meta.hot) {
import.meta.hot.accept('./modules/analytics-module.js', async (newModule) => {
if (!newModule) return
await shell.uninstall('analytics')
await shell.install(newModule.analyticsModule(options))
})
}

The shell’s uninstall cleans up the old module’s subscriptions; the install registers the new version. The runtime state survives (events emitted during the swap don’t get lost in flight; commands in progress complete). The pattern produces a smooth development experience — change a module, save, the application updates without a full reload.

For production, hot reload isn’t typically used. The pattern is included because it works well in development and shows the architecture’s clean separation between modules and the runtime.

For applications with many modules, a registry pattern keeps the bootstrap manageable:

modules/index.ts
export const coreModules = [
userModule(),
storageModule(),
notificationsModule(),
dialogModule(),
analyticsModule({ provider: analytics }),
auditModule({ record: api.audit.create }),
observabilityModule({ sentry })
]
export const adminModules = [
permissionsModule(),
auditViewModule()
]
export const experimentalModules = featureFlags.aiAssistant
? [aiAssistantModule()]
: []

The application’s main file composes from the registry:

import { createShell } from '@kitsune/core'
import { coreModules, adminModules, experimentalModules } from './modules'
const shell = createShell({
name: 'admin-app',
modules: [
...coreModules,
...adminModules,
...experimentalModules
]
})
await shell.start()

The pattern keeps the bootstrap legible. Adding a new module is a one-line addition. Removing is a one-line deletion. Conditional inclusion (feature flags, user role, environment) is straightforward.

The next chapter (Chapter 55) develops the boundary system in detail — the declarative <kit-boundary> element, the programmatic runtime.attachBoundary API, the context-collection algorithm, the production patterns for boundary management in dynamic applications.

After Chapter 55, the chapters that follow cover the metadata protocol (Ch 56), events and commands (Ch 57), and diagnostics (Ch 58). Then Chapters 59 through 63 walk through five complete applications.

Build a slow-handler sentinel — a module that observes the diagnostic stream and emits warnings when event handlers or command handlers take longer than a configured threshold.

The module:

  1. Subscribes to the runtime’s diagnostic stream.
  2. Tracks the time each handler takes (start to end).
  3. Emits runtime.slow_handler events when a handler exceeds the threshold.
  4. Records the threshold violations to a console log (or to an observability provider in production).

The module’s interface:

slowHandlerSentinel({
thresholdMs: 100,
onSlow: (info) => { /* report */ }
})

Then test it:

  1. Install the sentinel.
  2. Install a slow module (one that has a handler that sleeps for 200ms).
  3. Trigger an event the slow module observes.
  4. Verify the sentinel detects the slowness and emits a warning.

Reflect on:

  1. How does the sentinel pattern apply to other operational concerns? (Error rates, queue depth, memory usage.)
  2. Could a sentinel intervene — say, by uninstalling a misbehaving module? (Yes — the runtime exposes uninstall; sentinels can take action.)
  3. What’s the production cost of running a sentinel? (Small — observing the diagnostic stream is cheap; the work is per-entry.)

The exercise is a working production pattern. Real applications often have several sentinel modules — slow-handler detection, error-rate monitoring, security-policy enforcement, feature-flag debugging. Each is a small module in the architecture’s vocabulary. The composition produces a system that monitors itself.