Skip to content

Chapter 37: Events, Commands, and Causality

Applications become difficult to understand when what happened and what should happen are mixed together.

A user clicked save. The profile was saved. A dialog should open. A notification should appear. A draft should be cleared. Analytics should track a conversion. Audit should record an action. The error message should hide.

Some of these are facts. Some are requests. The two are different kinds of things, and confusing them is one of the reasons large frontend applications become hard to reason about.

The architecture this book proposes draws a deliberate line between them. Events are facts about what happened. Commands are requests for what should happen. The runtime distinguishes them. The components and modules use them differently. The split is borrowed, like the bounded-context idea from the previous chapter, from a backend pattern that has earned its keep over the past two decades.

The pattern has a name in backend literature: Command Query Responsibility Segregation, abbreviated CQRS.

Greg Young is the developer most associated with naming and developing the pattern, in talks and writing from the mid-to-late 2000s. Udi Dahan, working independently on similar ideas, was a parallel voice. The original article and Young’s subsequent decade of writing and consulting work made CQRS a foundational pattern in serious enterprise backend architecture.

The kernel of the idea: the model you use to write data is structurally different from the model you use to read it. The write side accepts commandsPlaceOrder, CancelSubscription, AssignTask — and produces events describing what changed. The read side observes events and maintains the data structures that views need. Many consumers of the write side’s events can exist (analytics, audit logs, search indexes, reporting tools, view caches), each maintaining its own derived state, without the write side knowing they exist.

CQRS came out of distributed-systems work where the read and write workloads had genuinely different requirements — different scaling profiles, different consistency requirements, different schema needs. The pattern was deliberately a heavy commitment for those systems. For frontend applications, the full CQRS machinery (separate write and read databases, eventual consistency, event sourcing) isn’t necessary. The vocabulary — the discipline of distinguishing commands from events — is.

This chapter adopts the vocabulary directly. Events are facts; commands are requests. The architectural payoff is that causality — the chain of X happened, which caused Y, which caused Z — stays legible. Tracing the application’s behavior becomes possible. Diagnosing failures becomes possible. Adding new consumers becomes possible without changing the producers.

An event says something happened.

profile.saved
checkout.started
form.validation_failed
dialog.closed
user.signed_in
session.expired

Events are immutable. The fact has already occurred. No subscriber can undo it. No subscriber can replace the fact with a different one. The event is a record of state that the application has moved through.

Events are observable. Many modules may subscribe to the same event. The analytics module subscribes to profile.saved. The audit module subscribes to the same event. The notification module subscribes. The observability module subscribes. None of these subscribers needs to know about any other. The event is broadcast; subscribers consume it.

Events have no return value. Subscribing is a fire-and-forget pattern. A subscriber might do something asynchronously in response to the event, but the emitter doesn’t wait for or care about the result. Profile saved is a complete fact; the emitter is done.

In code, an event emission looks like this:

runtime.emit({
type: 'profile.saved',
context: {
surface: 'profile-editor',
feature: 'preferences',
entity: { type: 'profile', id: 'user_123' }
},
payload: { displayName: 'Jeremy' }
})

The event has a type, a context (supplied by boundaries — Chapter 36), and a payload (the values relevant to the fact). Subscribers receive the full structure.

A subscriber registration:

runtime.on('profile.saved', (event) => {
audit.record('profile.saved', {
actor: providers.user.current().id,
target: event.context.entity.id,
timestamp: Date.now()
})
})

The handler runs every time the event fires. It can do work synchronously or asynchronously. The runtime doesn’t depend on its completion or its return value. If the handler throws an error, the runtime logs it through the diagnostics surface (Chapter 44) but doesn’t propagate the error back to the emitter; other handlers continue to run.

Events form the application’s observability surface. Every meaningful action produces an event with a name and context. The full list of events the application emits is, in effect, the application’s analytics schema, audit schema, and observability schema — written once in one place, consumed by many modules.

A command asks for something to happen.

notification.show
dialog.open
draft.save
form.validate
route.navigate
storage.set
session.refresh

Commands are imperative. They name an action the application wants performed. Show this notification. Open this dialog. Save this draft. The command specifies what should happen and trusts the runtime to route it to the right handler.

