Architecture v3 — React SPA (Feature-First, No Entities)
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
│ ├── ui/ # Button, Input, Modal, Card
│ ├── api/ # fetch client, telemetry, useApiError
│ ├── lib/ # date, formatters, validators
│ └── config/ # env, constants, feature flags
│
├── pages/ # 1:1 with routes
│ └── <page>/
│ ├── ui/ # page component
│ └── model.ts # route params, page-level side effects
│
├── features/ # all business code
│ ├── user/ # foundation feature (data model)
│ │ ├── model.ts # types, schemas, hooks
│ │ ├── api.ts # CRUD (internal)
│ │ ├── ui/ # UserAvatar, UserBadge
│ │ ├── lib/ # pure helpers
│ │ └── index.ts # barrel (public contract)
│ │
│ ├── auth/ # domain feature (user action)
│ │ ├── model.ts
│ │ ├── api.ts
│ │ ├── ui/
│ │ └── index.ts
│ │
│ └── checkout/ # domain feature
│ ├── model.ts
│ ├── api.ts
│ ├── ui/
│ └── index.ts
No top-level store/ directory. All Zustand stores live in features.
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, hooks (useQuery, useMutation). Owns React Query. Owns telemetry for its operations. Owns feature-local Zustand stores (UI state only). | Yes — exported in barrel |
api.ts |
Semantic API calls (business intent, not URLs). No React Query. No side effects. | No — internal only |
ui/ |
Feature UI components. Presentation-only, props or hook driven. | Yes — exported in barrel |
lib/ |
Pure helpers, no side effects. | Yes — exported in barrel |
index.ts |
Barrel export. The feature’s public contract. | — |
Feature UI state (e.g., “is login dialog open”) lives as a Zustand store defined in model.ts. Cross-feature UI state lives in the feature whose domain owns it, exported via barrel for other features to consume.
Internal chain:
ui/ → model.ts → api.ts → shared/api/client → HTTP
4. Barrel Contract
index.ts is the feature’s public API. Other features import from @/features/<name> — never from internal paths.
Exported: types, hooks, 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.tshas CRUD. Exposed only via hooks in 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.tsfrom 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 Notifications
| Context | Notify? |
|---|---|
| Mutation error | Always |
| Query — initial blocking load | Yes |
| Query — background refetch | No |
| Query — polling | No |
| Network / auth fatal errors | Yes |
useApiError (shared/api/useApiError.ts): decides visibility, severity, formats ProblemDetails, deduplicates, traces user-visible errors. Components never handle errors.
12. Zustand
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 — in model.ts, 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.
Per-feature Error Boundaries. One crash must not break the page.
14. Component Design
- Pure helpers outside component body. No closure over state.
- Error Boundaries per feature, not only at root.
- Split when >1 responsibility.
- Destructure props. Related values as single object.
- No nested render functions. Extract to named component.
- Configuration objects for repetitive markup. Loop, don’t hardcode.
- Defaults in destructuring, not
Comp.defaultProps. - Trust React 19 Compiler. Skip
useMemo/useCallback/React.memounless 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 |
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: components PascalCase, hooks useCamelCase with use prefix, helpers camelCase. Fixed filenames: api.ts, model.ts, index.ts. 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
- Import only downward: Pages → Features → Shared.
- Pages own layout composition, not data.
- All business code in features. No separate entity concept.
- Foundation features depend only on shared. Domain features depend on shared + foundation. Domain→domain imports forbidden.
api.tsalways internal. Cross-feature consumption via barrel (types, hooks, lib, UI) only.- Hooks are the only layer allowed to call services.
- React Query owns all server state.
- Zustand never mirrors server data. All Zustand stores live in features (
model.ts), exported via barrel if cross-feature. No top-levelstore/. - Query keys feature-namespaced. Only owning feature invalidates. Cross-feature invalidation via exported invalidation hooks.
- Mutations always notify; queries selectively.
- Components never handle errors or orchestration.
- Error Boundary per feature UI.
- No feature call without telemetry. No user-visible error without trace.
- Application Insights SDK only in
shared/api/telemetry.ts. - Every feature has a barrel (
index.ts). Cross-feature imports go through barrels, never internal paths. - Cross-feature imports: types, hooks, lib, UI — never
api.ts. Domain features import from foundation features only. - Foundation features never depend on other features.
- Semantic HTML and accessibility not optional.
- If it cannot be traced, it must not exist.
- No exceptions.