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 anindex.html+ entrymain.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 insidesrc/—domain/{contract,core},user-side/{elements,service},infra/*— and itspackage.jsonexportsmap 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); theexportsmap is the whole wall, and one package per context keeps all of it. This is Go's facade rule in TypeScript.design-systemstays 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
dependenciesplus 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 rootnode_modules. Project references do not restrict which projects a project may import: the same undeclared import builds clean undertsc -b --force. And a relative path (../../modules/catalog/domain/core/src/ internal/x.ts) walks straight through theexportsmap 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 asdependencyTypes: ['unknown']and every architecture rule passes green over a tree in violation. It needsenhancedResolveOptions: { 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
exportsmap (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-contractholds port interfaces (driving and driven), command/result types and domain events — depending only on@acme/platform-kernel;<ctx>-domain-coreholds entities, services and stores, and itspackage.jsonexportsmap exposes only factory entry points returning driving-port interfaces. Deep imports fail at module resolution — the frontend's nestedinternal/, 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 theirtsconfig, sodocument,fetch,HTMLElementand 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 DOMCustomEvents 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 viacrypto.randomUUID) sit outside every context in@acme/platform-commons. The shell's import graph decides what ships — the bundler tree-shakes frommain.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-servicefactory. It is the only package whosedependenciesname 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 answerscontext-requestwith 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-requestevent; a plain listener registered bymain.tsanswers 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-restis 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-requestevent (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 callsstopPropagation()and invokes the callback synchronously. The provider needn't be an element:main.tsregisters 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-contractports, 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'suser-side/elementspackage 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
subscribemode 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.tsregisters the listener before defining elements — definitions upgrade parsed markup and fireconnectedCallbacksynchronously). 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
dispatchentry, 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(createPlaceOrderwrapped 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 inconnectedCallback, 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-contractexposesubscribe; the component subscribes inconnectedCallbackand unsubscribes indisconnectedCallback. 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
adoptedStyleSheetsfed 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 anydomain-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 childCustomEvents 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-serviceexportscreateCatalogService(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-contractin its own vocabulary (CatalogPrices), and<consumer>-infra-<peer>-gatewayimplements 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 overcontext-requestare 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-requestfor 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, andinstanceofacross 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
libdial: 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