Commands have a single handler. Only one module is responsible for executing a given command. The runtime routes the command to its registered handler and waits for the result. If no handler is registered for the command type, the runtime returns an error.

Commands can have return values. The handler does work and reports back. Validate this form returns the validation result. Save this draft returns success or failure. Show this notification returns the notification’s ID (so the caller can dismiss it later). The handler’s return value is the command’s result.

In code, a command dispatch looks like this:

const result = await runtime.command({
type: 'form.validate',
payload: { form: formElement }
})
if (!result.valid) {
// show error UI
}

The handler implementation:

runtime.handleCommand('form.validate', (command) => {
const valid = command.payload.form.checkValidity()
return { valid, errors: collectErrors(command.payload.form) }
})

Only one module registers a handler for form.validate. The runtime ensures that. If two modules try to register handlers for the same command type, the runtime raises an error at startup. The single-handler discipline is what makes commands different from events.

Errors in command handlers propagate back to the caller. If the handler throws, the command’s promise rejects. The caller can catch the error and respond. This is different from events, where handler errors are isolated and logged but don’t propagate.

Commands form the application’s behavior surface. Every imperative action — show a thing, hide a thing, save a thing, navigate somewhere — is a command. The handlers are concentrated in modules. The command surface is documented; the implementation is centralized; the callers don’t need to know which module handles each command.

Chapter 22’s metadata protocol distinguishes events from commands in the DOM:

<!-- Declares an event -->
<kit-button meta-event="profile.saved">Save</kit-button>
<!-- Declares a command -->
<kit-button meta-command="dialog.open" meta-prop-target="help">
Help
</kit-button>

The runtime’s delegated boundary listener (Chapter 36) inspects the activated element’s attributes. If meta-event is present, the listener emits an event into the runtime. If meta-command is present, the listener dispatches a command. The attribute names disambiguate. The protocol is consistent.

A single button can declare both, when appropriate — clicking this button means a fact (the user clicked save) and a request (please save the draft). The pattern is rare but useful in some application flows. More commonly, a button declares one or the other.

The metadata protocol’s central architectural value is that components don’t have to know whether their action is consumed by event subscribers or by a single command handler. The component declares the type. The runtime routes accordingly. The component’s vocabulary stays small (it has a name and some metadata); the runtime’s responsibility (routing to the right consumer) is concentrated and consistent.

The split keeps the application’s causality legible. A user action flows through the runtime as a sequence of events and commands:

  1. User clicks the Save button.
  2. Runtime dispatches the command form.validate (declared on the button or implied by the flow).
  3. The form-validation module runs and returns { valid: true }.
  4. Runtime dispatches the command profile.save with the form data.
  5. The profile-save module makes the API call and returns { saved: true }.
  6. Runtime emits the event profile.saved with the saved profile data.
  7. The analytics module observes the event and tracks it.
  8. The audit module observes the event and records it.
  9. The draft-cleanup module observes the event and clears the user’s draft.
  10. The notification module observes the event and shows a success toast.

The flow has a clear shape. Commands request specific work; their handlers execute it. Events announce facts; their subscribers observe them. The order of events and commands is the application’s causality. A diagnostic trace of the flow (Chapter 44) shows exactly what happened, in what order, with what results.

If something goes wrong — the API call fails, the validation fails, the notification module throws — the trace shows where. The error path through commands is direct (the command’s promise rejects, the caller decides what to do). The error path through events is observable (handlers log their errors but don’t propagate; the trace shows which handlers succeeded and which failed).

Compare this to the alternative — the inline-everything style from Chapter 35’s overloaded SaveButton. The flow there is implicit, sequential, and hidden inside the click handler. If something goes wrong, the developer has to read through the handler to figure out what step failed. Adding a new step means editing the handler. Removing a step means editing the handler. The flow has no shape; it’s just a sequence of imperative calls.

The events-and-commands architecture makes the flow visible. The flow becomes a thing the team can inspect, trace, document, and reason about. The application’s causality becomes a first-class artifact.

A specific design choice is worth defending directly.

Events have many observers. Commands have one handler. The asymmetry is intentional, and it’s important.

