Skip to content

Chapter 35: From Components to Capabilities

Components changed frontend development because they gave UI a unit of composition.

That was a real architectural advance. Before the component generation, frontend code was scattered scripts, page-scoped logic, and shared utilities that didn’t compose cleanly. After components, UIs could be built out of reusable, named, testable pieces with defined inputs and outputs. The shift turned out to be so productive that frontend application and component tree have become roughly synonymous in most teams’ working vocabulary.

But modern applications need another kind of composition that the component model doesn’t naturally provide.

They need capability composition. A capability is a modular unit of product behavior that cuts across UI — analytics, observability, audit logging, storage, permissions, validation, notifications, feature exposure, experiments, design-system enforcement, and a long tail of others. These aren’t naturally owned by a single button, field, card, or page. They want to participate in many components at once, often in ways the components don’t need to know about.

When applications lack a capability architecture, these concerns leak into components. The component becomes an integration hub for half the application’s cross-cutting concerns. Chapter 13 named this the component trap. The trap exists because the component model doesn’t have a clean answer for capabilities, so capabilities default to living inside the components.

This chapter is about what a capability architecture looks like and why having one is the most consequential architectural move the missing layer makes.

A component that begins as UI tends to become an integration hub:

function SaveButton({ profileId }: { profileId: string }) {
const analytics = useAnalytics()
const storage = useDraftStorage()
const audit = useAudit()
const notify = useNotifications()
const permissions = usePermissions()
const observability = useObservability()
async function save() {
if (!permissions.can('profile.edit', profileId)) {
notify.error("You don't have permission to edit this profile")
return
}
observability.startSpan('profile.save')
analytics.track('Profile Saved', { profileId })
try {
await api.save(profileId)
await storage.clearDraft(profileId)
audit.record('profile.saved', { profileId, actor: currentUser.id })
notify.success('Profile saved')
} catch (error) {
observability.captureError(error)
notify.error('Could not save profile')
} finally {
observability.endSpan('profile.save')
}
}
return <button onClick={save}>Save</button>
}

This is recognizable. It’s not even an unusually complex example — every line is a thing the application reasonably wants to do when the user clicks Save. The button has accumulated knowledge of analytics, draft storage, audit logging, notifications, permission checks, observability spans, and error capture. It imports six hooks. It coordinates two side effects in parallel and one in sequence. If a seventh cross-cutting concern shows up next quarter (privacy event redaction, feature-flag exposure tracking, accessibility announcement), the button gets longer.

The architectural problem isn’t that the button does work. The work is necessary. The problem is that the button knows too much. The button knows analytics exists. It knows draft storage exists. It knows audit logging exists. It knows notification policy. It knows the specific event names. It knows the order in which the side effects should happen. It knows what should happen on error.

Multiply this by every interactive element in the application, and a substantial fraction of the codebase becomes integration code, written component-by-component, with all the duplication and drift that implies. Adding a new analytics provider means editing every component that calls analytics.track. Changing the audit format means editing every audit.record. Adding a new privacy policy means editing every place that decides what to send to analytics. The cross-cutting concerns are cross-cutting in name only; in practice they’re cross-duplicated across the component tree.

This pattern has a name in older architectural literature. Cross-cutting concerns are problems that aren’t naturally located in any one component but need to participate in many components at once.

The aspect-oriented programming (AOP) community studied this extensively in the 2000s. Logging, security, transaction management, caching — all of these are cross-cutting concerns in enterprise applications. The AOP answer was to declare these concerns separately from the components that needed them, and to weave them in at runtime (or compile time) through aspects that captured join points (places where the cross-cutting concern should be applied).

AOP itself didn’t survive as a dominant pattern outside Java’s Spring framework, but the problem it was naming is real and persistent. Modern frontends have the same problem in a different form. The component is the join point. The cross-cutting concerns want to participate there. The pattern that emerges — capability modules observing events emitted by components — is essentially AOP, with the DOM event system as the runtime weaving mechanism.

Anyone from a backend background will recognize this. Middleware in Express. Filters in ASP.NET. Interceptors in Spring. Decorators in Python frameworks. These are all the same architectural pattern: cross-cutting behavior factored out of the request handler and applied through a shared infrastructure. The frontend version has been slower to develop because the field’s primary substrate (the component tree) isn’t a natural fit for the pattern.

