rgoussu@goussu: ~/library/frontend
~/library/frontend cat hexagonal-reference-implementation.md

Hexagonal architecture reference implementation — Frontend (web components)

# The house hexagonal reference realized for the browser — an npm workspace of per-context module packages, custom elements as the driving adapters, every browser API behind a driven port, the WCCG Context protocol wiring ports from each app shell's assembly point, and an import map deduplicating element-defining packages across micro-frontend bundles.

Conceptsaved 2026-08-09updated 2026-08-14 #hexagonal-architecture#architecture#frontend#web-components#typescript#modulith#micro-frontends#reference-implementation

Overview

The house reference implementation of hexagonal architecture for the frontend, standing on its own terms. The shared principles live in that note — trisection, one deployment unit per delivery typology, a contract face and an implementation face for the domain, containerisation beside its unit, README.md/AGENTS.md at every level. The frontend realization takes the browser and the DOM API as the framework: the UI is built exclusively from web components (custom elements + Shadow DOM + <template>), the domain is pure TypeScript that never touches the DOM, and every browser capability — fetch, storage, sockets, the clock — sits behind a driven port. JavaScript has no compiler-enforced module walls, so the npm workspace package graph, TypeScript project references and exports maps hold the architecture. Derived 2026-08-09; the wiring stance was settled the same day (the Context protocol). Adopting the modulith layout on 2026-08-14 resolved most of what this note previously left open: a bounded context is a cluster of workspace packages, and a micro-frontend is a carved-out module — same procedure as every other reference. Verified in a browser (Chromium via Playwright) and against tsc/Node 22 on 2026-08-14: the Context protocol's subtree scoping holds exactly as claimed, the last open question — cross-bundle dependency deduplication — is now closed with a ruling, and the enforcement claims for the package graph turned out to be substantially weaker than written.