If commands had many handlers, the application would lose control of what should happen. Multiple modules might compete to save the draft, with no clear winner. Multiple modules might try to handle the navigation, with conflicting results. The architecture would have to add coordination logic — order of handlers, conflict resolution, priority — that doesn’t have an obvious right answer. The CQRS literature has decades of evidence that the single-handler-per-command discipline is the right default.

If events had a single observer, the architecture would lose the open composition Chapter 35 argued for. Adding a new analytics provider would require unregistering the old one. Adding observability would require unregistering analytics. The architecture would become a chain of swap-and-replace operations rather than a composable set of subscribers. The whole point of the events-as-facts model is that the emitter doesn’t decide who cares; subscribers register themselves.

The asymmetry is the right shape for the problem. Multiple observers, single handler. The runtime enforces both.

A capability the architecture enables, briefly, because Chapter 44 develops it fully.

The runtime can record every event and command that flows through it. The recorded trace shows the application’s behavior over a span of time:

[t=0] command dispatched: form.validate
[t=2ms] command handled by form-validation-module: { valid: true }
[t=3ms] command dispatched: profile.save
[t=180ms] command handled by profile-save-module: { saved: true, ... }
[t=181ms] event emitted: profile.saved
[t=182ms] handled by analytics: ok
[t=183ms] handled by audit: ok
[t=185ms] handled by draft-cleanup: ok
[t=186ms] handled by notification: ok
[t=190ms] command dispatched: notification.show
[t=191ms] command handled by notification-module: { id: 'notif_42' }

The trace makes the application’s behavior visible. Performance investigations become possible (which handler took the longest?). Debugging becomes possible (which handler failed?). Documentation becomes possible (the trace is the actual sequence the application went through).

This is the diagnostic-surface advantage of the events-and-commands architecture. The inline-everything alternative produces no trace by default — the click handler runs imperatively, side effects happen in some order, and reconstructing the sequence after the fact requires a debugger or careful logging. The events-and-commands architecture makes the trace the natural artifact of the runtime’s behavior.

The chapter has named the events-and-commands split and its consequences. The next chapter — The Browser-Native Application Loop — puts the pieces from Chapters 34–37 together into a single picture. Components emit events and dispatch commands. Boundaries supply context. The runtime routes. Modules consume. The closed loop is the architecture the rest of the book builds and ships.

The chapters after that (Part IV) build the loop by hand. The chapters after that (Part V) wrap it in components. Part VI ships it as Kitsune. The architecture has, by the end of Part III, a complete name and shape.

Exercise: Build a Tiny Event and Command Bus

Section titled “Exercise: Build a Tiny Event and Command Bus”

Implement, in plain TypeScript (no framework), a small runtime with these methods:

runtime.on(type, handler) // subscribe to events
runtime.emit(event) // fire an event
runtime.handleCommand(type, handler) // register a command handler
runtime.command(command) // dispatch a command, returns Promise

Support:

  • Multiple subscribers per event type.
  • Single handler per command type (with an error if two are registered).
  • Wildcard event subscribers (e.g., runtime.on('*', logEverything) to log every event).
  • An error when dispatching a command with no registered handler.
  • A diagnostic log that captures every event and command, with timestamps and handler results.

Then model a small flow:

  • A form.validate command (handler returns { valid: true }).
  • A profile.save command (handler simulates an async API call and returns the result).
  • A profile.saved event (subscribers: analytics, audit, draft-cleanup, notification, observability).

Dispatch the commands in sequence and watch the events fire. Look at the diagnostic log afterward. The whole flow should be inspectable from the log alone.

Reflect on:

  1. Which steps were facts? Which were requests?
  2. What did the flow’s shape make visible that an inline click-handler version would have hidden?
  3. If you added a sixth event subscriber (a feature-flag-exposure tracker), how much code would you change? (Hopefully: one new subscriber registration, zero changes elsewhere.)
  4. If you wanted to remove the audit module from production but keep it in development, how would you do it? (One conditional subscriber registration, zero other changes.)
  5. What does the trace tell you that you couldn’t easily get any other way?

The exercise is short. The architectural pattern it teaches is the one Part IV builds at proper scale. The whole runtime — events, commands, modules, providers, lifecycle, diagnostics — fits in under a thousand lines of TypeScript, and the small version you’ll build here is structurally identical to the full version.