Components Describe Meaning, Modules Handle Consequences

Section titled “Components Describe Meaning, Modules Handle Consequences”

The architectural shift the missing layer makes is to split intent from consequence.

A component describes intent. This button represents a profile-saved action. That’s all it does. It doesn’t know about analytics. It doesn’t know about audit. It doesn’t know about notifications. It declares the meaning of the action and gets out of the way.

<kit-button
meta-event="profile.saved"
meta-intent="primary-action"
meta-entity-type="profile"
meta-entity-id="me"
>
Save
</kit-button>

The button’s job ends there. The meta-event attribute (Chapter 22) declares that activating the button represents the application-level event profile.saved. The other attributes supply enriching context. The button doesn’t import any of the modules that care about this event. It doesn’t know they exist.

The capability modules handle consequences. Each module observes the events it cares about:

defineKitModule({
name: 'analytics',
events: {
'profile.saved': (event) => {
track('Profile Saved', {
profileId: event.context.entity.id,
surface: event.context.surface,
feature: event.context.feature
})
}
}
})
defineKitModule({
name: 'audit',
events: {
'profile.saved': (event) => {
record('profile.saved', {
profileId: event.context.entity.id,
actor: providers.user.current().id,
timestamp: Date.now()
})
}
}
})
defineKitModule({
name: 'draft-cleanup',
events: {
'profile.saved': async (event) => {
await providers.storage.clearDraft(event.context.entity.id)
}
}
})

Each module is a small piece of code that does one thing well. Each module names which events it cares about. When the button is activated, the event fires, the runtime enriches it with boundary context (Chapter 21, Chapter 36), and the modules subscribed to the event run their handlers. The button’s click handler is now zero lines of application-specific code. The button is a button.

The architectural lift is real. Adding a new analytics provider is one new module. Removing the audit log is removing one module. Changing the notification policy is editing one module. The cross-cutting concerns are now actually cross-cutting — they live in one place each, and they reach into the application through a shared event substrate.

The composition pattern is worth showing explicitly.

A single user action — clicking the Save button — produces a single event. The event reaches the runtime. The runtime distributes it to every subscribed module. Multiple modules respond, each doing its own work, without the source knowing.

The flow, traced step by step:

  1. User clicks the <kit-button>.
  2. The button’s native click fires (the platform handles this).
  3. A delegated listener on the surrounding <kit-boundary> observes the click, reads the button’s meta-* attributes, walks up the DOM to collect boundary context (surface, feature, entity), and constructs a meta:event CustomEvent.
  4. The runtime dispatches the event to subscribed handlers.
  5. The analytics module receives the event and sends an event to the analytics service.
  6. The audit module receives the same event and records an audit entry.
  7. The draft-cleanup module receives the same event and clears the user’s draft from storage.
  8. The notification module receives the same event and shows a Profile saved toast.
  9. The observability module receives the same event and adds a breadcrumb to the trace.

Six modules respond to one event. None of them is imported into the button. The button doesn’t know any of them exist. The composition is runtime composition — modules are installed into the runtime at application startup, and the runtime routes events between them. The composition is also open — adding a seventh module that responds to the same event doesn’t require changing anything else.

For anyone familiar with the publish-subscribe pattern from backend message buses, this should look familiar. The DOM is the substrate; events are the messages; modules are the subscribers; the runtime is the dispatcher. The architectural shape is the same as Kafka topics or AWS EventBridge or any of the dozens of event-bus systems in backend deployments — just in-browser, scoped to a single application, with the platform’s existing event machinery as the foundation.

The capability architecture pays off across several dimensions.

Substitutability. The analytics provider can be swapped without touching components. Move from one analytics vendor to another. Add a second analytics provider in parallel. Disable analytics entirely in tests. Each of these is a one-module change.

Testability. The Save button can be tested as a button — does it render correctly, does it fire the right event with the right context. The analytics module can be tested as an analytics module — does it correctly translate the event into a tracking call. The audit module can be tested as an audit module. None of the tests need to coordinate the others.

Auditability. The events the application produces are a documented vocabulary. A team can list every meta-event in the codebase and understand what the application’s analytics surface, audit surface, and notification surface look like. The same vocabulary feeds the runtime, the tests, and the documentation.

