Chapter 57: Events vs. Commands: The Closed Loop
The events-and-commands split is the architecture’s most important conceptual distinction.
The split was introduced in Chapter 23 (architectural argument) and Chapter 37 (educational detail). This chapter develops the production version — the patterns for naming events and commands, the error handling, the tracing, the operational considerations for running the closed loop at scale.
The Distinction, Concretely
Section titled “The Distinction, Concretely”Events are facts. They describe state changes that have happened. They have many subscribers, no return value, and isolated handler errors. Naming convention: past tense, dot-separated, namespaced by domain. profile.saved. checkout.completed. user.signed_out.
Commands are requests. They describe state changes that should happen. They have a single handler, a typed return value, and errors that propagate to the caller. Naming convention: imperative, dot-separated, namespaced by domain. notification.show. dialog.open. cart.add_item.
The naming is a discipline. Following it consistently makes the codebase legible — a developer reading profile.saved knows immediately that something happened in the past; a developer reading notification.show knows it’s a request.
Some events and commands have natural pairs. profile.save_requested (event the form fires) → profile.save (command the orchestrator dispatches) → profile.saved (event the orchestrator emits). The pair is request → action → result. The events bookend the command.
Event Patterns
Section titled “Event Patterns”A few patterns the Kitsune team has converged on for events.
Past-tense fact events. profile.saved, payment.completed, subscription.cancelled. These are the most common events — something happened, modules can react.
Failure events. profile.save_failed, payment.declined, signup.email_taken. The orchestrator emits these when an attempted action fails. UI modules subscribe to update the user; analytics modules subscribe to track failure rates.
State-changed events. theme.changed, locale.changed, user.role_updated. These are emitted when application-wide state shifts. Modules that care re-derive their behavior.
Lifecycle events. app.started, shell.mounted, route.changed, app.before_unload. These are emitted by the architecture itself or by specific lifecycle-aware modules.
Diagnostic events. slow_handler.detected, module.errored, validation.boundary_rejected. Operational concerns that monitoring modules observe.
The full event vocabulary for an application typically lands at 30-80 events. The vocabulary is documentable, testable, and stable over time.
Command Patterns
Section titled “Command Patterns”Commands tend to be more focused than events. A few patterns:
UI commands. notification.show, dialog.open, dialog.close, tooltip.show. The command handler runs in a UI module that owns the corresponding affordance. The component dispatching the command doesn’t import the UI module.
Data commands. profile.save, cart.add_item, subscription.cancel. The handler runs in an orchestrator module that owns the data flow.
Navigation commands. route.navigate, tab.switch, dialog.close. The handler runs in a navigation-aware module that coordinates with the router.
Configuration commands. theme.set, locale.set, preferences.update. The handler runs in a preferences module that persists the change.
A command’s handler returns a result that the caller can use. notification.show returns the notification’s ID (so the caller can dismiss it later). profile.save returns the saved profile data. cart.add_item returns the updated cart.
Error Handling
Section titled “Error Handling”Events and commands handle errors differently. The asymmetry is intentional.
Event handlers run with their errors isolated. A subscriber that throws doesn’t break the emission; the runtime catches the error, records it to the diagnostic stream, and continues running other subscribers. The emitter doesn’t know about the failure.
runtime.on('profile.saved', (event) => { // If this throws, the error is captured but doesn't propagate throw new Error('analytics failed')})
runtime.emit({ type: 'profile.saved', ... })// emit() returns successfully; the error is in the diagnostic streamThis is the right shape for events. The emitter is announcing a fact; subscribers are observing. The emitter shouldn’t be coupled to whether any specific subscriber succeeded.
Command handlers propagate errors back to the caller. A handler that throws produces a CommandResult with ok: false and the error. The caller can decide what to do.
const result = await runtime.command({ type: 'profile.save', payload: data })if (!result.ok) { console.error('Save failed', result.error) // The caller can recover, retry, show an error UI}This is the right shape for commands. The caller is requesting an action; the result tells the caller what happened. Coupling the result to the caller is intentional.
The asymmetry produces a workable error model. Event subscribers fail in isolation; command callers see failures explicitly.
The Causality Chain
Section titled “The Causality Chain”A specific architectural property the events-and-commands split enables: causality is legible.
A typical save flow:
- User clicks Save. Browser fires a click event.
- Metadata boundary observes the click, builds an event with context.
- Runtime emits
profile.save_requested. - Save-orchestrator module receives the event.
- Orchestrator dispatches
form.validatecommand. Handler returns{ valid: true }. - Orchestrator dispatches
profile.savecommand. Handler calls the API, returns{ saved: true, profile: {...} }. - Orchestrator emits
profile.savedwith the saved data. - Analytics, audit, draft-cleanup, notification modules each subscribe to
profile.savedand run their handlers. - Notification module’s handler dispatches
notification.showcommand. - Notifications module’s handler creates and shows a toast.
The chain is traceable. The diagnostic stream records every event and command in order. A developer investigating why didn’t the audit module run? can find the chain, see whether profile.saved fired, see whether the audit module’s handler was invoked, see whether the handler threw an error or returned successfully.
This is the architectural payoff of the split. The application’s behavior is a sequence of events and commands; the sequence is the trace; the trace is the documentation of what happened.
Production Patterns
Section titled “Production Patterns”Idempotent command handlers. Commands can be retried (manually by the caller, or automatically by the runtime in certain configurations). The handlers should be idempotent — calling the same command twice should produce the same result, not double-execute. The notification.show command typically generates a fresh ID each call; the profile.save command applies the save and returns the saved state, regardless of whether the same data was saved before.
Event versioning. As the application evolves, event shapes can change. The team can either rename events (profile.saved → profile.saved.v2) or version the schema in a meta-version attribute. The pattern matches HTTP API versioning — explicit versions, deprecation periods, backward-compatibility.
Command throttling. Some commands shouldn’t fire more than once per time window. The runtime can be configured with per-command-type throttling. notification.show with the same message in the last second can be deduplicated.
Event sampling. High-frequency events (mouse moves, scroll, animation ticks) might fire hundreds of times per second. The runtime can be configured to sample these events for analytics modules (record one in N) while letting other subscribers see every event.
Async command timeouts. Long-running commands shouldn’t block forever. The runtime can be configured with per-command timeouts. A command that doesn’t complete in the timeout produces an error result; the caller can retry or fall back.
These are operational decisions. The architecture supports them; the team configures them per application.
Testing the Closed Loop
Section titled “Testing the Closed Loop”Integration tests for the closed loop exercise the full chain:
test('profile save completes the closed loop', async () => { const { shell, runtime } = await createTestShell({ modules: [ saveOrchestratorModule({ repository: fakeRepo }), analyticsModule({ provider: fakeAnalytics }), auditModule({ record: fakeAudit }) ], template: html` <kit-boundary surface="profile-editor" entity-type="profile" entity-id="me"> <form data-meta-event="profile.save_requested" data-meta-prop-prevent-default="true"> <kit-text-field name="displayName" value="Jeremy"></kit-text-field> <kit-button type="submit">Save</kit-button> </form> </kit-boundary> ` })
const trace: any[] = [] runtime.onDiagnostic((entry) => trace.push(entry))
// Trigger shell.runtime.querySelector('form')!.requestSubmit() await waitForEvent(runtime, 'profile.saved')
// Verify expect(fakeRepo.saved).toHaveLength(1) expect(fakeAnalytics.tracked).toHaveLength(1) expect(fakeAudit.recorded).toHaveLength(1)
// Verify the trace shape const eventTypes = trace.filter(e => e.kind === 'event').map(e => e.type) expect(eventTypes).toEqual(['profile.save_requested', 'profile.saved'])
const commandTypes = trace.filter(e => e.kind === 'command').map(e => e.type) expect(commandTypes).toEqual(['profile.save'])
await shell.stop()})The test exercises the full chain. The assertions verify both the side effects (repo saved, analytics tracked, audit recorded) and the trace shape (events and commands in the expected order). The architecture’s events-and-commands split makes the test natural to write.
Bridge to Diagnostics
Section titled “Bridge to Diagnostics”The next chapter (Chapter 58) takes diagnostics seriously — the on-page debug overlay, the production observability integration, the patterns for making the closed loop visible during development and operations.
The events-and-commands system is what gets logged. Diagnostics is the surface that makes the log usable.
Exercise: Map an Application’s Causality
Section titled “Exercise: Map an Application’s Causality”Take an application you’ve worked on (or one you’ve built through the book’s exercises). Pick a single user-visible flow — user saves their profile, user adds to cart, user clicks Buy — and map the complete causality chain.
For the flow:
- List the user-visible interaction.
- List the metadata that the markup carries.
- List the events that fire in order.
- List the commands that dispatch in order.
- List the modules that observe each event or handle each command.
- List the side effects (API calls, storage writes, UI updates) that result.
The result is a trace specification — a description of what the application does for this flow.
Then verify the spec matches reality:
- Run the application in development mode with the diagnostic overlay enabled.
- Trigger the flow.
- Compare the live trace against your spec.
- Any discrepancies? (Missing modules? Unexpected events? Wrong order?)
Then think about adding a step:
- Suppose you want to add a fraud check — every checkout-started event triggers an asynchronous fraud-detection call that, on failure, emits
checkout.flagged. - Where in the chain does the new module fit?
- What does the trace look like with the new module added?
Reflect on:
- How does the events-and-commands trace compare to reading the React component tree? (More legible — the trace is a sequence, not a graph.)
- Could a non-engineer (product manager, analyst) read the trace and understand what happened? (Yes — the names are semantic, the order is clear.)
- If the application had a hundred flows like this, would the team be able to manage them? (With the diagnostic overlay and the event vocabulary as documented artifacts, yes.)
The exercise is the closed loop made concrete. The architecture’s commitment to causality is legible shows up in the artifact — the trace is the documentation, the test specification, and the debugging surface, all in one form.