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.
Module Anatomy
Section titled “Module Anatomy”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.
Building Modules
Section titled “Building Modules”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 and the Injection Pattern
Section titled “Providers and the Injection Pattern”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.
Provider Scope and Lifetime
Section titled “Provider Scope and Lifetime”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.
Module Composition Patterns
Section titled “Module Composition Patterns”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.
Testing Modules
Section titled “Testing Modules”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.
Module Discovery and Hot Reload
Section titled “Module Discovery and Hot Reload”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.
Module Registry Conventions
Section titled “Module Registry Conventions”For applications with many modules, a registry pattern keeps the bootstrap manageable:
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.
Bridge to Boundaries
Section titled “Bridge to Boundaries”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.
Exercise: Build a Sentinel Module
Section titled “Exercise: Build a Sentinel Module”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:
- Subscribes to the runtime’s diagnostic stream.
- Tracks the time each handler takes (start to end).
- Emits
runtime.slow_handlerevents when a handler exceeds the threshold. - 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:
- Install the sentinel.
- Install a slow module (one that has a handler that sleeps for 200ms).
- Trigger an event the slow module observes.
- Verify the sentinel detects the slowness and emits a warning.
Reflect on:
- How does the sentinel pattern apply to other operational concerns? (Error rates, queue depth, memory usage.)
- Could a sentinel intervene — say, by uninstalling a misbehaving module? (Yes — the runtime exposes
uninstall; sentinels can take action.) - 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.