1. Layers

3 layers. Import only downward.

Layer Purpose Can import
Pages Route entry points, layout composition Features, Shared
Features All business code — data models and user actions Features (barrel only), Shared
Shared Reusable code with no business affiliation Shared only
Pages → Features → Shared
         ↓
   Domain features → Foundation features → Shared

2. Project Structure

src/
├── app/                     # shell: routing, layouts, styles, assets
│   ├── routes/
│   ├── layouts/
│   ├── App.tsx
│   └── main.tsx
│
├── shared/                  # no business affiliation
│   ├── components/          #   one folder per component
│   │   ├── Button/          #     code, CSS module, test, barrel
│   │   ├── Input/
│   │   ├── Modal/
│   │   └── Card/
│   ├── api/                 #   fetch client, telemetry, useApiError
│   ├── lib/                 #   date, formatters, validators
│   └── config/              #   env, constants, feature flags
│
├── pages/                   # 1:1 with routes
│   └── <page>/
│       ├── <Page>.tsx       #   page component
│       ├── <Page>.module.css
│       ├── <Page>.test.tsx
│       └── model.ts         #   route params, page-level side effects
│
├── features/                # all business code
│   ├── user/                #   foundation feature (data model)
│   │   ├── model.ts         #     types, schemas, objects — no functions
│   │   ├── api.ts           #     CRUD (internal)
│   │   ├── hooks/           #     one hook per file
│   │   │   ├── useUser.ts
│   │   │   └── useUpdateUser.ts
│   │   ├── stores/          #     Zustand — only if state escapes the component
│   │   │   └── useDialogStore.ts
│   │   ├── components/      #     one folder per component
│   │   │   ├── UserAvatar/
│   │   │   │   ├── UserAvatar.tsx
│   │   │   │   ├── UserAvatar.module.css
│   │   │   │   ├── UserAvatar.test.tsx
│   │   │   │   └── index.ts
│   │   │   └── UserBadge/
│   │   ├── lib/             #     pure helpers
│   │   └── index.ts         #     barrel (public contract)
│   │
│   ├── auth/                #   domain feature (user action)
│   │   ├── model.ts
│   │   ├── api.ts
│   │   ├── hooks/
│   │   ├── components/
│   │   └── index.ts
│   │
│   └── checkout/            #   domain feature
│       ├── model.ts
│       ├── api.ts
│       ├── hooks/
│       ├── components/
│       └── index.ts

No top-level store/ directory. All Zustand stores live in features — one store per file in stores/.


3. Feature Internal Structure

Every feature has the same shape. No distinction between “entity” and “business” features in folder structure.

File Purpose Public?
model.ts Types, schemas, invariants, domain constants and factory objects. No functions — behavior lives in hooks/, stores/, lib/, or api.ts. Yes — exported in barrel
api.ts Semantic API calls (business intent, not URLs). No React Query. No side effects. No — internal only
hooks/ One hook per file (useUsers.ts, useUpdateUser.ts). React Query (useQuery, useMutation), semantic-intent telemetry (useTrace). The only layer allowed to call services. Yes — exported in barrel
stores/ Zustand stores, one store per file — only when state outlives the component or is consumed outside it (rare; default to local useState). Yes if cross-feature
components/ One folder per UI component: <Component>.tsx, <Component>.module.css, <Component>.test.tsx, index.ts. Presentation-only, props or hook driven. Yes — exported in barrel
lib/ Pure helpers (formatters, validators), no side effects. Yes — exported in barrel
index.ts Barrel export. The feature’s public contract.

Default: component-scoped UI state uses local useState inside the component. A Zustand store in stores/ is only for state that outlives the component or is consumed outside it (e.g., a dialog’s open state read by a sidebar in another feature). Cross-feature UI state lives in the feature whose domain owns it, exported via barrel for other features to consume.

Internal chain:

components/ → hooks/ → api.ts → shared/api/client → HTTP

model.ts is data only — imported, never called. Components and hooks read its types, schemas, and constants.

shared/api/client is transport: base URL, auth, correlation ID, failure normalization. api.ts is intent: the endpoint, the payload, and the mapping into model.ts. Each function carries a stable operation name for useTrace, and it is the only place backend shape changes land. Because the barrel never exports it (section 4), no other feature can bypass caching, invalidation, and tracing to call it, which is what makes rule 6 checkable.


4. Barrel Contract

index.ts is the feature’s public API. Other features import from @/features/<name> — never from internal paths.

Exported: types, hooks, stores (if cross-feature), lib functions, public UI components. Not exported: api.ts functions, internal sub-components, internal hooks.


