From explicit page composition to a registry of slice-owned components.
We have worked through the business process together. The Event Model shows what the user sees, which commands they can issue, which events record the outcome, and which views provide the information for the next step.
Now we want to build it.
For a state-change slice, the path into code is reasonably clear: implement the command, enforce its rules, and record the resulting events. For a state-view slice, build the projection and expose its query.
Then we reach the browser...
Three slices contribute to the same screen. One displays a summary. Another displays a history. A third lets the user change something. Who puts those components together? Who owns the URL? And how does the summary update when the user submits the form?
These questions matter if we want to keep adding slices without repeatedly changing the ones we have already built.
In this article, I want to work through a concrete approach. We will start with an explicitly composed page, where every connection is visible in code. Then we will move the repetitive registration into slice-owned contributions and let a small registry assemble them.
The Event Model describes the business solution. The composition mechanisms below are engineering choices for implementing it. They are a proposal you can adopt and adapt, not additional rules of EventModeling.
One screen, several slices
Consider an insurance application with a Policy Details page at /policies/P-123.
The user can inspect the policy, review payments, and request cancellation.
Three slices contribute to this page:
- View policy summary queries its read model and displays the policyholder, coverage, and status.
- View payment history queries its read model and displays the list of payments.
- Cancel policy provides the cancellation form, submits the command, and explains its outcome.
The summary projection consumes events such as PolicyIssued and PolicyCancelled. The payment projection consumes PaymentReceived. The cancellation command handler validates the request and, when successful, records PolicyCancelled.
These are illustrative business contracts. A real cancellation process might involve an effective date, approval, or a cooling-off rule. Those decisions belong in the Event Model before we implement the form.
A screen can contain UI contributions from several slices. That does not make the whole screen one slice, nor does it make each HTML element a separate slice. We keep the boundaries around the modeled behavior.
An automation usually contributes no interactive component. If users need to see its progress, that information belongs in a state view.
Give composition an owner
I separate three responsibilities:
- The application shell owns navigation, route matching, the page lifecycle, and the application layout.
- The page composition defines the regions, places components within them, and passes context into those components.
- Each slice component owns its query or command interaction, local state, rendering, and feedback.
The page knows that a summary and a cancellation form belong together. The summary does not need to know that the cancellation form exists.
Both receive a policyId. Each obtains the information it needs through its own contract. I would avoid passing a mutable currentPolicy object around and gradually turning it into a shared business store.
This requires some precision when we say slices are coupled only through business events. Business dependencies flow through those events. UI components also use technical contracts: identifiers, navigation, mounting, authentication facilities, and shared visual primitives. Those contracts must stay small and explicit.
A page that arranges components is expected. A page that decides whether a policy can be cancelled has acquired a business responsibility that belongs elsewhere.
Start with explicit composition
For this example, I use vanilla JavaScript and Web Components. The same ownership boundaries work with other component frameworks.
The code illustrates the composition contracts. It is not a complete application: API endpoints, authentication, backend handlers, and a production router are outside its scope.
Place the UI beside the slice implementation. For example, slices/view-policy-summary/ contains policy-summary.js and query.js; slices/cancel-policy/ contains its form and command adapter. Pages live under pages/, and reusable browser infrastructure under shell/.
Here is a minimal summary component:
// slices/view-policy-summary/policy-summary.js
import { watchPolicySummary } from "./query.js";
class PolicySummary extends HTMLElement {
connectedCallback() {
this.controller?.abort();
const controller = new AbortController();
this.controller = controller;
this.textContent = "Loading policy...";
watchPolicySummary(this.getAttribute("policy-id"), {
signal: controller.signal,
onValue: summary => {
if (controller.signal.aborted) return;
if (!summary) {
this.textContent = "Policy not found.";
return;
}
const name = document.createElement("h2");
name.textContent = summary.policyholder;
const status = document.createElement("p");
status.textContent = `Status: ${summary.status}`;
this.replaceChildren(name, status);
},
onError: () => {
if (!controller.signal.aborted) {
this.textContent = "Policy summary unavailable.";
}
}
});
}
disconnectedCallback() {
this.controller?.abort();
}
}
customElements.define("policy-summary", PolicySummary);
watchPolicySummary is a slice-owned query adapter. Its contract is to deliver the current view, deliver later updates, report errors, and stop when aborted. The component does not choose between polling, server-sent events, or another transport.
For an initial implementation, sequential polling is a reasonable way to fulfill that contract: fetch the projection, deliver it, wait, and repeat. Stop both the request and the timer on abort. Avoid overlapping requests that can render old results after newer ones. If you use a snapshot followed by a subscription instead, account for updates that occur during the handover.
Notice the cleanup. When the user leaves the page, the component must stop its work. Otherwise an old subscription can keep running after its UI has disappeared.
This example mounts fresh components when the route context changes. If you reuse an existing element and change its policy-id, implement attribute-change handling as well.
The other components follow the same boundary: payment history owns its query; cancellation owns its form and command adapter.
Now compose them:
// pages/policy-details.js
import "../slices/view-policy-summary/policy-summary.js";
import "../slices/view-payment-history/payment-history.js";
import "../slices/cancel-policy/cancel-policy.js";
export function createPolicyDetailsPage({ policyId }) {
const page = document.createElement("section");
page.innerHTML = `
<h1>Policy details</h1>
<div class="details-layout">
<section data-region="main" aria-label="Policy information">
</section>
<aside data-region="actions" aria-label="Policy actions">
</aside>
</div>
`;
function place(tag, region) {
const component = document.createElement(tag);
component.setAttribute("policy-id", policyId);
page.querySelector(`[data-region="${region}"]`)
.append(component);
}
place("policy-summary", "main");
place("payment-history", "main");
place("cancel-policy", "actions");
return page;
}
The HTML string contains only fixed markup. User data is assigned through attributes or text, not interpolated into HTML.
The page owns the layout. A CSS grid can put main and actions beside each other on a wide screen and stack them on a narrow one. Components should fit their containers rather than assume they occupy the whole application.
I create this page even when the first implementation contains only the summary. That gives the next slice somewhere to go without changing how the application mounts pages.
Make navigation a separate contract
The route table connects the URL to the page:
// composition/routes.js
import { createPolicyDetailsPage }
from "../pages/policy-details.js";
export const routes = [{
path: "/policies/:policyId",
createPage: createPolicyDetailsPage
}];
Our shell matches the route, decodes policyId, creates the page, and replaces the previous page in its outlet. Removing the old elements triggers their cleanup.
path and createPage are our illustrative router interface. Use your router's equivalent. It must handle direct entry, browser back and forward, unknown routes, and disposal. Page navigation should also update the document title and put keyboard focus in an appropriate place.
The server needs to serve the application entry point for /policies/P-123, while keeping API and asset routes intact. A page that works only after clicking through the home screen is unfinished.
A policy list can navigate with an ordinary link:
link.href = `/policies/${encodeURIComponent(policyId)}`;
link.textContent = "Open policy";
The shell may intercept ordinary same-origin clicks for client-side navigation. Modified clicks, opening another tab, and external links should retain their normal behavior.
The list depends on a URL contract. It does not import the destination component.
Opening a page is a navigation intent. It should not be recorded as PolicyOpened in the business event stream merely to make the router work. If viewing a policy has a genuine audit requirement, model that requirement separately.
What happens after cancellation?
This is where an apparently independent UI can quietly become coupled.
It is tempting for the cancellation form to find the summary element and call refresh(). Or for the page to listen for a form callback and update a shared policy object.
That creates a dependency on another slice's behavior.
Instead, the cancellation form submits CancelPolicy through its own adapter. The backend validates it and commits PolicyCancelled. The summary projection consumes that event. The summary component receives the new query result through its own adapter.
The browser does not invent a successful business event after a button click. The persisted outcome is authoritative.
There are two moments the UI needs to distinguish:
- When the backend confirms that the cancellation event was committed, the UI can say that the cancellation was recorded.
- When the summary projection includes that event, the UI can say that the displayed summary reflects the cancellation.
If an API only acknowledges that a command was queued, even the first statement is premature. The response contract must make that distinction clear.
With asynchronous projections, the form can show a confirmed outcome while the summary briefly still shows the old status. Decide what that experience should be. Do not leave it to timing accidents.
For stricter read-your-writes behavior, define a consistency token: a committed event position, for example, that the relevant query can compare with its projection checkpoint. A bounded wait can then distinguish “updated” from “still catching up.” The positions must belong to a compatible ordering scheme; arbitrary timestamps are not a substitute.
A notification that a projection changed can also trigger a query refresh. It must be emitted after the projection commit and scoped to the relevant view and context. Keep business rules out of the notification mechanism.
Finally, handle uncertain submissions. A network failure may occur after the backend commits. Retrying that same attempt should reuse its command ID and payload, backed by server-side idempotency. A fresh ID on every retry can turn one user intention into multiple commands.
None of this belongs in the page layout.
Add one more slice
Suppose the next requirement is to show policy documents.
We implement view-policy-documents, including its component and query. Then we add an import and a placement call to the page:
import "../slices/view-policy-documents/policy-documents.js";
// Inside createPolicyDetailsPage:
place("policy-documents", "main");
The existing summary, payment history, and cancellation slices remain untouched.
This is already additive at the slice level. The composition changed because the screen changed.
There is a useful distinction here: avoiding changes to existing slices is different from avoiding every edit to an existing file. Explicit composition achieves the former. If many teams or agents keep editing the same composition file, we may also want the latter for routine additions.
That gives us a concrete reason to introduce a registry.
Move registration into the slice
The registry will answer a small question: which UI contributions belong on this page, and in which region?
It will not load business data, decide command eligibility, or coordinate business workflows.
First, give each slice a contribution descriptor:
// slices/view-policy-summary/ui.contribution.js
export default {
id: "view-policy-summary",
page: "policy-details",
region: "main",
order: 10,
tag: "policy-summary",
inputs: { "policy-id": "policyId" },
load: () => import("./policy-summary.js")
};
The cancellation slice contributes another descriptor:
// slices/cancel-policy/ui.contribution.js
export default {
id: "cancel-policy",
page: "policy-details",
region: "actions",
order: 10,
tag: "cancel-policy",
inputs: { "policy-id": "policyId" },
load: () => import("./cancel-policy.js")
};
Payment history follows the same shape with region: "main" and order: 20.
The inputs mapping is deliberately narrow: take policyId from the page context and assign it to the element's policy-id attribute. This version handles required string identifiers. It is not a serialization scheme for business objects.
These descriptors contain presentation metadata. They stay outside domain handlers and projections.
The page still owns its regions and their layout. A descriptor selects an established region; it cannot invent arbitrary DOM locations or reach inside another component.
For a component used on several screens, export several descriptors, each with a unique ID. Keep the reusable component independent of those placements.
Discover contributions at build time
A handwritten central array would still require a central edit for every addition. We can remove that particular bottleneck with build-time discovery.
Here is a Vite-specific example:
// composition/contributions.js
const modules = import.meta.glob(
"../slices/*/ui.contribution.js",
{ eager: true, import: "default" }
);
export const contributions = Object.values(modules).flat();
import.meta.glob is a Vite feature, not standard JavaScript. It discovers matching modules as part of the build. The descriptors load eagerly; their component implementations remain behind the load() functions. With another build system, generate the import list in a build step instead. [2]
This is one application deployment. We have not introduced a runtime plugin server or independent frontend deployments.
Keep the glob aligned with your folder convention. This example finds one directory level under slices; a more deeply nested slice structure needs a corresponding pattern.
Keep the page definition explicit
The page remains a named composition with a route, context, and layout:
// pages/policy-details.definition.js
export default {
id: "policy-details",
path: "/policies/:policyId",
contextKeys: ["policyId"],
regions: ["main", "actions"],
createLayout() {
const page = document.createElement("section");
page.innerHTML = `
<h1>Policy details</h1>
<div class="details-layout">
<section data-region="main" aria-label="Policy information">
</section>
<aside data-region="actions" aria-label="Policy actions">
</aside>
</div>
`;
return page;
}
};
Use a stable page ID. Renaming “Policy details” in a heading should not disconnect its contributions.
Before the application starts, validate the descriptors against page definitions. Reject duplicate contribution IDs, unknown page or region IDs, unsupported context keys, invalid custom-element names, missing loaders, and non-finite order values. Also validate that each declared region exists exactly once in its layout. These are configuration errors, so report the contribution ID and source file.
Keep ordering deterministic. Sort by order, then by contribution ID to break ties. File-discovery order should never decide the user experience.
Mount through one generic host
We can now replace the explicit placement calls with a host. It creates a container for each contribution, loads the component, passes context, and handles loading failures.
// shell/compose-page.js
export function composePage(definition, context, contributions) {
const element = definition.createLayout();
const lifetime = new AbortController();
const selected = contributions
.filter(c => c.page === definition.id)
.sort((a, b) => a.order - b.order ||
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
for (const c of selected) {
const region = element.querySelector(
`[data-region="${c.region}"]`
);
const host = document.createElement("div");
host.dataset.contribution = c.id;
host.textContent = "Loading...";
region.append(host);
void mount(c, host);
}
async function mount(c, host) {
try {
await c.load();
if (lifetime.signal.aborted) return;
if (!customElements.get(c.tag)) {
throw new Error(`Component not registered: ${c.tag}`);
}
const component = document.createElement(c.tag);
for (const [attribute, key] of Object.entries(c.inputs)) {
const value = context[key];
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Missing context: ${key}`);
}
component.setAttribute(attribute, value);
}
host.replaceChildren(component);
} catch (error) {
if (lifetime.signal.aborted) return;
console.error(`Failed to mount ${c.id}`, error);
host.textContent = "This section is unavailable.";
}
}
return {
element,
dispose() {
lifetime.abort();
element.remove();
}
};
}
This function assumes the configuration validation described above has already succeeded. It creates the containers synchronously, so a fast-loading component cannot jump ahead of a slow one.
Its lifecycle contract differs slightly from our first page factory: the shell now receives { element, dispose }. On navigation, it calls the current page's dispose(), then mounts the next page's element. Make that infrastructure change once when adopting the host.
Aborting the lifetime prevents a late module load from mounting after navigation. Removing the page disconnects its components, which must clean up their own listeners, timers, and queries.
The catch block isolates module-loading and host setup failures. It is not an error boundary for every exception inside a Web Component. Each slice still handles its own rendering and asynchronous failures.
The route factory now calls composePage(policyDetails, params, contributions). Routing mechanics stay in the shell; page-specific layout stays in the definition.
Add the documents slice again
With discovery in place, the documents slice supplies its own descriptor:
// slices/view-policy-documents/ui.contribution.js
export default {
id: "view-policy-documents",
page: "policy-details",
region: "main",
order: 30,
tag: "policy-documents",
inputs: { "policy-id": "policyId" },
load: () => import("./policy-documents.js")
};
On the next build, discovery includes it. The host places it after payment history and gives it the same policy identifier.
No existing slice changes. No central contribution list changes. The existing page definition also remains unchanged because this addition fits its established regions and context.
That last condition matters. A new screen, a new layout, or a new context requirement still needs a composition decision. A registry cannot make those decisions disappear. New pages can eventually use build-time discovery too, but that is a separate extension.
For an existing application, I would migrate one page first. Once the host mounts its contributions, remove the old imports and placement calls for that page. Two mechanisms mounting the same component will produce duplicate forms, subscriptions, and requests.
Keep the design visible in the Event Model
The Event Model already gives us the user's screens and the information flowing through them. Add an explicit mapping from those screens to the implementation:
For the screen policy-details, the mapping is:
- The main region contains view-policy-summary, view-payment-history, and view-policy-documents, in that order.
- The actions region contains cancel-policy.
This mapping makes composition reviewable with the people who understand the workflow. Does the user have the information needed before cancelling? Is the action placed where they expect it? What do they see while the system catches up?
The mapping could generate descriptors. If it does, choose a source of truth and regenerate consistently. Avoid maintaining the model, a separate composition document, and handwritten descriptors independently without checking for drift.
EventModeling's foundational description connects command-based UIs with composability. [1] The registry here is an implementation choice that supports our goal of additive slices. It is not required to use the method.
Test the seams users experience
A component rendering in isolation is useful evidence, but it does not prove that the application works.
I would exercise these scenarios:
- Open /policies/P-123 directly, refresh it, then navigate back and forward.
- Navigate from one policy to another while a query is pending. The old result must not appear on the new page.
- Cancel a policy with a deliberately delayed projection. Feedback must distinguish the committed outcome from the updating summary.
- Fail one contribution's module load. Other contributions should remain usable.
- Leave and reopen the page. Subscriptions and event listeners must not accumulate.
- Add the documents contribution. It should appear without edits to existing slices or the host.
- Introduce an invalid descriptor. Validation should identify the configuration error before a user encounters it.
Backend authorization remains necessary for every query and command. Hiding a contribution is a presentation decision, not access control.
These scenarios also make the integration work visible. A slice is complete when a user can reach it, use it, and understand its outcome in the intended page.
Where I would start
For the first page, I would write the explicit composition. It exposes the boundaries and gives the team a small implementation to discuss. We can see whether identifiers are sufficient, whether regions make sense, and whether each slice actually owns its behavior.
Once the repeated wiring is clear, I would extract the contribution contract and introduce build-time discovery. The existing components should need little or no change because the registry replaces their assembly mechanism.
That is also a useful constraint for AI-assisted implementation. An agent can work within a slice, implement its query or command UI, and add its contribution descriptor. The page contract limits where it belongs. The Event Model and its scenarios remain the reference for what the behavior must mean.
The practical test comes with the next slice. Can we add it, place it on the intended screen, and verify the user's journey without opening the internals of the slices already there?
That is the property I want this architecture to preserve.
References
- Adam Dymitruk, Event Modeling: What is it?. Foundational explanation of commands, views, wireframes, and composability; it does not prescribe the registry in this article.
- Vite, Glob import. Documentation for the build-time discovery syntax used here.
Originally published on LinkedIn on 17 Oct 2026: https://www.linkedin.com/pulse/building-ui-from-event-model-gabriel-n-schenker-ggmve/