Pattern
intent and mechanism
Use Web/Components/* as the named fragment API inside an
IHP Web/* layer. A component is not just "some HSX". It is
a reusable Web-layer fragment that a full route page can embed and/or a
controller can return for an HTMX response.
The boundary separates two axes that often get confused:
- Web responsibility — controller boundary, complete
route page, component fragment, authorization policy.
- Reusable vocabulary — page-local presentation
versus shared view helper primitives below
Web/*.
The core rule is ownership:
- controllers parse params, enforce method and authorization gates,
read or write data, call external providers, freeze render context, and
choose the response;
- route views compose complete pages and keep page-local helpers
private;
- Web components render named fragments, form groups, controls,
section swap targets, and component-local presentation/workflow
configuration;
- reusable view helpers render repeated presentation primitives, even
when they are entity-specific;
- authorization modules define policy language and decisions.
When a controller needs to return a reusable HTMX fragment, move that
fragment behind a named component entrypoint. The full page delegates to
the same component. This prevents Web/View/* modules from
becoming accidental partial APIs and keeps fragment call sites
grepable.
This is a Composition pattern because the mechanism is code
placement, not a single rendering trick. It composes with HTMX
target/swap patterns, controller gates, authorization policies, reusable
view helpers, and external-provider boundaries.
Search aliases: component boundary, fragment API, partial response
boundary, view-to-component split, controller-owned endpoint,
presentation helper placement, Web layer placement.
Project-specific
notes and rollout guidance
Use component modules when the fragment owns Web workflow vocabulary:
a named component boundary, a domain-specific HTMX swap target,
authorization salience, enum ordering, domain workflow semantics, or
controller-rendered partial responses. Use reusable view helpers when
the code is repeated presentation vocabulary without workflow
ownership.
Generic HTMX helpers belong below Web/* when they
parameterize route/action vocabulary instead of owning it. A helper such
as a select, listing, button, or field renderer may accept a
HasPath action, render hx-post, or receive an
hx-target; that does not make it a component when the
caller supplies the workflow boundary.
A helper can be entity-specific and still belong below
Web/* when it renders pure presentation reused across
unrelated views or components. It may even link to a route when that
link is ordinary presentation vocabulary. Conversely, a fragment can
look visually generic and still belong in Web/Components/*
when it owns route/action/HTMX/workflow semantics.
Use export lists according to the layer. Component modules and
reusable helper modules should have explicit export lists because their
public surface is the API. Ordinary IHP route view modules may stay
idiomatic and omit export lists when they only expose the
View instance and page-local helpers. Add a selective view
export list only when the view module deliberately exposes a named
fragment API for controller use or another reviewed public entry
point.
For component modules, the export list is the fragment API: expose
form groups, controller-facing section renderers, configuration types,
and stable ids that callers genuinely need. Keep DOM-id derivation, row
renderers, OOB helpers, validation internals, and endpoint-local helpers
private until a second real caller proves they are public API.
Do not state a hard rule that every partial-looking helper must leave
a view. A large singular route-local section may stay in its route view
when it is not a shared fragment API, when moving it would only create
ceremony, and when the exception is documented locally. If such a
section is still rendered by a controller, name the controller-facing
renderer clearly, keep low-level DOM ids and plumbing helpers internal,
and use a small config record when the context is dense. But a
controller-used, reusable HTMX fragment normally belongs in
Web/Components/*.
Components may consume authorization decisions or small pure
classification helpers. They must not duplicate policy matrices or
enforce gates. The controller enforces; the component renders the
allowed, disabled, or unavailable state using the decision it was
given.
Components may render forms and controls, but endpoint bodies stay in
controllers. Request parsing, database queries and writes,
external-service calls, context freezing, respondHtml,
redirects, and flash or header response selection are controller
responsibilities. If a component appears to need those, split the
function: controller orchestration plus component renderer.
Preserve pattern identity in host code through names. Local modules
and helpers should combine domain vocabulary with component vocabulary,
for example itemsIndexSection,
projectMemberRoleDropdown, or
entityPickerComponent. Do not silently inline the component
boundary into a full-page view or controller action.
Treat config records as API hygiene, not architecture dogma. They are
useful for controller-facing fragments with dense context because they
make call sites readable and reduce positional-argument mistakes. They
are unnecessary ceremony for small page-local helpers with one or two
obvious arguments.
Supporting
snippets
Typical component module export shape:
module Web.Components.Items
( ItemsIndexConfig(..)
, itemsIndexSectionId
, itemsIndexSection
, itemFormGroupComponent
) where
Typical full-page view delegation. The explicit view export list is
optional for an ordinary IHP route view; use it when the module
intentionally exposes a reviewed API beyond the framework's view
instance.
module Web.View.Items.Index where
import Web.Components.Items (ItemsIndexConfig(..), itemsIndexSection)
instance View IndexView where
html IndexView { .. } = [hsx|
{itemsIndexSection ItemsIndexConfig { .. }}
|]
Typical controller fragment response shape:
action ItemsAction = do
let filterValue = fromMaybe "all" (paramOrNothing @Text "filter")
items <- fetchItemsForFilter filterValue
let config = ItemsIndexConfig { items, filterValue }
if isHtmxRequestFor itemsIndexSectionId
then do
frozenContext <- freeze ?context
let ?context = frozenContext
respondHtml (itemsIndexSection config)
else render IndexView { .. }
Typical route-local controller-facing exception. Keep the public
renderer named and narrow; keep DOM-id derivation and small row helpers
private.
module Web.View.Items.Index
( IndexView(..)
, ItemsIndexSectionConfig(..)
, itemsIndexSection
) where
Typical reusable presentation helper counter-shape:
module Application.Helper.View.Entities
( renderEntityLinked
, renderEntitiesLinked
) where
The helper may be entity-specific and may link to routes, but it is
not a component when it has no route-page, HTMX, action, authorization,
or workflow ownership.
Typical parameterized HTMX helper counter-shape:
renderSelectPrimitive :: HasPath action => (value -> action) -> Maybe Text -> [value] -> Html
This helper renders route and HTMX attributes, but the caller owns
the concrete workflow, domain vocabulary, and swap boundary.
Core
reusable pattern infrastructure
The ADT below is deliberately small. It gives agents and maintainers
a typed placement vocabulary without turning the pattern into a
generator or registry.
{-# LANGUAGE OverloadedStrings #-}
module Patterns.Composition.WebComponentBoundary
( WebPlacement(..)
, PlacementSignal(..)
, SplitReason(..)
, PlacementDecision(..)
, PlacementConflict(..)
, classifyWebPlacement
, placementModuleHint
, requiresSplit
) where
import Prelude
import Data.Text (Text)
-- | The allowed homes for Web-layer code under this pattern.
data WebPlacement
= ControllerBoundary
| RoutePageView
| WebFragmentComponent
| ReusableViewHelper
| AuthorizationPolicyModule
deriving (Eq, Show)
-- | Evidence observed while deciding where code belongs.
-- Signals are intentionally mechanism-level, not domain-specific.
data PlacementSignal
= ParsesRequestParams
| PerformsAuthorizationGate
| ReadsOrWritesDatabase
| CallsExternalProvider
| FreezesRenderContext
| ChoosesResponse
| RendersCompleteRoutePage
| RendersControllerUsedHtmxFragment
| RendersReusableWebFragment
| RendersPureReusablePresentation
| RendersParameterizedHtmxPrimitive
| DefinesPolicyDecision
| ConsumesPolicyDecision
-- | Use only for deliberate, locally documented exceptions that should
-- remain in a route view instead of becoming a component API.
| DocumentedPageLocalSingularSection
| ParameterizesRouteOrActionVocabulary
-- | Concrete route/action use is a weak signal; ordinary links and helper
-- parameters do not imply component ownership.
| UsesConcreteRouteOrActionVocabulary
| OwnsHtmxSwapBoundary
| CarriesDomainWorkflowSemantics
| DuplicatedAcrossViewsOrComponents
deriving (Eq, Show)
-- | Why one observed unit should be split across two homes.
data SplitReason
= ControllerRendersComponent
| ControllerSelectsRoutePage
| ControllerRendersRouteLocalPageSection
| PolicyFeedsController
| PolicyFeedsPresentation
| FullPageDelegatesComponent
deriving (Eq, Show)
-- | A placement decision is either one home or an explicit split.
data PlacementDecision
= Single WebPlacement
| Split WebPlacement WebPlacement SplitReason
deriving (Eq, Show)
-- | Cases where the signals are insufficient for an automatic placement.
data PlacementConflict
= NoPlacementSignal
| AmbiguousHelperVsComponent
deriving (Eq, Show)
The classifier encodes precedence, not full automation. It is
conservative: when the evidence does not distinguish helper from
component, it asks the caller to decide or split.
classifyWebPlacement :: [PlacementSignal] -> Either PlacementConflict PlacementDecision
classifyWebPlacement signals
| null signals = Left NoPlacementSignal
| has DocumentedPageLocalSingularSection signals && has RendersReusableWebFragment signals =
Left AmbiguousHelperVsComponent
| has RendersPureReusablePresentation signals && has RendersReusableWebFragment signals && not (hasAny strongComponentSignals signals) =
Left AmbiguousHelperVsComponent
| hasAny helperPrimitiveSignals signals && has RendersReusableWebFragment signals && not (hasAny strongComponentSignals signals) =
Left AmbiguousHelperVsComponent
| has DefinesPolicyDecision signals && hasAny controllerSignals signals =
Right (Split AuthorizationPolicyModule ControllerBoundary PolicyFeedsController)
| has DefinesPolicyDecision signals && hasAny presentationSignals signals =
Right (Split AuthorizationPolicyModule (preferredPresentationPlacement signals) PolicyFeedsPresentation)
| hasAny controllerSignals signals && has DocumentedPageLocalSingularSection signals && hasAny renderSignals signals =
Right (Split ControllerBoundary RoutePageView ControllerRendersRouteLocalPageSection)
| hasAny controllerSignals signals && has RendersCompleteRoutePage signals && not (hasAny fragmentSignals signals) =
Right (Split ControllerBoundary RoutePageView ControllerSelectsRoutePage)
| hasAny controllerSignals signals && hasAny renderSignals signals =
Right (Split ControllerBoundary (preferredPresentationPlacement signals) ControllerRendersComponent)
| hasAny controllerSignals signals =
Right (Single ControllerBoundary)
| has DefinesPolicyDecision signals =
Right (Single AuthorizationPolicyModule)
| has RendersCompleteRoutePage signals && hasAny fragmentSignals signals && not (has DocumentedPageLocalSingularSection signals) =
Right (Split RoutePageView WebFragmentComponent FullPageDelegatesComponent)
| has RendersCompleteRoutePage signals =
Right (Single RoutePageView)
| has RendersPureReusablePresentation signals && hasAny strongComponentSignals signals =
Right (Single WebFragmentComponent)
| hasAny helperPrimitiveSignals signals && not (hasAny strongComponentSignals signals) =
Right (Single ReusableViewHelper)
| has RendersPureReusablePresentation signals || has DuplicatedAcrossViewsOrComponents signals =
Right (Single ReusableViewHelper)
| hasAny fragmentSignals signals =
Right (Single WebFragmentComponent)
| otherwise = Left NoPlacementSignal
Precedence helpers stay private. They make the classifier readable
and keep the exported API small.
controllerSignals :: [PlacementSignal]
controllerSignals =
[ ParsesRequestParams
, PerformsAuthorizationGate
, ReadsOrWritesDatabase
, CallsExternalProvider
, FreezesRenderContext
, ChoosesResponse
]
renderSignals :: [PlacementSignal]
renderSignals =
[ RendersCompleteRoutePage
, RendersControllerUsedHtmxFragment
, RendersReusableWebFragment
, RendersPureReusablePresentation
, RendersParameterizedHtmxPrimitive
]
presentationSignals :: [PlacementSignal]
presentationSignals =
renderSignals <> [ConsumesPolicyDecision]
helperPrimitiveSignals :: [PlacementSignal]
helperPrimitiveSignals =
[ RendersParameterizedHtmxPrimitive
, ParameterizesRouteOrActionVocabulary
]
fragmentSignals :: [PlacementSignal]
fragmentSignals =
[ RendersControllerUsedHtmxFragment
, RendersReusableWebFragment
, OwnsHtmxSwapBoundary
, CarriesDomainWorkflowSemantics
]
strongComponentSignals :: [PlacementSignal]
strongComponentSignals =
[ RendersControllerUsedHtmxFragment
, OwnsHtmxSwapBoundary
, CarriesDomainWorkflowSemantics
]
preferredPresentationPlacement :: [PlacementSignal] -> WebPlacement
preferredPresentationPlacement signals
| has DocumentedPageLocalSingularSection signals = RoutePageView
| has RendersCompleteRoutePage signals && not (hasAny fragmentSignals signals) = RoutePageView
| hasAny helperPrimitiveSignals signals && not (hasAny strongComponentSignals signals) = ReusableViewHelper
| has RendersPureReusablePresentation signals && not (hasAny strongComponentSignals signals) = ReusableViewHelper
| otherwise = WebFragmentComponent
has :: PlacementSignal -> [PlacementSignal] -> Bool
has signal = elem signal
hasAny :: [PlacementSignal] -> [PlacementSignal] -> Bool
hasAny candidates signals = any (`elem` signals) candidates
Module hints are examples, not globally required names.
placementModuleHint :: WebPlacement -> Text
placementModuleHint ControllerBoundary = "Web/Controller/*"
placementModuleHint RoutePageView = "Web/View/<Entity>/<Action>.hs"
placementModuleHint WebFragmentComponent = "Web/Components/*"
placementModuleHint ReusableViewHelper = "Application/Helper/View/*"
placementModuleHint AuthorizationPolicyModule = "Web/Authorization/*"
requiresSplit :: PlacementDecision -> Bool
requiresSplit (Single _) = False
requiresSplit (Split _ _ _) = True
Helper
implementation examples
A controller-rendered HTMX section is a split: the controller owns
request and response orchestration; the component owns the stable
fragment renderer.
controllerRenderedSectionDecision :: Either PlacementConflict PlacementDecision
controllerRenderedSectionDecision = classifyWebPlacement
[ ParsesRequestParams
, ReadsOrWritesDatabase
, FreezesRenderContext
, ChoosesResponse
, RendersControllerUsedHtmxFragment
, OwnsHtmxSwapBoundary
]
A complete route page that delegates to a reusable fragment is also a
split: the route view remains the page, while the component is the
reusable fragment API.
routePageDelegatesToComponentDecision :: Either PlacementConflict PlacementDecision
routePageDelegatesToComponentDecision = classifyWebPlacement
[ RendersCompleteRoutePage
, RendersReusableWebFragment
, OwnsHtmxSwapBoundary
]
A repeated label/list renderer is a reusable view helper when it is
pure presentation vocabulary and carries no HTMX or workflow ownership.
Ordinary route links do not make it a component.
reusablePresentationHelperDecision :: Either PlacementConflict PlacementDecision
reusablePresentationHelperDecision = classifyWebPlacement
[ RendersPureReusablePresentation
, UsesConcreteRouteOrActionVocabulary
, DuplicatedAcrossViewsOrComponents
]
A generic parameterized HTMX primitive is a reusable view helper. It
may render hx-post or hx-target, but the
caller supplies the concrete action and owns the swap boundary.
genericParameterizedHtmxHelperDecision :: Either PlacementConflict PlacementDecision
genericParameterizedHtmxHelperDecision = classifyWebPlacement
[ RendersParameterizedHtmxPrimitive
, ParameterizesRouteOrActionVocabulary
]
A controller-rendered route-local section is an explicit documented
exception: split the request/response work from the route view, but do
not force a component when the section is singular, page-local, and not
a reusable Web fragment.
routeLocalControllerSectionDecision :: Either PlacementConflict PlacementDecision
routeLocalControllerSectionDecision = classifyWebPlacement
[ ParsesRequestParams
, FreezesRenderContext
, ChoosesResponse
, RendersCompleteRoutePage
, DocumentedPageLocalSingularSection
]
A documented route-local exception cannot also claim to be a reusable
Web fragment. That contradiction must stop instead of silently keeping
the fragment in the route view.
ambiguousRouteLocalReusableFragmentDecision :: Either PlacementConflict PlacementDecision
ambiguousRouteLocalReusableFragmentDecision = classifyWebPlacement
[ ParsesRequestParams
, FreezesRenderContext
, ChoosesResponse
, RendersReusableWebFragment
, DocumentedPageLocalSingularSection
]
The same contradiction must stop even without controller signals;
otherwise a complete route-page render could hide a reusable fragment
inside the documented exception.
ambiguousRoutePageReusableFragmentDecision :: Either PlacementConflict PlacementDecision
ambiguousRoutePageReusableFragmentDecision = classifyWebPlacement
[ RendersCompleteRoutePage
, RendersReusableWebFragment
, DocumentedPageLocalSingularSection
]
A provider-backed autocomplete endpoint is split three ways in
practice. This classifier identifies the Web-layer split; compose it
with ExternalProviderModuleBoundary for the provider module
itself.
providerAutocompleteEndpointDecision :: Either PlacementConflict PlacementDecision
providerAutocompleteEndpointDecision = classifyWebPlacement
[ ParsesRequestParams
, PerformsAuthorizationGate
, CallsExternalProvider
, FreezesRenderContext
, ChoosesResponse
, RendersControllerUsedHtmxFragment
]
Usage
examples
Use the decision values as executable documentation in host-project
planning or review. The expected shapes below are intentionally small
and grepable.
expectedControllerRenderedSection :: PlacementDecision
expectedControllerRenderedSection =
Split ControllerBoundary WebFragmentComponent ControllerRendersComponent
controllerRenderedSectionIsPlaced :: Bool
controllerRenderedSectionIsPlaced =
controllerRenderedSectionDecision == Right expectedControllerRenderedSection
expectedRoutePageDelegation :: PlacementDecision
expectedRoutePageDelegation =
Split RoutePageView WebFragmentComponent FullPageDelegatesComponent
routePageDelegationIsPlaced :: Bool
routePageDelegationIsPlaced =
routePageDelegatesToComponentDecision == Right expectedRoutePageDelegation
expectedReusablePresentationHelper :: PlacementDecision
expectedReusablePresentationHelper =
Single ReusableViewHelper
reusablePresentationHelperIsPlaced :: Bool
reusablePresentationHelperIsPlaced =
reusablePresentationHelperDecision == Right expectedReusablePresentationHelper
genericParameterizedHtmxHelperIsPlaced :: Bool
genericParameterizedHtmxHelperIsPlaced =
genericParameterizedHtmxHelperDecision == Right expectedReusablePresentationHelper
expectedRouteLocalControllerSection :: PlacementDecision
expectedRouteLocalControllerSection =
Split ControllerBoundary RoutePageView ControllerRendersRouteLocalPageSection
routeLocalControllerSectionIsPlaced :: Bool
routeLocalControllerSectionIsPlaced =
routeLocalControllerSectionDecision == Right expectedRouteLocalControllerSection
ambiguousRouteLocalReusableFragmentStops :: Bool
ambiguousRouteLocalReusableFragmentStops =
ambiguousRouteLocalReusableFragmentDecision == Left AmbiguousHelperVsComponent
ambiguousRoutePageReusableFragmentStops :: Bool
ambiguousRoutePageReusableFragmentStops =
ambiguousRoutePageReusableFragmentDecision == Left AmbiguousHelperVsComponent
A helper/component ambiguity is a stop point. If the code is merely
repeated presentation or a parameterized HTMX primitive, keep or move it
to the helper layer. If it owns a concrete HTMX boundary or domain
workflow semantics, place it as a component.
ambiguousHelperOrComponentDecision :: Either PlacementConflict PlacementDecision
ambiguousHelperOrComponentDecision = classifyWebPlacement
[ RendersPureReusablePresentation
, RendersReusableWebFragment
]
ambiguousHelperOrComponentStops :: Bool
ambiguousHelperOrComponentStops =
ambiguousHelperOrComponentDecision == Left AmbiguousHelperVsComponent
A pure-presentation fragment with concrete HTMX or workflow ownership
is no longer ambiguous; the ownership signal makes it
component-owned.
ownedPurePresentationFragmentDecision :: Either PlacementConflict PlacementDecision
ownedPurePresentationFragmentDecision = classifyWebPlacement
[ RendersPureReusablePresentation
, RendersReusableWebFragment
, OwnsHtmxSwapBoundary
]
ownedPurePresentationFragmentIsComponent :: Bool
ownedPurePresentationFragmentIsComponent =
ownedPurePresentationFragmentDecision == Right (Single WebFragmentComponent)
A parameterized HTMX primitive that is also marked as a reusable Web
fragment is ambiguous unless it owns a concrete HTMX/workflow boundary.
Do not silently push that mixed signal to the helper layer.
ambiguousParameterizedFragmentDecision :: Either PlacementConflict PlacementDecision
ambiguousParameterizedFragmentDecision = classifyWebPlacement
[ RendersParameterizedHtmxPrimitive
, RendersReusableWebFragment
]
ambiguousParameterizedFragmentStops :: Bool
ambiguousParameterizedFragmentStops =
ambiguousParameterizedFragmentDecision == Left AmbiguousHelperVsComponent
Those ambiguity contracts also win before policy splitting.
Policy-aware fragments still need a coherent component/helper/page
boundary.
ambiguousPolicyRouteLocalFragmentDecision :: Either PlacementConflict PlacementDecision
ambiguousPolicyRouteLocalFragmentDecision = classifyWebPlacement
[ DefinesPolicyDecision
, ConsumesPolicyDecision
, RendersReusableWebFragment
, DocumentedPageLocalSingularSection
]
ambiguousPolicyParameterizedFragmentDecision :: Either PlacementConflict PlacementDecision
ambiguousPolicyParameterizedFragmentDecision = classifyWebPlacement
[ DefinesPolicyDecision
, ConsumesPolicyDecision
, RendersParameterizedHtmxPrimitive
, RendersReusableWebFragment
]
ambiguousPolicyFragmentsStop :: Bool
ambiguousPolicyFragmentsStop =
ambiguousPolicyRouteLocalFragmentDecision == Left AmbiguousHelperVsComponent
&& ambiguousPolicyParameterizedFragmentDecision == Left AmbiguousHelperVsComponent
Policy plus rendering is also a split. Define the policy in
authorization code; pass the decision into the component or view.
policyAwareComponentDecision :: Either PlacementConflict PlacementDecision
policyAwareComponentDecision = classifyWebPlacement
[ DefinesPolicyDecision
, RendersReusableWebFragment
, ConsumesPolicyDecision
]
expectedPolicyAwareComponent :: PlacementDecision
expectedPolicyAwareComponent =
Split AuthorizationPolicyModule WebFragmentComponent PolicyFeedsPresentation
Standalone
checks
The example decisions should keep matching the intended placement
contract.
standalonePlacementChecks :: Bool
standalonePlacementChecks = and
[ controllerRenderedSectionIsPlaced
, routePageDelegationIsPlaced
, reusablePresentationHelperIsPlaced
, genericParameterizedHtmxHelperIsPlaced
, routeLocalControllerSectionIsPlaced
, ambiguousRouteLocalReusableFragmentStops
, ambiguousRoutePageReusableFragmentStops
, ambiguousHelperOrComponentStops
, ownedPurePresentationFragmentIsComponent
, ambiguousParameterizedFragmentStops
, ambiguousPolicyFragmentsStop
, policyAwareComponentDecision == Right expectedPolicyAwareComponent
, requiresSplit expectedControllerRenderedSection
, not (requiresSplit expectedReusablePresentationHelper)
]