5. Feature Types

Two conceptual roles. Same structure. Different dependency rules.

Foundation features — data models (“what things are”). Naming heuristic: nouns (user, order, product). Not a hard rule.

  • Depend only on shared/. Never on other features.
  • Own the types. Other features consume, never duplicate.
  • api.ts has CRUD. Exposed only via hooks (hooks/), re-exported by barrel.
  • Export entity UI components (avatars, badges, cards) in the barrel for cross-feature reuse.

Domain features — user actions (“what users do”). Naming heuristic: domain actions (auth, checkout, dashboard). Not a hard rule.

  • Depend on shared/ + foundation features (via barrel).
  • Import types, hooks, lib, and UI components from foundation features.
  • Never import from other domain features. If two domain features need shared logic, extract it to a foundation feature.
  • Never import api.ts from any feature.

Extract to a foundation feature when a type or hook is needed by 2 or more domain features.


6. Cross-Feature Import Rules

Import Domain → Foundation Domain → Domain Foundation → any feature
Types (from barrel)
Hooks (from barrel)
Lib functions (from barrel)
UI components (from barrel)
api.ts
Internal paths (@/features/x/api)

Domain features import from foundation features only. Domain→domain imports are forbidden — extract shared logic to a foundation feature. Foundation features depend on shared/ only, never on other features.

