11 min read · 2,590 words
Experience-Based Decomposition begins with the same premise as VBD: components should be organized around how they change.
Most frontend patterns organize by size or technical role. Atomic Design gives you atoms, molecules, organisms, templates, and pages — a clear taxonomy built on composition. It solves a real problem: it stops component libraries from becoming an undifferentiated pile. But it solves it with the wrong axis.
Size is not a volatility axis. An atom changes because a brand standard changed. An organism changes because a business process changed. The two might change together, or never at the same time, and the structure gives you no way to predict which. You end up with a well-organized component library where nobody can confidently scope a feature change.
Experience-Based Decomposition organizes by the reason a component changes, not how large it is.
Axes of Interface Volatility
In practice, most meaningful frontend change falls into four categories:
- Functional volatility — what the interface enables users to do. Changes when product strategy changes.
- Structural volatility — how the interface sequences a goal. Navigation flow, step ordering, progressive disclosure. Changes when UX architecture changes.
- Environmental volatility — how the interface communicates with the backend. API contracts, data shapes, auth flows. Changes when backend integration changes.
- Cross-cutting volatility — the primitives shared across the interface. Design tokens, form components, loading states. Changes when the design system changes.
These axes evolve independently. When they are combined within the same component, change in one axis forces unnecessary churn in the others.
The purpose of decomposition is to prevent that coupling.
Component Tiers
Experience-Based Decomposition aligns those axes with structural tiers:
| Tier | Concern | Responsibility |
|---|---|---|
| Experience | Functional | Complete user journey, top-level state, backend communication |
| Flow | Structural | Goal-directed sequence within a journey |
| Interaction | Atomic surface | A single atomic user action |
| Utility | Cross-cutting | Shared primitives and design system components |
An Experience owns a complete user journey. It knows which Flows are involved, owns the top-level state, and determines what happens in what order. It is also the only tier that communicates with the backend — when accumulated state is ready to be acted on, the Experience is the one that sends it. It does not render its own UI; it delegates entirely to Flows.
A Flow owns a goal-directed sequence within an Experience. “Enter payment details” is a Flow. “Review your order” is a Flow. A Flow knows the Interactions it contains and the local state that connects them. It does not know which Experience it belongs to.
An Interaction is an atomic user action — a form field, a confirmation button, an inline validation pattern. It has no knowledge of the Flow it lives in. It exposes a clean interface: inputs and events, nothing more.
A Utility is a cross-cutting concern — a date formatter, a design token, an error boundary. It can be used from any tier. It has no business logic and makes no API calls.
Communication Rules
The effectiveness of this decomposition depends on a single rule:
State flows down, events propagate up, and the Experience is the only tier that communicates with the backend.
Experiences delegate to Flows. Flows compose Interactions. Flows surface their accumulated state upward as completion events — they do not call the backend directly. The Experience holds everything, decides when state is ready to be sent, and decides what happens next.
Flows do not share state laterally. Interactions do not call APIs. Nothing reaches up the tree.
This rule preserves the independence of volatility axes. When a Flow makes a direct API call, a backend contract change begins propagating through the component tree.
%%{init: {'theme': 'base', 'themeVariables': {
'actorBkg': '#0b57d0',
'actorTextColor': '#ffffff',
'actorBorder': '#0842a0',
'actorLineColor': '#cfd1d4',
'signalColor': '#3c4043',
'signalTextColor': '#3c4043',
'activationBkgColor': '#e8f0fe',
'activationBorderColor': '#0b57d0',
'labelBoxBkgColor': '#f8f9fa',
'labelBoxBorderColor': '#cfd1d4',
'loopTextColor': '#3c4043'
}}}%%
sequenceDiagram
actor U as User
participant EXP as CheckoutExperience
participant CRF as CartReviewFlow
participant PF as PaymentFlow
participant CF as ConfirmationFlow
participant BE as Backend Workflow
EXP->>+CRF: enter(state)
U-->>CRF: interacts
CRF->>-EXP: flowComplete(cartState)
EXP->>+PF: enter(state + cartState)
U-->>PF: interacts
PF->>-EXP: flowComplete(paymentState)
EXP->>+CF: enter(accumulated)
U-->>CF: interacts
CF->>-EXP: flowComplete(confirmed)
EXP->>BE: submit(accumulatedState)
What a Well-Engineered Frontend Gets Wrong
Many teams produce something like this. They are not writing spaghetti components. They are applying container/presentational separation. They have custom hooks for data fetching, organisms for layout, molecules for interaction patterns. The structure is principled.
The structure is defensible. This is what careful, experienced work looks like.
flowchart TB
classDef container fill:#0b57d0,color:#ffffff,stroke:#0842a0,stroke-width:2px;
classDef organism fill:#fbbc04,color:#202124,stroke:#c49000,stroke-width:2px;
classDef hook fill:#188038,color:#ffffff,stroke:#146c2e,stroke-width:2px;
classDef external fill:#f8f9fa,color:#3c4043,stroke:#cfd1d4,stroke-width:1.5px;
CP["CheckoutPage (Container)"]:::container
CS["CartSection (Organism)"]:::organism
PS["PaymentSection (Organism)"]:::organism
OS["OrderSummary (Organism)"]:::organism
UC["useCart()"]:::hook
UP["usePayment()"]:::hook
UO["useOrder()"]:::hook
CART_API["Cart API"]:::external
PAY_API["Payment API"]:::external
ORD_API["Order API"]:::external
CP --> CS
CP --> PS
CP --> OS
CP --> UC
CP --> UP
CP --> UO
UC --> CART_API
UP --> PAY_API
UO --> ORD_API
Separation of concerns is present. Hooks are reusable. Organisms are composable. This passes review.
And it still has the same fundamental problem.
The hooks are organized by data entity — useCart, usePayment, useOrder. The container owns step sequencing. The organisms own rendering. When checkout requirements change — a new step, a conditional flow for guest users, a different step order — the container logic changes, the hooks may need new data, and the organisms may need to handle new states. The change is functionally local but structurally diffuse.
The hooks are the shared aggregate equivalent. They are organized around data shape, not around what changes them. Everything that needs cart data imports useCart. When the Cart API contract changes, you find out how many places that is.
The abstraction addressed the wrong boundary.
Container/presentational separation asks: what renders and what fetches? Experience-Based Decomposition asks: what forces this to change? These are different questions, and they produce different structures.
The Same Interface After Experience-Based Decomposition
Under Experience-Based Decomposition, the same checkout feature is structured differently.
flowchart TB
classDef experience fill:#0b57d0,color:#ffffff,stroke:#0842a0,stroke-width:2px;
classDef flow fill:#fbbc04,color:#202124,stroke:#c49000,stroke-width:2px;
classDef interaction fill:#9b59b6,color:#ffffff,stroke:#6c3483,stroke-width:2px;
classDef external fill:#f8f9fa,color:#3c4043,stroke:#cfd1d4,stroke-width:1.5px;
subgraph APP["Checkout (Experience-Aligned)"]
CE["CheckoutExperience"]
CRF["CartReviewFlow"]
PF["PaymentFlow"]
CF["ConfirmationFlow"]
CII["CartItemInteraction"]
PCI["PromoCodeInteraction"]
CARDI["CardInputInteraction"]
BAI["BillingAddressInteraction"]
OSI["OrderSummaryInteraction"]
end
CM["VBD Backend Workflow"]:::external
CE --> CRF
CE --> PF
CE --> CF
CRF --> CII
CRF --> PCI
PF --> CARDI
PF --> BAI
CF --> OSI
CE -->|accumulated state| CM
class CE experience
class CRF,PF,CF flow
class CII,PCI,CARDI,BAI,OSI interaction
class CM external
The CheckoutExperience coordinates the journey, owns the top-level state, and is the only component that talks to the backend. It sequences CartReview, then Payment, then Confirmation — accumulating state from each Flow as it completes.
When the journey is done, it emits that accumulated state as a single coherent event to the corresponding VBD backend workflow — an OrderManager, a SubmissionManager, whatever the domain calls it. The Experience does not call a cart API, then a payment API, then an order API. It hands off everything at once to the component that owns what happens next.
Each Flow owns its goal-directed sequence and the local state that connects its Interactions. PaymentFlow knows about card input and billing address. It does not know about cart state, order confirmation, or the backend.
This is the structural isomorphism in action. The Experience and its backend peer change for the same reasons, at the same rate, in response to the same business events — even if their names reflect different vocabularies. When product says “change the checkout process,” the scope is immediately visible: one Experience, one Manager, and whatever Flows and Engines sit beneath them.
Here is what that backend looks like.
flowchart TB
classDef manager fill:#0b57d0,color:#ffffff,stroke:#0842a0,stroke-width:2px;
classDef engine fill:#fbbc04,color:#202124,stroke:#c49000,stroke-width:2px;
classDef accessor fill:#188038,color:#ffffff,stroke:#146c2e,stroke-width:2px;
classDef external fill:#f8f9fa,color:#3c4043,stroke:#cfd1d4,stroke-width:1.5px;
subgraph APP["Order Processing (Volatility-Aligned)"]
direction TB
OM["OrderManager"]
VE["ValidationEngine"]
PE["PricingEngine"]
PM["PaymentManager"]
NM["NotificationManager"]
IA["ItemAccessor"]
RA["RulesAccessor"]
PRA["PriceAccessor"]
OA["OrderAccessor"]
end
subgraph EXT["External Systems"]
direction LR
IDS["Item Data Source"]
RDS["Rules Store"]
PDS["Pricing Data Source"]
ODS["Order Store"]
PG["Payment Provider"]
NS["Notification Service"]
end
OM --> VE
VE --> IA
VE --> RA
OM --> PE
PE --> PRA
OM --> OA
OM -.->|async| PM
OM -.->|async| NM
IA --> IDS
RA --> RDS
PRA --> PDS
OA --> ODS
PM --> PG
NM --> NS
class OM,PM,NM manager
class VE,PE engine
class IA,RA,PRA,OA accessor
class IDS,RDS,PDS,ODS,PG,NS external
The CheckoutExperience emits accumulated state. The OrderManager receives it and owns everything from there. Two components, one boundary, clean handoff. Neither needs to know anything about the other’s internal structure.
The OrderManager might expose a
checkout()method — or it might beprocessOrder(). The distinction matters. A consumer storefront conceptualizes this as a checkout. A vendor portal conceptualizes it as raising a purchase order. A subscription system conceptualizes it as a renewal. All of them can hand accumulated state to the same OrderManager and let it do its work. The backend doesn’t know it was a checkout. It doesn’t need to. Different Experiences, different vocabularies, different user journeys — same backend workflow. The interface vocabulary and the domain vocabulary are allowed to differ. What must align is the structure.
Change in Practice
A new requirement arrives: guest users can check out without creating an account.
This is a single, well-scoped requirement. It touches one concern: the journey sequencing. Watch what happens when it lands on each system.
In the Container/Hook System
The CheckoutPage container must branch on authentication state — it owns sequencing, so it now owns the conditional logic too. The hooks may need to handle unauthenticated API paths. The organisms may need to render differently for guest users. Each piece is a small addition, but they land across the structure.
flowchart TB
classDef changed fill:#d93025,color:#ffffff,stroke:#b31412,stroke-width:2px;
classDef affected fill:#e37400,color:#ffffff,stroke:#b06000,stroke-width:2px;
classDef unchanged fill:#f1f3f4,color:#9aa0a6,stroke:#dadce0,stroke-width:1px;
classDef unchangedExt fill:#f8f9fa,color:#c5c8cb,stroke:#ebebeb,stroke-width:1px;
CP["CheckoutPage (Container)"]:::changed
CS["CartSection (Organism)"]:::unchanged
PS["PaymentSection (Organism)"]:::affected
OS["OrderSummary (Organism)"]:::unchanged
UC["useCart()"]:::unchanged
UP["usePayment()"]:::affected
UO["useOrder()"]:::changed
CART_API["Cart API"]:::unchangedExt
PAY_API["Payment API"]:::unchangedExt
ORD_API["Order API"]:::unchangedExt
CP --> CS
CP --> PS
CP --> OS
CP --> UC
CP --> UP
CP --> UO
UC --> CART_API
UP --> PAY_API
UO --> ORD_API
Red — must change to handle guest state. Orange — potentially affected depending on whether guest users follow a different payment or data-fetching path. The change is conceptually simple — one new journey type — but structurally invasive because the container owns both sequencing and auth awareness simultaneously.
In the EBD System
No new Experience is created. The same CheckoutExperience gains a GuestDetailsFlow through configuration — email capture before payment. The existing Flows are untouched. The Interactions are untouched. Nothing in the existing checkout path changes at all.
flowchart TB
classDef experience fill:#0b57d0,color:#ffffff,stroke:#0842a0,stroke-width:2px;
classDef experienceNew fill:#d93025,color:#ffffff,stroke:#b31412,stroke-width:2px;
classDef flowNew fill:#d93025,color:#ffffff,stroke:#b31412,stroke-width:2px;
classDef flow fill:#f1f3f4,color:#9aa0a6,stroke:#dadce0,stroke-width:1px;
classDef interaction fill:#f1f3f4,color:#9aa0a6,stroke:#dadce0,stroke-width:1px;
classDef external fill:#f8f9fa,color:#c5c8cb,stroke:#ebebeb,stroke-width:1px;
CE["CheckoutExperience"]:::experienceNew
CRF["CartReviewFlow"]:::flow
PF["PaymentFlow"]:::flow
CF["ConfirmationFlow"]:::flow
GDF["GuestDetailsFlow"]:::flowNew
CII["CartItemInteraction"]:::interaction
PCI["PromoCodeInteraction"]:::interaction
CARDI["CardInputInteraction"]:::interaction
BAI["BillingAddressInteraction"]:::interaction
OSI["OrderSummaryInteraction"]:::interaction
CM["VBD Backend Workflow"]:::external
CE --> CRF
CE --> GDF
CE --> PF
CE --> CF
CRF --> CII
CRF --> PCI
PF --> CARDI
PF --> BAI
CF --> OSI
CE -->|accumulated state| CM
One new component added. The Experience composition changes — everything else is untouched.
The new journey is a new composition of existing parts. CartReviewFlow, PaymentFlow, and ConfirmationFlow do not change. Every Interaction is unchanged. The backend receives the same accumulated state regardless of which Experience sent it.
The Same Requirement, Side by Side
| Component | Container/Hook System | EBD |
|---|---|---|
| Journey coordination | Changes — container branches on auth state | Experience composition changes (GuestDetailsFlow added via configuration) |
| Step sequencing | Embedded in container — changes | Experience adds GuestDetailsFlow to sequence — existing Flows unchanged |
| Data capture step | New hook and organism section added to container | New GuestDetailsFlow added, isolated |
| Payment path | Potentially affected if guest users have different payment flow | PaymentFlow unchanged — reused as-is |
| Existing checkout | Modified — new conditional branches throughout | Untouched — runs exactly as before |
| Interactions | May need new rendering variants | No change |
If a Flow is just a composition of Interactions with a defined sequence and exit condition, it is already a data structure. A new step is not a code change — it is a registration. A new journey is not a refactor — it is a new composition of parts that already exist. The architecture does not just make change cheaper. It changes what change means.
Why This Matters
Frontend complexity does not usually arrive all at once. It accumulates. A new journey gets added to an existing container. A new API call gets added to an existing hook. A new variant gets conditionally rendered inside an existing organism. Each change is small. The structure quietly degrades.
EBD addresses this at the source. When journeys are Experiences, they compose rather than branch. When goals are Flows, they are reusable across contexts rather than rebuilt per page. When the backend boundary belongs to the Experience, API contract changes have one place to land.
The result is a frontend codebase that product teams can reason about in the same terms engineers use. A new journey is a new Experience. A new step is a new or reordered Flow. The scope of any change is visible before anyone opens a file.
That is the structural property EBD is designed to preserve. Not just clean code — predictable scope.