Privacy enforcement. The privacy redaction policy lives in one place — the analytics module, or a privacy-policy module that intercepts events before they reach analytics. Components don’t have to know about privacy. The button declares the event; the redaction happens in the module that handles consumption.

AI compatibility. AI-generated components can decorate their interactive elements with meta-event attributes following the established vocabulary. The capability modules — written and reviewed by humans — handle the consumption. The AI’s output is constrained to declaration; the consequences are managed by the durable, audited capability layer.

Resilience to renderer change. The capability modules don’t depend on which renderer produced the button. The same modules work whether the button is server-rendered HTML, a Lit-authored custom element, or React-rendered JSX. Migrating from React to Lit doesn’t require rewriting the capability layer; the events still fire from the same DOM, just from a different renderer.

Application explainability. The runtime can produce a diagnostic trace of every event that fired, which modules observed it, how long each handler took. The application becomes inspectable in a way the component-tree-everywhere architecture isn’t. Chapter 44 (Building Diagnostics) develops this further.

The dimensions add up. Capabilities-as-modules is, the chapter argues, the highest-leverage architectural move the missing layer makes. The other principles in Part III — boundaries, events/commands, the closed loop — are how the capability layer works in detail. The capability/component split is the central organizing decision.

The chapter should be clear about what doesn’t move out of the component.

Local UI state stays in the component. Whether the menu is open, whether the input is focused, whether a tooltip is showing — these are the component’s own state and they belong there.

Rendering decisions stay in the component. What the markup looks like, which child components are rendered, what props they receive — these are rendering concerns and the component is the right home for them.

Input handling stays in the component (and on the platform). A button’s click handling, a form’s submit handling, an input’s value handling — these are the component’s local responsibility, supported by the platform’s native event model.

Style stays with the component, ideally through scoped CSS or Shadow DOM.

Accessibility semantics stay with the component. The component is responsible for being a real button, a real form, a real link, with the platform’s native semantics preserved or properly augmented through ARIA.

What moves out of the component is the application-level cross-cutting work. Analytics. Audit. Notifications. Permissions. Storage. Observability. Feature flags. These were never properly the component’s job; they ended up there because the component was the only home available. The capability layer gives them a proper home.

The chapter has been describing capabilities as modules that observe events. The events come from somewhere, and they carry context from somewhere. The next chapter — Boundaries as Application Geography — names where the context comes from.

The short version: events emitted from components don’t carry enough information to be useful on their own. profile.saved without further context could mean a user saving their own profile, an admin saving someone else’s, a system-triggered save, a draft being committed. The context that disambiguates these — surface (where the user is), feature (which capability), entity (what’s being acted on), mode (what the user’s relationship to the entity is) — comes from boundaries in the DOM. A boundary is a semantic region that supplies context to events fired inside it.

Capabilities consume events. Boundaries supply context. The two pieces work together. Chapter 36 develops the boundary side.

Take a component you’ve written that handles several cross-cutting concerns inline. The Save button at the top of this chapter is a reasonable starting point. Pick something similar from your own codebase.

Rewrite the component so it does one thing — declares the event it represents through markup or dispatchEvent. Strip out the analytics call, the storage call, the audit call, the notification call, the observability call, the permission check.

Now create conceptual modules that handle each concern. You don’t need a runtime yet (Part IV builds one). Just sketch what each module looks like as a function that observes events:

const analyticsModule = {
name: 'analytics',
observes: ['profile.saved'],
handle: (event) => track('Profile Saved', { ... })
}
const auditModule = {
name: 'audit',
observes: ['profile.saved'],
handle: (event) => recordAudit(event)
}
// ...

Then answer:

  1. What code stayed in the component? What moved to modules?
  2. Which capabilities are observing events (they want to know that something happened)?
  3. Which are handling commands (they want to be told to do something specific)?
  4. If you needed to add a new capability tomorrow — say, a feature-flag exposure tracker — how much code would change? In the component-only version vs. the component-plus-modules version?
  5. If you needed to remove a capability (e.g., drop the analytics provider), how much code would change in each version?

The point is to feel the architectural difference. The component-only version is shorter to write the first time. The component-plus-modules version is shorter to maintain over time, because each cross-cutting concern lives in one place that can be changed independently of everything else.