Key points

  • The hexagon inverts the backend intuition: the UI is the driving side, the backend is a driven one. A frontend's domain is client-side application logic — commands, workflows, validation, view-independent state. Components drive it through driving ports; the server is infrastructure it reaches through a gateway port, exactly like a database seen from a backend hexagon.
  • One npm workspace per frontend service; one application/<app>/ per deployment unit. Each app shell (public-app, backoffice-app) is an index.html + entry main.ts, bundled to static files and shipped in its own container (nginx/caddy) with the Dockerfile beside it. The shell is the assembly: it decides which modules it mounts, wires their ports, and owns the History-API router. Individual custom elements are adapter parts, as handlers are to a REST adapter.
  • A bounded context is one workspace package at modules/<context>/, named @acme/<ctx> (revised 2026-08-14 — this note previously specified a cluster of four or five packages per context). Its layers are ordinary directories inside src/domain/{contract,core}, user-side/{elements,service}, infra/* — and its package.json exports map publishes exactly three entry points: "." (the facade), "./service" (the peer seam) and "./elements" (the element registrations). A cluster bought four manifests and no additional enforcement, because the package graph checks nothing (see the next bullet); the exports map is the whole wall, and one package per context keeps all of it. This is Go's facade rule in TypeScript. design-system stays a separate top-level package — domain-blind, consumed by every context, and the package the import map deduplicates.
  • The package graph enforces nothing; the linter is the wall (corrected 2026-08-14). The previous claim — that workspace dependencies plus TypeScript project references make a cross-context import a build failure — is false, and was tested three ways. An undeclared dependency on a sibling workspace package typechecks and runs, because npm symlinks every member into the root node_modules. Project references do not restrict which projects a project may import: the same undeclared import builds clean under tsc -b --force. And a relative path (../../modules/catalog/domain/core/src/ internal/x.ts) walks straight through the exports map that would have blocked the package-specifier form. dependency-cruiser is not a belt-and-braces addition here — it is the only thing holding the inter-context and layering rules, and it must be treated as load-bearing infrastructure.
  • A dependency-cruiser that isn't configured for the workspace fails open. With default options, pointed at source directories, every @acme/* import resolves to the bare specifier as dependencyTypes: ['unknown'] and every architecture rule passes green over a tree in violation. It needs enhancedResolveOptions: { extensions: ['.ts', …], exportsFields: ['exports'], conditionNames: ['import', 'default', 'types'] } before it resolves workspace packages back to real source paths. A lint that silently passes is worse than no lint; verify it fires by committing a deliberate violation once.
  • What the packages do buy: the exports map (a real resolver-level wall against deep package-specifier imports, enforced by tsc and Node alike), the bundler's route-level code-split boundary, and an extraction that rewrites zero imports because every cross-context import is already a package specifier. Those are the reasons to keep per-module packages in the browser — not enforcement.
  • The domain of a context is two packages. <ctx>-domain-contract holds port interfaces (driving and driven), command/result types and domain events — depending only on @acme/platform-kernel; <ctx>-domain-core holds entities, services and stores, and its package.json exports map exposes only factory entry points returning driving-port interfaces. Deep imports fail at module resolution — the frontend's nested internal/, held by the resolver and the bundler rather than a compiler.
  • The domain compiles without the DOM. The domain packages set "lib": ["ES2022"] — no "DOM" — in their tsconfig, so document, fetch, HTMLElement and friends are unresolvable identifiers inside the hexagon. "The domain never touches the browser" is a tsc error, not a convention.
  • Custom elements are the driving adapters: they translate DOM events into commands on driving ports, subscribe to read models for state, and render by cloning <template>s and patching only what changed — no virtual DOM, no render framework. Shadow DOM walls the styles; domain events cross the contract face as plain objects, and only the application layer re-emits them as DOM CustomEvents when elements need to talk outward.
  • Secondary adapters wrap browser capabilities and belong to the context that owns their port, granularity ad-hoc as in the other references: <ctx>-infra-gateway-rest (fetch against the backend), -infra-storage-indexeddb, -infra-push-websocket. Ubiquitous ports (clock, id generation via crypto.randomUUID) sit outside every context in @acme/platform-commons. The shell's import graph decides what ships — the bundler tree-shakes from main.ts, so a module the shell doesn't mount costs its bundle nothing. Per-module packages are what make that true: they are also the natural route-level code-split boundary.
  • Contexts meet at <consumer>-infra-<peer>-gateway, a package implementing a driven port the consumer declared, over the peer's -user-side-service factory. It is the only package whose dependencies name two contexts, and dependency-cruiser enforces that.
  • The assembly point is each shell's main.ts: construct each module's infra adapters, call its domain factories, bridge the peer gateways, register the context provider that answers context-request with the wired ports, then define the elements. Dependency injection stays a pattern, never a container.
  • The wiring rule (settled 2026-08-09): the WCCG Context protocol. The parser, not your code, instantiates elements from markup, so constructor injection is off the table. Instead, components request their ports with a composed, bubbling context-request event; a plain listener registered by main.ts answers from the port map it built — the assembly point keeps the graph. The protocol is built from baseline DOM events, so there is no browser-support surface at all. Ruled out: module-scope singletons (a service locator with a hidden graph) and reflection DI containers.
  • Command dispatch: no mediator (settled 2026-08-09) — commands stay plain objects, but the dispatch seam is the per-use-case driving port delivered by its typed context key; a central dispatcher is ruled out (it would defeat tree-shaking, collapse the typed context keys into one, and weaken subtree scoping). Cross-cutting concerns are factory decorations in main.ts. Details under the wiring section.
  • Test strategy per the house rule: the domain is unit-tested in Node with no DOM lib; components are tested in a real browser against the domain's own in-memory fakes; gateway-rest is integration-tested against the real API contract. No contract testing between infrastructure and the domain contract.

Details

The derived tree

acme-front/
├── README.md / AGENTS.md
├── package.json                        # npm workspaces root; tsconfig project references
├── platform/
│   ├── kernel/                         # command/result types, the Context protocol
│   └── commons/                        # ubiquitous ports: clock, id generation
├── modules/
│   ├── catalog/
│   │   ├── domain-contract/            # ports, commands, events — no DOM lib
│   │   ├── domain-core/                # entities, stores — exports map: factories only
│   │   ├── user-side/
│   │   │   ├── elements/               # port-bound custom elements + context keys
│   │   │   └── service/                # the in-process API for peer modules
│   │   └── infra/
│   │       ├── gateway-rest/           # fetch adapter for catalog's backend ports
│   │       └── storage-indexeddb/
│   └── checkout/
│       ├── domain-contract/            # declares a CatalogPrices driven port
│       ├── domain-core/
│       ├── user-side/elements/
│       └── infra/
│           └── catalog-gateway/        # the one package naming two contexts
├── design-system/                      # domain-blind components — never imports a domain
├── application/
│   ├── public-app/                     # deployment unit: index.html + main.ts (assembly)
│   │   ├── Dockerfile                  # static-file container, beside its unit
│   │   ├── src/
│   │   │   ├── main.ts                 # the assembly point
│   │   │   └── router.ts               # History API → the mounted modules' elements
│   │   └── custom-elements.json        # generated manifest — only where consumers exist
│   └── backoffice-app/
├── compose.yaml
└── deployment/                         # IaC: OpenTofu modules + per-environment stacks

Custom element tags carry the context: <acme-catalog-product-grid>, <acme-checkout-cart>. The registry is global and flat, so namespacing by module is what keeps two contexts — and, after extraction, two independently deployed bundles — from colliding on a tag name.

The package graph, held by the workspace + project references (an arrow reads "depends on"):

flowchart TB
    subgraph application["application/public-app (deployment unit)"]
        MAIN["main.ts (the assembly point)"]
    end
    subgraph catalog["modules/catalog"]
        CELEM["user-side/elements"]
        CSVC["user-side/service"]
        CCORE["domain-core (exports map: factories only)"]
        CAPI["domain-contract (no DOM lib)"]
        CREST["infra/gateway-rest"]
    end
    subgraph checkout["modules/checkout"]
        KELEM["user-side/elements"]
        KCORE["domain-core"]
        KAPI["domain-contract (declares CatalogPrices)"]
        KGW["infra/catalog-gateway"]
    end
    KERNEL["platform/kernel"]

    CAPI --> KERNEL
    KAPI --> KERNEL
    CCORE --> CAPI
    CREST --> CAPI
    CELEM --> CAPI
    CSVC --> CAPI
    KCORE --> KAPI
    KELEM --> KAPI
    KGW --> KAPI
    KGW -->|"the one inter-context edge"| CSVC
    MAIN --> CCORE
    MAIN --> CREST
    MAIN --> KCORE
    MAIN --> KGW
    MAIN -->|"defines; answers context-request with ports"| CELEM
    MAIN -->|"defines"| KELEM
    CELEM -.->|"blocked at resolution"| CCORE
    KELEM -.->|"blocked at resolution"| CAPI

The walls and what enforces them

TypeScript's types are erased at runtime, so no wall lives in the type system itself — they live at module resolution and build time, which the bundler consumes too: a rule-breaking import fails CI before it can ship.

Rule Enforced by
the domain never touches the browser domain packages compile with "lib": ["ES2022"] and no "DOM"document, fetch, HTMLElement are unresolvable: tsc
a context's core is invisible to adapters and shells <ctx>-domain-core's exports map — only factory entry points resolve; deep imports fail in Node (ERR_PACKAGE_PATH_NOT_EXPORTED) and tsc (TS2307) alike. Bypassed by a relative path, so pair it with the rule below
no relative import escapes its own package dependency-cruiser — the rule that makes the exports map airtight
contexts don't import each other dependency-cruiser only. Workspace dependencies and TS project references do not enforce this: an undeclared sibling import typechecks and runs
contexts meet only at the service seam only <consumer>-infra-<peer>-gateway may depend on <peer>-user-side-service — dependency-cruiser
domain imports nothing from user-side/infra; infra never imports user-side; adapters never import each other dependency-cruiser (the package graph documents the intent; it does not check it)
a deployment unit ships only what it uses the shell's import graph — the bundler tree-shakes from main.ts, and a module the shell doesn't mount is simply absent
element tags don't collide across contexts the acme-<context>-* tag prefix convention, checked by a lint rule over customElements.define calls
published UI contracts exist only where real a generated Custom Elements Manifest (custom-elements.json) only when outside consumers embed the elements — the pair is earned, never imposed
the assembly point stays boring plain main.ts wiring; one context-provider listener

Wiring web components — the platform-forced decision

Every reference has one decision its platform forces (Java's configurator, Go's DI stance, Rust's dyn-vs-generics). The browser's is element construction: the parser instantiates a custom element the moment its tag appears in markup — you never call new, so you can never pass a constructor argument. The stance, settled 2026-08-09: the Context community protocol is the wiring mechanism [1].

  • How it works. A consumer dispatches a context-request event (bubbles: true, composed: true — it climbs through shadow roots) carrying a typed context key and a callback. The first ancestor listening that recognizes the key calls stopPropagation() and invokes the callback synchronously. The provider needn't be an element: main.ts registers a plain listener on the document answering from the map of ports it constructed. The protocol is ~10 lines of event class the workspace owns — no library, no new browser API, and interoperable by design with outside implementations such as @lit/context [2].
  • Context keys are user-side artifacts, typed by the context's domain-contract ports, and namespaced per module (createContext<PlaceOrder>('acme.checkout.place-order')): the key couples consumer to contract, TypeScript carries the port type through the callback, and the domain never learns a DOM-flavored DI mechanism exists. Keys live in the module's user-side/elements package beside the elements that request them, so a module carries its own DI vocabulary wherever it is mounted.
  • Fail fast. A present provider answers synchronously, so absence is detectable immediately: request in connectedCallback, then throw loudly if the port field is still empty. Runtime resolution reduces to one guard clause.
  • Context delivers ports once; reactivity stays with the read models. The protocol's subscribe mode goes unused — the driving side already has subscriptions, and keeping the two mechanisms separate keeps both honest.
  • The scoping prize — verified 2026-08-14. First-ancestor-wins means a per-route provider, a preview subtree running on the domain's in-memory fakes, or several units on one page each answering their own subtree — without touching any element. Two independently built bundles, each registering its listener on its own root element, each resolved its own widget's port with no coordination and no global provider. Tests wrap the element in a test provider: same tag everywhere, no registry games.
    • The corollary, also verified: a widget cross-mounted into another bundle's subtree gets no provider at all. Elements are not composable across shells, only within one — which is exactly why templates and pages are shell-owned (see the atomic-design section). Composition across contexts is visual, in a shell, or it is through a gateway; never by dropping one module's element inside another's subtree.
  • The stated caveat — upgrade ordering. A consumer that requests before its provider listens gets silence. At startup the assembly shape prevents it (main.ts registers the listener before defining elements — definitions upgrade parsed markup and fire connectedCallback synchronously). It reappears with lazy-loaded, element-based providers; the cure is the ContextRoot pattern — buffer unanswered requests and replay them when a provider announces itself [2].
  • Rejected alternatives (2026-08-09 discussion): definition-time closure injection (factories at define — explicit, but fuses one tag to one wiring and threads factories through every composition layer; demoted from default to niche trick), constructor injection via code-only construction of port-bound organisms (honest signatures, but bans port-bound elements from markup and gives up the scoping wins), property injection (temporal coupling, wiring scattered across creation sites), and — with prejudice — module-scope singletons and reflection containers.

Command dispatch — no mediator (settled 2026-08-09)

The mediator object is ruled out for the frontend; the principle it serves — commands as data, one explicit seam, components never importing concrete handlers — is already delivered by the per-use-case driving ports and the Context wiring:

  • Tree-shaking is decisive. A mediator is one object referencing every handler: any unit touching one command drags the whole domain into its bundle, breaking "a deployment unit ships only what it uses". Per-port factories keep the import graph — and the bundle — honest.
  • The Context protocol already settled the seam. Ports arrive per typed context key; a mediator would collapse every key into one dispatch entry, erasing the per-port typing the protocol was chosen for and weakening the scoping prize (per-route providers, fake-backed preview subtrees swap individual ports).
  • Cross-cutting (command logging, optimistic-update policy hooks): decorate the domain factories in main.ts (createPlaceOrder wrapped with logging) — the assembly point is the seam, exactly like Go's decorators.

The layout dial — both layouts, basic default (settled 2026-08-14)

The architectural pull toward the modulith is stronger here than in any other reference: a real app is multi-context before its first release (catalog / checkout / account are distinct bounded contexts on day one), the context is already the natural code-split boundary, and a micro-frontend is a carved-out module. That argues for making the modulith the browser's only layout, and an earlier draft of this note did.

Both layouts ship anyway, with basic as the default. The modulith's cost is not only the packages it adds but the indirection it imposes — a modules/<ctx>/ level, a facade, a peer seam with no peer — and plenty of real browser work (a single-purpose widget, an internal admin panel, a demo SPA) genuinely has one context. A scaffold should let that project decline the structure rather than teach it a shape it must immediately work around. Where this reference has an opinion, it belongs in the choice's help text, not in removing the choice.

The package shape is the same under both: a package per bounded context under the modulith, and the same flat package set as today under basic. See the four references compared.

Rendering and state without a framework

  • A component clones its <template> once in connectedCallback, keeps references to the nodes it owns, and patches only what changed when notified — targeted DOM updates instead of re-render-and-diff.
  • The subscription is the reactivity: driving-side read models in the context's domain-contract expose subscribe; the component subscribes in connectedCallback and unsubscribes in disconnectedCallback. State classification per component architecture & state still applies — server state lives behind the gateway port, client state in domain stores, URL state in the router; only element-local UI state (an open/closed flag) stays in the component.
  • Styling: open shadow roots with adoptedStyleSheets fed by the design system's tokens; where each atomic level of that system lives is settled in the next section.
  • Server rendering is out of scope by design: this reference is CSR-only; declarative Shadow DOM and the trade-offs in rendering strategies enter only if a real need does.

Where atomic design fits — the interior of the driving adapter

Atomic design and the hexagon are orthogonal and compose: hexagonal architecture organizes the application, atomic design organizes the inside of the driving adapter — the same relationship DDD tactical patterns have to the inside of the domain. The architectural line through the atomic hierarchy is drawn by port-awareness, not atomic level — though in practice it falls at the organism line:

  • Tokens (sub-atomic), atoms, molecules — and any presentational organism — are domain-blind and live in the design-system package (its own workspace member, or its own repo when other products consume it). They speak pure web-component vocabulary: attributes/properties in, CustomEvents out, a render that is a function of their inputs. They depend on the DOM and the tokens, and never on any domain-contract — the design system is to the UI adapter what a serialization library is to a REST adapter: infrastructure of the adapter, invisible to the hexagon. The package graph enforces it: the design-system package declares no dependency on the domain packages, so a domain-aware "atom" is a wall violation CI catches, not a review debate.
  • Port-bound organisms are the adapter's parts and belong to their module, in modules/<ctx>/user-side/elements — they move with the context when it is extracted. They are where the two composition worlds meet: context-supplied ports on one side, design-system children on the other — subscribe to a read model and push state down as properties; listen to child CustomEvents and translate them up into commands on a driving port. The container/presentational split re-emerges here, drawn by the architecture instead of taste. Duplicating a port-bound organism between units is accepted over cross-unit coupling, per the house rule.
  • Templates and pages are always shell-owned: templates are the assembly's routed views — layout compositions of organisms drawn from whichever modules it mounts; pages are those templates mounted by the shell's router with real data flowing through the ports. This is the one place a shell legitimately composes two contexts' elements side by side, because it composes them visually and not through code.
  • Testing falls out along the same line: design-system levels are tested in isolation (Storybook as the workbench) with no domain anywhere in sight; port-bound organisms are tested against the domain's in-memory fakes like any driving adapter.
  • The design-system package is also where the Custom Elements Manifest is naturally real — it is a published, consumed contract by construction, where an app unit's manifest stays earned-only.

Atomic taxonomy debates (molecule vs organism) stay as cheap as the atomic-design note says they should be: they never move the architectural line, because the line is drawn by whether a component knows a port exists.

Composing two contexts, and the micro-frontend question

The frontend seam is the same as everywhere else, expressed in packages:

  • The provider exports a factory: <ctx>-user-side-service exports createCatalogService(deps): CatalogService — an interface with its own DTOs, implemented by mapping onto the context's driving ports. It is a driving adapter with no DOM in sight.
  • The consumer declares the port in its domain-contract in its own vocabulary (CatalogPrices), and <consumer>-infra-<peer>-gateway implements it over the peer's service. Only that package names two contexts.
  • The shell bridges them in main.ts, before registering the context provider — so the ports handed out over context-request are already wired across modules.

A micro-frontend is a carved-out module, and that is the whole answer to what this note previously left open. Extraction means: give the module its own application/<ctx>-app shell and bundle, replace the peer gateway with one built on a cross-document transport, and let the two bundles coexist on a page. Three things the modulith already established make it work:

  • Tag namespacing by context (acme-catalog-*) means two independently deployed bundles cannot collide in the global registry — the collision risk that made this an open question.
  • The Context protocol scopes by subtree: first-ancestor-wins means each mounted bundle answers context-request for its own subtree, with no coordination between shells and no global provider.
  • Per-module packages are already the code-split boundary, so a module extracted into its own bundle ships exactly what it shipped as a lazily-loaded route.

Cross-bundle deduplication — closed (settled 2026-08-14)

This note previously left shared-dependency deduplication open, calling it a build-topology decision rather than an architectural one. Browser testing shows it is architectural, and the ruling is: element-defining packages must be deduplicated across bundles, via an import map.

The reason is specific. CustomElementRegistry is a per-document singleton keyed by tag name, and define() throws on a duplicate. So any module that calls customElements.define at import time is a singleton dependency: it may be loaded at most once per document, however many bundles want it. Two independently built bundles each inlining the design system produced NotSupportedError: the name "acme-ds-button" has already been used with this registry — and because the throw aborts that module's top-level execution, the second micro-frontend's own elements never registered either. Half the page silently vanished. That is not wasted bytes; it is a correctness failure with a confusing symptom.

The three-way classification that falls out:

Shared code Rule Why
Module-owned elements (acme-catalog-*) duplication impossible by construction tag prefixes are per-context, so two bundles never define the same tag — the note's original claim, and it holds
Element-defining shared packages (the design system) must be a single instance per document — leave external, resolve via import map its tags are not context-prefixed; it is exactly the thing two bundles both want
Non-defining shared code (kernel, commons, pure domain) may duplicate costs bytes and gives each bundle its own module state; no crash
  • The mechanism is the import map, not module federation. Each bundle is built with the design system marked external; the page carries {"imports": {"@acme/design-system": "/assets/design-system.js"}}. Verified: one instance, shared module state, every panel rendered, no errors. Import maps are a platform feature — no bundler runtime, no framework, which is the same reason the Context protocol was chosen over a DI library.
  • Rejected — the guarded define (if (!customElements.get(tag)) define(...)). It removes the crash and installs silent divergence in its place: whichever bundle loads first owns the class for the whole page, the loser keeps a dead second copy of the module's state, and instanceof across the boundary is false. A crash you can read beats an identity bug you can't.
  • Consequence for the design system's release cadence. One instance per document means one version per document, so independently deployed shells cannot upgrade the design system independently. That is the genuine cost of the ruling, and it is the usual micro-frontend trade: shared-singleton dependencies are the coupling that survives extraction. Keep the design system's public API additive, and treat a breaking change to it as a coordinated release across every shell on the page.
  • Still worth revisiting: scoped custom element registries would dissolve the singleton constraint entirely and make per-bundle versions possible. Not yet broadly available; the import-map ruling stands until it is.

Examples

The protocol, owned by the workspace — the whole mechanism:

// platform/kernel — owned by the workspace, shared by every module
export interface Context<T> { readonly name: string }
export const createContext = <T>(name: string): Context<T> => ({ name });

export class ContextRequestEvent<T> extends Event {
  constructor(
    readonly context: Context<T>,
    readonly callback: (value: T) => void,
  ) {
    super('context-request', { bubbles: true, composed: true });
  }
}

application/public-app/src/main.ts as the configurator — provider first, definitions last (definitions upgrade parsed markup and fire connectedCallback synchronously, so the listener must already be in place):

import { createCatalogBrowsing, createCatalogStore } from '@acme/catalog-domain-core';
import { createCatalogService } from '@acme/catalog-user-side-service';
import { RestCatalogGateway } from '@acme/catalog-infra-gateway-rest';
import { createCheckout } from '@acme/checkout-domain-core';
import { CatalogPricesGateway } from '@acme/checkout-infra-catalog-gateway';
import { systemClock } from '@acme/platform-commons';
import { catalogContexts, defineCatalogElements } from '@acme/catalog-user-side-elements';
import { checkoutContexts, defineCheckoutElements } from '@acme/checkout-user-side-elements';

// catalog — its own hexagon, wired from its own packages
const catalogStore = createCatalogStore();
const catalog = createCatalogBrowsing(
  new RestCatalogGateway(import.meta.env.API_BASE_URL),
  catalogStore,
  systemClock,
);

// checkout — reaches catalog only through its own port, via the gateway
const checkout = createCheckout(
  new CatalogPricesGateway(createCatalogService(catalog)),
  systemClock,
);

const ports = new Map<Context<unknown>, unknown>([
  [catalogContexts.browsing, catalog],
  [catalogContexts.products, catalogStore],
  [checkoutContexts.checkout, checkout],
]);
document.addEventListener('context-request', (e) => {
  const req = e as ContextRequestEvent<unknown>;
  if (!ports.has(req.context)) return;   // let another provider answer
  req.stopPropagation();
  req.callback(ports.get(req.context));
});

defineCatalogElements();    // each module registers its own acme-<context>-* tags
defineCheckoutElements();

A port-bound element — statically defined, ports arriving via context, failing fast:

// modules/checkout/user-side/elements/src/cart.ts — DOM in, commands out
import type { Checkout } from '@acme/checkout-domain-contract';
import { ContextRequestEvent } from '@acme/platform-kernel';
import { checkoutContexts } from './contexts.js';

export class Cart extends HTMLElement {
  #root = this.attachShadow({ mode: 'open' });
  #checkout?: Checkout;

  connectedCallback() {
    this.dispatchEvent(new ContextRequestEvent(checkoutContexts.checkout, (p) => (this.#checkout = p)));
    if (!this.#checkout) throw new Error('<acme-checkout-cart>: no provider for checkout');

    this.#root.append(template.content.cloneNode(true));
    this.#root.querySelector('form')!.addEventListener('submit', async (e) => {
      e.preventDefault();
      await this.#checkout!.place(toCommand(new FormData(e.target as HTMLFormElement)));
    });
  }
}

The gateway — the only package in the workspace naming two contexts:

// modules/checkout/infra/catalog-gateway/src/index.ts
import type { CatalogPrices, Money, Sku } from '@acme/checkout-domain-contract';
import type { CatalogService } from '@acme/catalog-user-side-service';

export class CatalogPricesGateway implements CatalogPrices {
  constructor(private readonly catalog: CatalogService) {}

  async priceOf(sku: Sku): Promise<Money> {
    const product = await this.catalog.findProduct(sku.value);   // peer DTOs in…
    return { cents: product.priceCents, currency: product.currency };  // …checkout's types out
  }
}

Related

  • Hexagonal architecture — the shared house principles this note realizes; the hub for all the reference implementations.
  • Modulith & microservices — the extraction procedure this note reads as micro-frontend composition.
  • The four hexagonal references compared — why the browser has the weakest walls and the strongest domain-purity check of the four, and where server-side TypeScript's answer differs.
  • Component architecture & state management — the state-classification discipline this reference pins behind ports.
  • Atomic design — the methodology organizing the driving adapter's interior; the port boundary splits its hierarchy at the organism line.
  • TypeScript deep dive — erased types, project references and the lib dial: the machinery the walls are built from.
  • Browser internals — the platform this reference treats as the framework: parser-driven element construction, the event loop, the rendering pipeline.
  • Frontend testing — the behavior-first component tests and boundary mocking the test strategy leans on.
  • Design systems — the domain-blind component layer the deployment units compose from, and the natural consumer of a Custom Elements Manifest.

Citations

[1] Context — Web Components Community Group protocol proposal [2] Lit Context — the graduated reference implementation of the protocol