Lint enforcement: ESLint no-restricted-imports blocking @/features/*/api and cross-domain imports.


7. Pages

Route entry points. 1:1 with URLs.

Contains: page UI component, route param extraction, page-level side effects (scroll-to-top, analytics page-view). Never contains: data fetching, React Query, Zustand, API calls, business logic, reusable UI.

Pages compose features and shared UI. Never import from other pages.


8. API Client (shared/api/client.ts)

Single fetch wrapper. Responsibilities: base URL, auth token injection, correlation ID, request execution, normalize all failures into ApiError, dependency telemetry. Never leaks raw errors.

ApiError: { type: 'network'|'auth'|'validation'|'server', status?, title, detail?, errors? }


9. Telemetry

Azure Application Insights. SDK usage only in shared/api/telemetry.ts.

Must trace: query start/failure, mutation start/success/failure, HTTP dependencies, user-visible notifications, auth/network fatal errors. No silent paths.

Ownership: Feature hooks own semantic-intent tracing (useTrace calls). shared/api/client owns HTTP dependency telemetry. useApiError owns user-visible error tracing.


10. React Query

Queries: read-only. Cached globally. Key format: ['<feature>', '<resource>', ...]. Owning feature manages invalidation.

Mutations: state-changing. User-initiated. Must emit telemetry (start, success, failure), invalidate queries, notify on error via useApiError. Both telemetry and notification fire — neither is optional.

Cross-feature invalidation: When a mutation in one feature affects another feature’s queries, the owning feature exports an invalidation hook in the barrel (e.g., useInvalidateUser). The mutating feature calls that hook. Never reach into another feature’s queryClient calls directly.


11. Error Handling

A component may render a state. It never decides what an error means.

Stage Owner
Classify shared/api/client (raw failure becomes a typed ApiError)
Decide, notify useApiError (visibility, severity, ProblemDetails, dedupe, trace)
Render component (branch only, never reads error.type or composes copy)
Contain Error Boundary per feature (render and lifecycle crashes)

Notification rules:

Context Notify?
Mutation error Always
Query — initial blocking load Yes
Query — background refetch No
Query — polling No
Network / auth fatal errors Yes

Rules:

  • Visibility is centralized because rule 15 holds only if one file decides.
  • useApiError is called from hooks, never from components.
  • A query failure renders the branch the hook produces.
  • Validation errors map to form fields in the mutation hook, the only layer that sees ApiError.
  • Crash containment is a boundary, not a handler: one primitive in shared/components/, instantiated per feature with a name for telemetry.
  • Invalidation, navigation, and notification are sequencing, so they live in the hook. A component calls one function.
  • Never in a component: try/catch, error.type switches, toast calls, retry counters, multi-step await chains.

12. Local State vs Zustand

Default: local useState. Component-scoped state that lives and dies with the component stays in useState. This covers the majority of UI state — form inputs, toggle flags, selected items in a list, any data that no other component needs.

Zustand is the exception, not the rule. Reach for it only when:

  • State must outlive the component (e.g., a multi-step wizard spanning route changes).
  • State is consumed outside the component (e.g., a sidebar that reads the current page context).

Zustand scope: Only UI state (dialogs, steps, filters), ephemeral cross-feature state. Never: server entities, lists, cached data, error state, retry counters, telemetry state.

All Zustand stores live in features — one store per file in stores/, exported via barrel if cross-feature. No top-level store/ directory. React Query is the only source of server state.


13. Components

Presentation-only. Receive data via hooks or props. No API calls, no mutations, no orchestration, no error handling, no telemetry. Display loading/empty/success states. Error policy lives in section 11; a component renders the branch that policy produces.

One folder per component: <Component>.tsx, <Component>.module.css, <Component>.test.tsx, index.ts. Styles never live outside the component folder. Global styles (tokens, resets) live in app/.

Per-feature Error Boundaries. One crash must not break the page.


14. Component Design

  1. Pure helpers outside component body. No closure over state.
  2. Error Boundaries per feature, not only at root.
  3. Split when >1 responsibility.
  4. Destructure props. Related values as single object.
  5. No nested render functions. Extract to named component.
  6. Configuration objects for repetitive markup. Loop, don’t hardcode.
  7. Defaults in destructuring, not Comp.defaultProps.
  8. Trust React 19 Compiler. Skip useMemo/useCallback/React.memo unless flagged.

15. Accessibility

Semantic HTML (<nav>, <main>, <section>, <article>, <aside>). Icon-only buttons: aria-label. Form inputs: associated <label>. Focus: modals trap, route changes move to heading. Images: alt required. Mantine defaults pass AA; verify overrides.


16. Testing

Layer Type What Mock
Features Integration + unit Hook → api → client chain; pure types/lib; Zustand state transitions HTTP (MSW); nothing for pure
Pages Component / E2E Render with mocked hooks; critical flows Feature hooks, router
Shared Unit Utilities, formatters, config Nothing

Component tests are colocated: <Component>.test.tsx sits next to <Component>.tsx in the component folder.

Keep Vitest tests narrow: one target module, or a feature’s own hook → api → client chain. Mock the boundary, not the layers inside it (HTTP via MSW for feature tests, imports for pure units).


17. Routing

Wouter. One route → one page. Routes in app/routes/ map URLs to pages. Zero business logic in routing. Auth via PrivateRoute wrapper.


18. Performance

Code splitting: route-level React.lazy() + <Suspense>; feature-level lazy load on user intent. Bundle budget: no chunk > 200 KB uncompressed. Prefetch via queryClient.prefetchQuery or link hover. Monitor LCP/INP/CLS. SPA-first; SSR not assumed but achievable.


19. DevEx

Absolute imports only (@/ alias). Import order: React → libs → shared → features → pages → styles. Barrel exports per feature. File naming: component folders and components PascalCase, CSS modules <Component>.module.css, hooks useCamelCase with use prefix, helpers camelCase. Fixed filenames: api.ts, model.ts, index.ts. One hook per file in hooks/, one Zustand store per file in stores/. 2-space tabs. Format on save.


20. Tool Stack

Concern Tool
Server state React Query (TanStack)
UI state Zustand
Routing Wouter
UI components Mantine
Telemetry Azure Application Insights
HTTP native fetch via shared/api/client.ts
Testing Vitest + MSW + Playwright

Replacements only affect shared/. Features and pages unaffected.


21. Hard Rules

  1. Import only downward: Pages → Features → Shared.
  2. Pages own layout composition, not data.
  3. All business code in features. No separate entity concept.
  4. Foundation features depend only on shared. Domain features depend on shared + foundation. Domain→domain imports forbidden.
  5. api.ts always internal. Cross-feature consumption via barrel (types, hooks, lib, UI) only.
  6. Hooks are the only layer allowed to call services.
  7. model.ts is data only — types, schemas, objects. No functions.
  8. React Query owns all server state.
  9. Zustand never mirrors server data. All Zustand stores live in features (stores/, one store per file), exported via barrel if cross-feature. No top-level store/.
  10. Query keys feature-namespaced. Only owning feature invalidates. Cross-feature invalidation via exported invalidation hooks.
  11. Mutations always notify; queries selectively.
  12. Components never handle errors or orchestration.
  13. Error Boundary per feature UI.
  14. No feature call without telemetry. No user-visible error without trace.
  15. Application Insights SDK only in shared/api/telemetry.ts.
  16. Every feature has a barrel (index.ts). Cross-feature imports go through barrels, never internal paths.
  17. Cross-feature imports: types, hooks, lib, UI — never api.ts. Domain features import from foundation features only.
  18. Foundation features never depend on other features.
  19. Semantic HTML and accessibility not optional.
  20. If it cannot be traced, it must not exist.
  21. No exceptions.