EBD in 10 Minutes: Interface Architecture That Scales

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:

TierConcernResponsibility
ExperienceFunctionalComplete user journey, top-level state, backend communication
FlowStructuralGoal-directed sequence within a journey
InteractionAtomic surfaceA single atomic user action
UtilityCross-cuttingShared 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 be processOrder(). 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

ComponentContainer/Hook SystemEBD
Journey coordinationChanges — container branches on auth stateExperience composition changes (GuestDetailsFlow added via configuration)
Step sequencingEmbedded in container — changesExperience adds GuestDetailsFlow to sequence — existing Flows unchanged
Data capture stepNew hook and organism section added to containerNew GuestDetailsFlow added, isolated
Payment pathPotentially affected if guest users have different payment flowPaymentFlow unchanged — reused as-is
Existing checkoutModified — new conditional branches throughoutUntouched — runs exactly as before
InteractionsMay need new rendering variantsNo 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.

Leave a Comment


Stay in the loop.