chore: agentic upgrade
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
---
|
||||
name: feature-sliced-design
|
||||
description: >
|
||||
Official Feature-Sliced Design (FSD) v2.1 skill for applying the methodology
|
||||
to frontend projects. Use when the task involves organizing project structure
|
||||
with FSD layers, deciding where code belongs, placing static assets (images,
|
||||
icons, fonts, PDFs), grouping closely related slices, defining public APIs
|
||||
and import boundaries, resolving cross-imports or evaluating the @x pattern,
|
||||
deciding whether to create or remove an entity, evaluating whether the
|
||||
entities layer is needed at all, deciding whether logic should remain local
|
||||
or be extracted, migrating from FSD v2.0 or a non-FSD codebase, integrating
|
||||
FSD with frameworks (Next.js App Router and Pages Router, Nuxt, Vite,
|
||||
Astro), or implementing common patterns such as authentication, API
|
||||
handling, Redux, and TanStack Query (React Query) within FSD.
|
||||
---
|
||||
|
||||
# Feature-Sliced Design (FSD) v2.1
|
||||
|
||||
> **Source**: [fsd.how](https://fsd.how) | Strictness can be adjusted based on
|
||||
> project scale and team context.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Philosophy & Layer Overview
|
||||
|
||||
FSD v2.1 core principle: **"Start simple, extract when needed."**
|
||||
|
||||
Place code in `pages/` first. Duplication across pages is acceptable and does
|
||||
not automatically require extraction to a lower layer. Extract only when the
|
||||
same code is currently being used in multiple places (not hypothetically),
|
||||
the usages do not always change together, and the boundary has a focused
|
||||
responsibility.
|
||||
|
||||
**Not all layers are required.** Most projects can start with only `shared/`,
|
||||
`pages/`, and `app/`. Add `widgets/`, `features/`, `entities/` only when they
|
||||
provide clear value. Do not create empty layer folders "just in case."
|
||||
|
||||
FSD uses 6 standardized layers, listed here from highest to lowest:
|
||||
|
||||
```text
|
||||
app/ → App initialization, providers, routing
|
||||
pages/ → Route-level composition, owns its own logic
|
||||
widgets/ → Large composite UI blocks reused across multiple pages
|
||||
features/ → Reusable user interactions (only when used in 2+ places)
|
||||
entities/ → Reusable business domain models (only when used in 2+ places)
|
||||
shared/ → Infrastructure with no business logic (UI kit, utils, API client)
|
||||
```
|
||||
|
||||
**Import rule**: A module may only import from layers strictly below it.
|
||||
Cross-imports between slices on the same layer are forbidden.
|
||||
|
||||
```typescript
|
||||
// ✅ Allowed
|
||||
import { Button } from "@/shared/ui/Button"; // features → shared
|
||||
import { useUser } from "@/entities/user"; // pages → entities
|
||||
|
||||
// ❌ Violation
|
||||
import { loginUser } from "@/features/auth"; // entities → features
|
||||
import { likePost } from "@/features/like-post"; // features → features
|
||||
```
|
||||
|
||||
**Note**: The `processes/` layer is **deprecated** in v2.1. For migration
|
||||
details, read `references/migration-guide.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision Framework
|
||||
|
||||
When writing new code, follow this tree:
|
||||
|
||||
**Step 1: Where is this code used?**
|
||||
|
||||
- Used in only one page → keep it in that `pages/` slice.
|
||||
- Used in 2+ pages but duplication is manageable → keeping separate copies
|
||||
in each page is also valid.
|
||||
- An entity or feature used in only one page → keep it in that page
|
||||
(Steiger: `insignificant-slice`).
|
||||
|
||||
**Step 2: Is it reusable infrastructure with no business logic?**
|
||||
|
||||
- UI components → `shared/ui/`
|
||||
- Utility functions → `shared/lib/`
|
||||
- API client, route constants → `shared/api/` or `shared/config/`
|
||||
- Auth tokens, session management → `shared/auth/`
|
||||
- CRUD operations → `shared/api/`
|
||||
|
||||
**Step 3: Is it a complete user action currently used in multiple places,
|
||||
with stable boundaries?**
|
||||
|
||||
- Yes → `features/`
|
||||
- Uncertain, single use, or speculative reuse → keep in the page.
|
||||
|
||||
**Step 4: Is it a business domain model currently used in multiple places,
|
||||
with stable boundaries?**
|
||||
|
||||
- Yes → `entities/`
|
||||
- Uncertain, single use, or speculative reuse → keep in the page.
|
||||
|
||||
**Step 5: Is it app-wide configuration?**
|
||||
|
||||
- Global providers, router, theme → `app/`
|
||||
|
||||
**Golden Rule: When in doubt, keep it in `pages/`. Extract only when the
|
||||
same code is actively used in multiple places and the boundary is clear.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Quick Placement Table
|
||||
|
||||
| Scenario | Single use | Confirmed multi-use |
|
||||
| --------------------- | ------------------------------------------- | ------------------------------------- |
|
||||
| User profile form | `pages/profile/ui/ProfileForm.tsx` | `features/profile-form/` |
|
||||
| Product card | `pages/products/ui/ProductCard.tsx` | `entities/product/ui/ProductCard.tsx` |
|
||||
| Product data fetching | `pages/product-detail/api/fetch-product.ts` | `entities/product/api/` |
|
||||
| Auth token/session | `shared/auth/` (always) | `shared/auth/` (always) |
|
||||
| Auth login form | `pages/login/ui/LoginForm.tsx` | `features/auth/` |
|
||||
| CRUD operations | `shared/api/` (always) | `shared/api/` (always) |
|
||||
| Generic Card layout | | `shared/ui/Card/` |
|
||||
| Modal manager | | `shared/ui/modal-manager/` |
|
||||
| Modal content | `pages/[page]/ui/SomeModal.tsx` | |
|
||||
| Date formatting util | | `shared/lib/format-date.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Architectural Rules (MUST)
|
||||
|
||||
These rules are the foundation of FSD. Violations weaken the architecture.
|
||||
If you must break a rule, ensure it is an intentional design decision and
|
||||
document the reason in code (a comment or ADR).
|
||||
|
||||
### 4-1. Import only from lower layers
|
||||
|
||||
`app → pages → widgets → features → entities → shared`.
|
||||
Upward imports and cross-imports between slices on the same layer are
|
||||
forbidden.
|
||||
|
||||
### 4-2. Public API: every slice exports through index.ts
|
||||
|
||||
External consumers may only import from a slice's `index.ts`. Direct imports
|
||||
of internal files are forbidden.
|
||||
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
import { LoginForm } from "@/features/auth";
|
||||
|
||||
// ❌ Violation: bypasses public API
|
||||
import { LoginForm } from "@/features/auth/ui/LoginForm";
|
||||
```
|
||||
|
||||
**Shared layer:** Shared has no slices. Define a separate public API per
|
||||
segment (`shared/ui/index.ts`, `shared/api/index.ts`, etc.) rather than
|
||||
one top-level `shared/index.ts`. This keeps imports from Shared
|
||||
organized by intent.
|
||||
|
||||
**RSC / meta-framework exception:** Split entry points
|
||||
(`index.client.ts`, `index.server.ts`) are permitted. Details and rules:
|
||||
`references/framework-integration.md`.
|
||||
|
||||
### 4-3. No cross-imports between slices on the same layer
|
||||
|
||||
If two slices on the same layer need to share logic, follow the resolution
|
||||
order in Section 7. Do not create direct imports.
|
||||
|
||||
### 4-4. Domain-based file naming (no desegmentation)
|
||||
|
||||
Name files after the business domain they represent, not their technical role.
|
||||
Technical-role names like `types.ts`, `utils.ts`, `helpers.ts` mix unrelated
|
||||
domains in a single file and reduce cohesion.
|
||||
|
||||
```text
|
||||
// ❌ Technical-role naming
|
||||
model/types.ts ← Which types? User? Order? Mixed?
|
||||
model/utils.ts
|
||||
|
||||
// ✅ Domain-based naming
|
||||
model/user.ts ← User types + related logic
|
||||
model/order.ts ← Order types + related logic
|
||||
api/fetch-profile.ts ← Clear purpose
|
||||
```
|
||||
|
||||
### 4-5. No business logic in shared/
|
||||
|
||||
Shared contains only infrastructure: UI kit, utilities, API client setup,
|
||||
route constants, assets. Business calculations, domain rules, and workflows
|
||||
belong in `entities/` or higher layers.
|
||||
|
||||
```typescript
|
||||
// ❌ Business logic in shared
|
||||
// shared/lib/userHelpers.ts
|
||||
export const calculateUserReputation = (user) => { ... };
|
||||
|
||||
// ✅ Move to the owning domain
|
||||
// entities/user/lib/reputation.ts
|
||||
export const calculateUserReputation = (user) => { ... };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Recommendations (SHOULD)
|
||||
|
||||
### 5-1. Pages First: place code where it is used
|
||||
|
||||
Place code in `pages/` first. Extract to lower layers only when truly needed.
|
||||
Extraction is a design decision that affects the whole project, so the
|
||||
threshold should be high.
|
||||
|
||||
**What stays in pages:**
|
||||
|
||||
- Large UI blocks used only in one page
|
||||
- Page-specific forms, validation, data fetching, state management
|
||||
- Page-specific business logic and API integrations
|
||||
- Code that looks reusable but is simpler to keep local
|
||||
|
||||
**Evolution pattern:** Start with everything in `pages/profile/`. When the
|
||||
same user data is being consumed by another page (not hypothetically),
|
||||
extract the shared model to `entities/user/`. Keep page-specific API calls
|
||||
and UI in the page.
|
||||
|
||||
### 5-2. Be conservative with entities
|
||||
|
||||
The entities layer is highly accessible (almost every other layer can import
|
||||
from it), so changes propagate widely.
|
||||
|
||||
1. **Start without entities.** `shared/` + `pages/` + `app/` is valid FSD.
|
||||
Thin-client apps rarely need entities.
|
||||
2. **Do not split slices prematurely.** Keep code in pages. Extract to
|
||||
entities only when the same code is currently used by multiple
|
||||
consumers and the boundary is stable.
|
||||
3. **Business logic does not automatically require an entity.** Keeping types
|
||||
in `shared/api` and logic in the current slice's `model/` segment may
|
||||
be sufficient.
|
||||
4. **Place CRUD in `shared/api/`.** CRUD is infrastructure, not entities.
|
||||
5. **Place auth data in `shared/auth/` or `shared/api/`.** Tokens and login
|
||||
DTOs are auth-context-dependent and rarely reused outside authentication.
|
||||
|
||||
For detailed guidance on keeping the entities layer clean (when to skip
|
||||
it entirely, how to isolate business contexts, why CRUD belongs in
|
||||
`shared/api`), see `references/excessive-entities.md`.
|
||||
|
||||
### 5-3. Start with minimal layers
|
||||
|
||||
```text
|
||||
// ✅ Valid minimal FSD project
|
||||
src/
|
||||
app/ ← Providers, routing
|
||||
pages/ ← All page-level code
|
||||
shared/ ← UI kit, utils, API client
|
||||
|
||||
// Add layers only when an actual use case requires them:
|
||||
// + widgets/ ← UI blocks currently reused across multiple pages
|
||||
// + features/ ← User interactions currently reused across multiple pages
|
||||
// + entities/ ← Domain models currently reused across pages or features
|
||||
```
|
||||
|
||||
### 5-4. Validate with the Steiger linter
|
||||
|
||||
[Steiger](https://github.com/feature-sliced/steiger) is the official FSD
|
||||
linter. Key rules:
|
||||
|
||||
- **`insignificant-slice`**: Suggests merging an entity/feature into its page
|
||||
if only one page uses it.
|
||||
- **`excessive-slicing`**: Suggests merging or grouping when a layer has too
|
||||
many slices.
|
||||
|
||||
```bash
|
||||
npm install -D @feature-sliced/steiger
|
||||
npx steiger src
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Anti-patterns (AVOID)
|
||||
|
||||
- **Do not create entities prematurely.** Data structures used in only one
|
||||
place belong in that place.
|
||||
- **Do not put CRUD in entities.** Use `shared/api/`. Consider entities only
|
||||
for complex transactional logic.
|
||||
- **Do not create a `user` entity just for auth data.** Tokens and login DTOs
|
||||
belong in `shared/auth/` or `shared/api/`.
|
||||
- **Do not abuse `@x`.** It is a necessary compromise, not a recommended
|
||||
pattern. The notation is for the entities layer only, and only when
|
||||
boundary merge is genuinely impossible. Features and widgets handle
|
||||
cross-imports through strategies A–D (see Section 7).
|
||||
- **Do not extract single-use code.** A feature or entity used by only one
|
||||
page should stay in that page.
|
||||
- **Do not use technical-role file names.** Use domain-based names
|
||||
(see Rule 4-4).
|
||||
- **Be cautious adding UI to entities.** Entity UI tempts cross-imports from
|
||||
other entities. If you add UI segments to entities, only import them from
|
||||
higher layers (features, widgets, pages), never from other entities.
|
||||
- **Do not create god slices.** Slices with excessively broad responsibilities
|
||||
should be split into focused slices (e.g., split `user-management/` into
|
||||
`auth/`, `profile-edit/`, `password-reset/`).
|
||||
- **Do not create a top-level `assets/` segment.** Place static assets next
|
||||
to the code that uses them. See `references/asset-handling.md`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cross-Import Resolution
|
||||
|
||||
Cross-imports are a code smell, not an absolute prohibition. The right
|
||||
strategy depends on the layer and the situation.
|
||||
|
||||
### Entities layer: prefer boundary merge, @x is last resort
|
||||
|
||||
Cross-imports in `entities` are usually caused by splitting entities too
|
||||
granularly. Before reaching for `@x`, consider whether the boundaries
|
||||
should be merged.
|
||||
|
||||
`@x` is a **necessary compromise, not a recommended approach**. Use it only
|
||||
when boundaries genuinely cannot be merged, and document why. Overuse locks
|
||||
entity boundaries together and increases refactoring cost.
|
||||
|
||||
### Features and widgets: four strategies (A, B, C, D)
|
||||
|
||||
In `features` and `widgets`, choose based on context:
|
||||
|
||||
- **Strategy A: Slice merge.** Two slices always change together → merge.
|
||||
- **Strategy B: Push to entities.** Shared domain logic → move to
|
||||
`entities/`, keep UI in features/widgets.
|
||||
- **Strategy C: Compose from upper layer (IoC).** The parent (pages or app)
|
||||
imports both slices and connects them via render props, slots, or DI.
|
||||
- **Strategy D: Public API access.** When reuse is genuinely unavoidable,
|
||||
allow it only through the slice's `index.ts`. Never reach into `model/`,
|
||||
`store/`, or internal files.
|
||||
|
||||
The `@x` notation is for the entities layer only. Features and widgets use
|
||||
strategies A–D above.
|
||||
|
||||
### Strictness depends on project context
|
||||
|
||||
Cross-imports are dependencies that are generally best avoided, but
|
||||
sometimes used intentionally. Strictness varies by project context:
|
||||
|
||||
- **Early-stage products** with heavy experimentation: allowing some
|
||||
cross-imports may be a pragmatic speed trade-off.
|
||||
- **Long-lived or regulated systems** (fintech, large-scale services):
|
||||
stricter boundaries pay off in maintainability and stability.
|
||||
|
||||
If a cross-import is introduced, treat it as a deliberate choice and
|
||||
document the reasoning in code (a comment explaining why other strategies
|
||||
do not apply).
|
||||
|
||||
For detailed code examples of each strategy, read
|
||||
`references/cross-import-patterns.md`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Segments & Structure Rules
|
||||
|
||||
### Standard segments
|
||||
|
||||
Segments group code within a slice by technical purpose:
|
||||
|
||||
- **`ui/`**: UI components, styles, display-related code
|
||||
- **`model/`**: Data models, state stores, business logic, validation
|
||||
- **`api/`**: Backend integration, request functions, API-specific types
|
||||
- **`lib/`**: Internal utility functions for this slice
|
||||
- **`config/`**: Configuration, feature flags
|
||||
|
||||
### Layer structure rules
|
||||
|
||||
- **App and Shared**: No slices, organized directly by segments. Segments
|
||||
within these layers may import from each other.
|
||||
- **Pages, Widgets, Features, Entities**: Slices first, then segments inside
|
||||
each slice.
|
||||
- **Slice groups (optional)**: A group folder may contain related slices on
|
||||
the same layer for navigation purposes only. The group has no segments and
|
||||
no public API. See `references/layer-structure.md` for details.
|
||||
|
||||
### File naming within segments
|
||||
|
||||
Always use domain-based names that describe what the code is about:
|
||||
|
||||
```text
|
||||
model/user.ts ← User types + logic + store
|
||||
model/order.ts ← Order types + logic + store
|
||||
api/fetch-profile.ts ← Profile fetching
|
||||
api/update-settings.ts ← Settings update
|
||||
```
|
||||
|
||||
If a segment has only one domain concern, the filename may match the slice
|
||||
name (e.g., `features/auth/model/auth.ts`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Shared Layer Guide
|
||||
|
||||
Shared contains infrastructure with **no business logic**. It is organized by
|
||||
segments only (no slices). Segments within shared may import from each other.
|
||||
|
||||
**Allowed in shared:**
|
||||
|
||||
- `ui/`: UI kit (Button, Input, Modal, Card)
|
||||
- `lib/`: Utilities (formatDate, debounce, classnames)
|
||||
- `api/`: API client, route constants, CRUD helpers, base types
|
||||
- `auth/`: Auth tokens, login utilities, session management
|
||||
- `config/`: Environment variables, app settings
|
||||
- `assets/`: Branding assets shared across the app (use sparingly; see
|
||||
`references/asset-handling.md`)
|
||||
|
||||
Shared **may** contain application-aware code (route constants, API endpoints,
|
||||
branding assets, common types). It must **never** contain business logic,
|
||||
feature-specific code, or entity-specific code.
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick Reference
|
||||
|
||||
- **Import direction**: `app → pages → widgets → features → entities → shared`
|
||||
- **Minimal FSD**: `app/` + `pages/` + `shared/`
|
||||
- **Create entities when**: the same business domain model is currently
|
||||
used across multiple pages, features, or widgets, with stable
|
||||
boundaries.
|
||||
- **Create features when**: the same user interaction is currently used
|
||||
across multiple pages or widgets, with stable boundaries.
|
||||
- **Breaking rules**: Only as an intentional design choice. Document the
|
||||
reason in code (comment or ADR).
|
||||
- **Cross-import resolution (entities)**: Merge boundaries first; `@x` is a
|
||||
necessary compromise, not recommended.
|
||||
- **Cross-import resolution (features/widgets)**: Strategy A (merge), B
|
||||
(push to entities), C (compose from upper layer), or D (Public API).
|
||||
The `@x` notation is for entities only.
|
||||
- **File naming**: Domain-based (`user.ts`, `order.ts`). Never technical-role
|
||||
(`types.ts`, `utils.ts`).
|
||||
- **Asset placement**: Place next to the code that uses them; reuse goes to
|
||||
`shared/ui/`; global stylesheets and fonts go to `app/`.
|
||||
- **Slice groups**: Optional navigation aid for large layers; group folder
|
||||
has no segments and no public API.
|
||||
- **Processes layer**: Deprecated. See `references/migration-guide.md`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Conditional References
|
||||
|
||||
Read the following reference files **only** when the specific situation applies.
|
||||
Do **not** preload all references.
|
||||
|
||||
- **When creating, reviewing, or reorganizing folder and file structure** for
|
||||
FSD layers and slices, including grouping closely related slices into a
|
||||
parent folder for navigation (e.g., "set up project structure", "where does
|
||||
this folder go", "how do I group these payment entities"):
|
||||
→ Read `references/layer-structure.md`
|
||||
|
||||
- **When resolving cross-import issues** between slices on the same layer,
|
||||
evaluating the `@x` pattern, choosing between Strategy A/B/C/D for
|
||||
features and widgets, or deciding whether boundaries should be merged:
|
||||
→ Read `references/cross-import-patterns.md`
|
||||
|
||||
- **When deciding whether to create or remove an entity**, dealing with too
|
||||
many entities, evaluating whether to skip the entities layer entirely,
|
||||
placing CRUD operations, deciding where authentication data belongs, or
|
||||
isolating business contexts to avoid `@x` chains:
|
||||
→ Read `references/excessive-entities.md`
|
||||
|
||||
- **When deciding where to place static assets** (images, icons, fonts,
|
||||
PDFs, stylesheets) for a single slice, for sharing across slices, or
|
||||
globally:
|
||||
→ Read `references/asset-handling.md`
|
||||
|
||||
- **When migrating** from FSD v2.0 to v2.1, converting a non-FSD codebase to
|
||||
FSD, or deprecating the processes layer:
|
||||
→ Read `references/migration-guide.md`
|
||||
|
||||
- **When integrating FSD with a specific framework** (Next.js with App Router
|
||||
or Pages Router, Nuxt, Vite, CRA, Astro) for wiring routes to FSD pages,
|
||||
placing middleware/instrumentation files, structuring API route handlers,
|
||||
or configuring path aliases:
|
||||
→ Read `references/framework-integration.md`
|
||||
|
||||
- **When implementing concrete code patterns** for authentication, API request
|
||||
handling, type definitions, or state management (Redux, TanStack Query /
|
||||
React Query, including query factories, infinite scroll, Suspense mode,
|
||||
and `useMutationState`) within FSD structure:
|
||||
→ Read `references/practical-examples.md`
|
||||
Note: If you already loaded `layer-structure.md` in this conversation,
|
||||
avoid loading this file simultaneously. Address structure first, then load
|
||||
patterns in a follow-up step if needed.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Asset Handling
|
||||
|
||||
How to place static assets (images, icons, fonts, PDFs, stylesheets) inside an
|
||||
FSD project. Assets follow the same placement rules as code: group by use
|
||||
case, not by type, and keep them next to the code that uses them.
|
||||
|
||||
> **Caution:** A custom top-level `assets` segment that aggregates all static
|
||||
> files is **not recommended**. It violates the FSD principles of high
|
||||
> cohesion and locality of changes. Place assets where they are used.
|
||||
|
||||
---
|
||||
|
||||
## Decision Tree
|
||||
|
||||
1. **Used by exactly one slice?** Keep the asset inside that slice, usually
|
||||
in the `ui/` segment, or in `model/` if it is part of business logic.
|
||||
2. **Reused across the app (icons, placeholder images)?** Move to
|
||||
`shared/ui/`.
|
||||
3. **Global stylesheet, font, or app-level resource?** Place in the `app/`
|
||||
layer (`app/styles/`, `app/fonts/`).
|
||||
4. **Served as-is by the bundler (favicon, robots.txt)?** Use the framework's
|
||||
`public/` folder. The `public/` folder is not part of FSD and does not
|
||||
conflict with FSD layers.
|
||||
|
||||
---
|
||||
|
||||
## Slice-specific Assets
|
||||
|
||||
When an asset belongs to one page, widget, or feature, keep it inside that
|
||||
slice. The asset lives next to the component that renders it:
|
||||
|
||||
```text
|
||||
pages/
|
||||
home/
|
||||
ui/
|
||||
hero-image.jpg ← Used only by HomePage
|
||||
HomePage.tsx
|
||||
index.ts
|
||||
```
|
||||
|
||||
If a slice uses many static images, group them in a subfolder of `ui/`:
|
||||
|
||||
```text
|
||||
pages/
|
||||
home/
|
||||
ui/
|
||||
previews/
|
||||
cake.jpg
|
||||
pizza.jpg
|
||||
sushi.jpg
|
||||
HomePage.tsx
|
||||
index.ts
|
||||
```
|
||||
|
||||
### Non-UI Assets
|
||||
|
||||
Some assets are not part of the UI but are coupled to business logic. For
|
||||
example, a PDF template used to generate invoices. Place these in the
|
||||
`model/` segment alongside the logic that consumes them, not in `ui/`:
|
||||
|
||||
```text
|
||||
features/
|
||||
billing/
|
||||
model/
|
||||
invoice-template.pdf ← Coupled to create-invoice.ts
|
||||
create-invoice.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
The principle is locality of changes: if you delete the slice, every file it
|
||||
owns goes with it. An asset that lives in business logic should sit next to
|
||||
that logic.
|
||||
|
||||
---
|
||||
|
||||
## Shared Assets
|
||||
|
||||
When the same asset appears across multiple slices, move it to `shared/ui/`.
|
||||
Place reusable images in a topical subfolder, or place a single asset next to
|
||||
the shared component that uses it:
|
||||
|
||||
```text
|
||||
shared/
|
||||
ui/
|
||||
placeholders/ ← Reused placeholder images
|
||||
cake.jpg
|
||||
pizza.jpg
|
||||
Dropdown.tsx
|
||||
chevron.svg ← Used only by Dropdown, kept next to it
|
||||
```
|
||||
|
||||
A single icon used by exactly one component in the UI kit stays next to that
|
||||
component. A library of icons or images reused across many components goes
|
||||
in a topical subfolder.
|
||||
|
||||
---
|
||||
|
||||
## Global Assets
|
||||
|
||||
Global stylesheets and fonts belong in the `app/` layer because they are
|
||||
imported by the application entrypoint, not by individual slices:
|
||||
|
||||
```text
|
||||
app/
|
||||
styles/
|
||||
reset.css
|
||||
global.css
|
||||
fonts/
|
||||
inter.woff2
|
||||
main.ts
|
||||
```
|
||||
|
||||
Theme variables, CSS resets, and font registrations are app-wide concerns.
|
||||
They bootstrap the application's visual layer the same way providers
|
||||
bootstrap the runtime layer.
|
||||
|
||||
---
|
||||
|
||||
## Public Folder
|
||||
|
||||
Most bundlers expose a `public/` folder at the project root. Files here are
|
||||
served as-is, without bundling or hashing.
|
||||
|
||||
- Vite, Next.js, Nuxt: `public/` at the project root.
|
||||
- Astro: `public/` at the project root (path is fixed and cannot be changed).
|
||||
|
||||
`public/` is not part of FSD. It does not collide with FSD layers and does
|
||||
not need to live under `src/`. Use it for files that must be served at fixed
|
||||
URLs: favicon, `robots.txt`, `sitemap.xml`, OG images, and similar.
|
||||
|
||||
```text
|
||||
public/
|
||||
favicon.ico
|
||||
robots.txt
|
||||
og-image.png
|
||||
src/
|
||||
app/
|
||||
pages/
|
||||
shared/
|
||||
```
|
||||
|
||||
Some projects keep a project-local `app/public/` folder when the bundler
|
||||
allows assets to live alongside the entrypoint. Both layouts are valid.
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Asset | Location |
|
||||
| -------------------------------------- | ----------------------------------------- |
|
||||
| Image used by one page/widget/feature | Inside the slice's `ui/` segment |
|
||||
| PDF or template tied to business logic | Inside the slice's `model/` segment |
|
||||
| Icon reused across the app | `shared/ui/` (topical subfolder if many) |
|
||||
| Icon used by exactly one shared kit UI | Next to that component in `shared/ui/` |
|
||||
| Global CSS reset, theme variables | `app/styles/` |
|
||||
| Web fonts | `app/fonts/`, `public/`, or `app/public/` |
|
||||
| Favicon, robots.txt, sitemap | `public/` (or `app/public/`) |
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Do not create a top-level `assets/` segment** that holds all images,
|
||||
fonts, and icons. It breaks cohesion and forces consumers to import from a
|
||||
folder unrelated to the code they are working on.
|
||||
- **Do not extract a slice-local asset to `shared/` "in case" it gets
|
||||
reused.** Move it only when actual reuse appears.
|
||||
- **Do not place CSS modules in an `assets/` folder.** A component's
|
||||
stylesheet belongs next to that component in `ui/`.
|
||||
- **Do not name an FSD segment `public`.** The framework's `public/` folder
|
||||
is reserved and lives outside `src/`.
|
||||
- **Do not split assets and the components that use them.** A page that
|
||||
ships a hero image should keep that image in the page so removing the page
|
||||
removes the image.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- `references/layer-structure.md`: segment rules and layer organization
|
||||
- [Desegmentation](https://fsd.how/docs/guides/issues/desegmented/): why
|
||||
technical-role grouping (including a generic `assets/` segment) hurts
|
||||
cohesion
|
||||
@@ -0,0 +1,374 @@
|
||||
# Cross-Import Resolution Patterns
|
||||
|
||||
How to resolve cross-imports between slices on the same layer. Cross-imports
|
||||
are a code smell, not an absolute prohibition. The strategies below are
|
||||
ordered, but the right choice depends on the project context.
|
||||
|
||||
## What is a cross-import?
|
||||
|
||||
A cross-import is an import between different slices within the same layer.
|
||||
For example:
|
||||
|
||||
- importing `features/product` from `features/cart`
|
||||
- importing `widgets/sidebar` from `widgets/header`
|
||||
|
||||
The `shared` and `app` layers do not have slices, so imports within those
|
||||
layers are not cross-imports.
|
||||
|
||||
## Why is this a code smell?
|
||||
|
||||
Cross-imports blur domain boundaries and introduce implicit dependencies.
|
||||
Four concrete problems:
|
||||
|
||||
1. **Unclear ownership and responsibility.** When `cart` imports from
|
||||
`product`, it becomes unclear which slice owns the shared logic.
|
||||
Changes to `product`'s internal implementation can break `cart`
|
||||
without warning. This makes bugs harder to localize and code harder
|
||||
to reason about.
|
||||
2. **Reduced isolation and testability.** A core benefit of sliced
|
||||
architecture is that each slice can be developed, tested, and deployed
|
||||
independently. Cross-imports break this isolation. Testing `cart` now
|
||||
requires setting up `product`, and changes in one slice can cause
|
||||
unexpected test failures in another.
|
||||
3. **Increased cognitive load.** Working on `cart` now requires accounting
|
||||
for how `product` is structured. As cross-imports accumulate, tracing
|
||||
the impact of a change requires following more code across slice
|
||||
boundaries.
|
||||
4. **Path to circular dependencies.** Cross-imports often start as one-way
|
||||
dependencies but evolve into bidirectional ones (A imports B, B imports
|
||||
A). This locks slices together and makes refactoring increasingly costly.
|
||||
|
||||
## Entities layer: prefer boundary merge over @x
|
||||
|
||||
Cross-imports in `entities` are usually caused by splitting entities too
|
||||
granularly. Before reaching for `@x`, consider whether the boundaries should
|
||||
be merged instead.
|
||||
|
||||
The `@x` notation is available as a dedicated cross-import surface for
|
||||
`entities`, but it should be treated as a **last resort**, a **necessary
|
||||
compromise**, not a recommended approach. Think of `@x` as an explicit
|
||||
gateway for unavoidable domain references, not a general-purpose reuse
|
||||
mechanism. Overuse locks entity boundaries together and makes refactoring
|
||||
more costly over time.
|
||||
|
||||
### How @x works (when boundary merge is genuinely impossible)
|
||||
|
||||
Each entity exposes a special `@x/` directory containing files named after
|
||||
the consuming entity. This makes the cross-import explicit and auditable.
|
||||
|
||||
**Direction rule:** in the path `entities/A/@x/B`, **A is the producer and
|
||||
B is the consumer**. Read it as "A crossed with B": the file `A/@x/B.ts`
|
||||
is the public API that A exposes specifically for B. So in the example
|
||||
below, `entities/user/@x/order.ts` is what `user` exposes to `order`, and
|
||||
`order` imports from it.
|
||||
|
||||
```text
|
||||
entities/
|
||||
user/
|
||||
@x/
|
||||
order.ts ← Exposed specifically for the order entity
|
||||
model/
|
||||
user.ts
|
||||
index.ts
|
||||
order/
|
||||
model/
|
||||
order-summary.ts ← Imports from user/@x/order
|
||||
index.ts
|
||||
```
|
||||
|
||||
```typescript
|
||||
// entities/user/@x/order.ts: exposes only what order needs
|
||||
export { getUserDisplayName } from "../model/user";
|
||||
|
||||
// entities/order/model/order-summary.ts
|
||||
import { getUserDisplayName } from "@/entities/user/@x/order";
|
||||
```
|
||||
|
||||
### Rules when using @x
|
||||
|
||||
1. Document why `@x` is needed and why merging boundaries does not apply.
|
||||
2. Review periodically. Requirements change and `@x` may become unnecessary.
|
||||
3. Minimize the surface area of `@x` exports.
|
||||
4. Only between entities. Features and widgets should use Strategy C or D
|
||||
below, not `@x`.
|
||||
|
||||
## Features and widgets: four strategies
|
||||
|
||||
In `features` and `widgets`, multiple strategies are available depending on
|
||||
project context. Cross-imports here are not always forbidden; they are
|
||||
dependencies that should be deliberate. The four strategies below are
|
||||
listed in preferred order, but each fits different situations.
|
||||
|
||||
### Strategy A: Slice merge
|
||||
|
||||
If two slices are not truly independent and always change together, merge
|
||||
them into a single larger slice.
|
||||
|
||||
```text
|
||||
// Before: two features that always change together
|
||||
features/profile/
|
||||
features/profile-settings/
|
||||
|
||||
// After: one cohesive feature
|
||||
features/profile/
|
||||
ui/
|
||||
Profile.tsx
|
||||
ProfileSettings.tsx
|
||||
model/
|
||||
profile.ts
|
||||
profile-settings.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
If two slices keep cross-importing each other and effectively move as one
|
||||
unit, they are likely one feature in practice. Merging is often the simpler
|
||||
and cleaner choice.
|
||||
|
||||
### Strategy B: Push shared domain flows down into entities
|
||||
|
||||
If multiple features share a domain-level flow, move that flow into a domain
|
||||
slice inside `entities`. Key principles:
|
||||
|
||||
- `entities` contains domain types and domain logic only.
|
||||
- UI remains in `features` and `widgets`.
|
||||
- Features import and use the domain logic from `entities`.
|
||||
|
||||
For example, if both `features/auth` and `features/profile` need session
|
||||
validation, place session-related domain functions in `entities/session`
|
||||
and reuse them from both features.
|
||||
|
||||
```text
|
||||
entities/
|
||||
session/
|
||||
model/
|
||||
validate-session.ts
|
||||
session.ts
|
||||
index.ts
|
||||
|
||||
features/
|
||||
auth/
|
||||
ui/LoginForm.tsx
|
||||
model/login.ts ← imports validateSession from entities/session
|
||||
index.ts
|
||||
profile/
|
||||
ui/ProfilePanel.tsx
|
||||
model/profile.ts ← imports validateSession from entities/session
|
||||
index.ts
|
||||
```
|
||||
|
||||
### Strategy C: Compose from an upper layer (IoC)
|
||||
|
||||
Instead of connecting slices within the same layer via cross-imports,
|
||||
compose them at a higher level (`pages` or `app`). The upper layer assembles
|
||||
and connects the slices; the slices themselves do not know about each other.
|
||||
|
||||
Common Inversion of Control techniques:
|
||||
|
||||
- **Render props (React)**: pass components or render functions as props.
|
||||
- **Slots (Vue)**: use named slots to inject content from parent components.
|
||||
- **Dependency injection**: pass dependencies through props or context.
|
||||
|
||||
#### Basic composition (React)
|
||||
|
||||
```typescript
|
||||
// features/user-profile/index.ts
|
||||
export { UserProfilePanel } from "./ui/UserProfilePanel";
|
||||
|
||||
// features/activity-feed/index.ts
|
||||
export { ActivityFeed } from "./ui/ActivityFeed";
|
||||
|
||||
// pages/UserDashboardPage.tsx
|
||||
import { UserProfilePanel } from "@/features/user-profile";
|
||||
import { ActivityFeed } from "@/features/activity-feed";
|
||||
|
||||
export const UserDashboardPage = () => (
|
||||
<div>
|
||||
<UserProfilePanel />
|
||||
<ActivityFeed />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
`features/user-profile` and `features/activity-feed` do not know about each
|
||||
other. The page composes them.
|
||||
|
||||
#### Render props (React)
|
||||
|
||||
When one feature needs to render content from another, use render props to
|
||||
invert the dependency:
|
||||
|
||||
```typescript
|
||||
// features/comment-list/ui/CommentList.tsx
|
||||
interface CommentListProps {
|
||||
comments: Comment[];
|
||||
renderUserAvatar?: (userId: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const CommentList = ({ comments, renderUserAvatar }: CommentListProps) => (
|
||||
<ul>
|
||||
{comments.map((comment) => (
|
||||
<li key={comment.id}>
|
||||
{renderUserAvatar?.(comment.userId)}
|
||||
<span>{comment.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
// pages/PostPage.tsx
|
||||
import { CommentList } from "@/features/comment-list";
|
||||
import { UserAvatar } from "@/features/user-profile";
|
||||
|
||||
export const PostPage = () => (
|
||||
<CommentList
|
||||
comments={comments}
|
||||
renderUserAvatar={(userId) => <UserAvatar userId={userId} />}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
`CommentList` does not import from `user-profile`. The page injects the
|
||||
avatar component.
|
||||
|
||||
#### Slots (Vue)
|
||||
|
||||
Vue's slot system provides a natural way to compose features without
|
||||
cross-imports:
|
||||
|
||||
```vue
|
||||
<!-- features/comment-list/ui/CommentList.vue -->
|
||||
<template>
|
||||
<ul>
|
||||
<li v-for="comment in comments" :key="comment.id">
|
||||
<slot name="avatar" :userId="comment.userId" />
|
||||
<span>{{ comment.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- pages/PostPage.vue -->
|
||||
<template>
|
||||
<CommentList :comments="comments">
|
||||
<template #avatar="{ userId }">
|
||||
<UserAvatar :userId="userId" />
|
||||
</template>
|
||||
</CommentList>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Strategy D: Cross-feature reuse only via Public API
|
||||
|
||||
If strategies A-C do not fit and cross-feature reuse is genuinely
|
||||
unavoidable, allow it only through an explicit Public API (exported hooks
|
||||
or UI components). Do not access another slice's `store`, `model`, or
|
||||
internal implementation.
|
||||
|
||||
Unlike strategies A-C which aim to eliminate cross-imports, this strategy
|
||||
accepts them while minimizing risk through strict boundaries.
|
||||
|
||||
```typescript
|
||||
// features/auth/index.ts
|
||||
export { useAuth } from "./model/use-auth";
|
||||
export { AuthButton } from "./ui/AuthButton";
|
||||
|
||||
// features/profile/ui/ProfileMenu.tsx
|
||||
import { useAuth, AuthButton } from "@/features/auth";
|
||||
|
||||
export const ProfileMenu = () => {
|
||||
const { user } = useAuth();
|
||||
if (!user) return <AuthButton />;
|
||||
return <div>{user.name}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
The boundary holds: `features/profile` cannot import from
|
||||
`@/features/auth/model/internal/*`. Only what `features/auth` explicitly
|
||||
exposes through `index.ts` is reachable.
|
||||
|
||||
The `@x` notation is for the entities layer only. Features and widgets use
|
||||
strategies A through D above; their access path is the standard public API
|
||||
(`index.ts`), not a dedicated cross-import surface.
|
||||
|
||||
## When to treat a cross-import as a problem
|
||||
|
||||
After reviewing these strategies, the question is: when is a cross-import
|
||||
acceptable to keep, and when should it be treated as a code smell and
|
||||
refactored?
|
||||
|
||||
Common warning signs:
|
||||
|
||||
- Directly depending on another slice's `store`, `model`, or business logic
|
||||
- Deep imports into another slice's internal files (bypassing the public API)
|
||||
- Bidirectional dependencies (A imports B, and B imports A)
|
||||
- Changes in one slice frequently breaking another slice
|
||||
- Flows that should be composed in `pages` or `app`, but are forced into
|
||||
cross-imports within the same layer
|
||||
|
||||
When these signals appear, treat the cross-import as a code smell and apply
|
||||
one of the strategies above.
|
||||
|
||||
## Strictness depends on project context
|
||||
|
||||
The strictness of cross-import enforcement depends on the project:
|
||||
|
||||
- In **early-stage products** with heavy experimentation, allowing some
|
||||
cross-imports may be a pragmatic speed trade-off.
|
||||
- In **long-lived or regulated systems** (fintech, large-scale services),
|
||||
stricter boundaries pay off in maintainability and stability.
|
||||
|
||||
Cross-imports are not an absolute prohibition. They are dependencies that
|
||||
are generally best avoided, but sometimes used intentionally. If a
|
||||
cross-import is introduced:
|
||||
|
||||
- Treat it as a deliberate architectural choice.
|
||||
- Document the reasoning in code (a comment explaining why other
|
||||
strategies do not apply).
|
||||
- Revisit it periodically as the system evolves; if requirements change,
|
||||
the cross-import may no longer be needed.
|
||||
|
||||
## Decision flow for AI agents
|
||||
|
||||
```text
|
||||
Two slices on the same layer need to share code.
|
||||
│
|
||||
├─ ENTITIES layer?
|
||||
│ ├─ Can boundaries be merged into one entity?
|
||||
│ │ └─ YES → Merge. Stop.
|
||||
│ └─ Boundaries must stay separate?
|
||||
│ └─ Use @x as last resort. Document why merge is not possible.
|
||||
│
|
||||
└─ FEATURES or WIDGETS layer?
|
||||
├─ Strategy A: Do they always change together?
|
||||
│ └─ YES → Merge slices.
|
||||
│
|
||||
├─ Strategy B: Is the shared part domain-only logic?
|
||||
│ └─ YES → Push down to entities. Keep UI in features.
|
||||
│
|
||||
├─ Strategy C: Can the connection be assembled by a higher layer?
|
||||
│ └─ YES → Compose in pages or app via render props, slots, or DI.
|
||||
│
|
||||
└─ Strategy D: Is reuse genuinely unavoidable and the access surface
|
||||
limited to a Public API?
|
||||
└─ YES → Allow, but only through index.ts. Never reach into
|
||||
model/, store/, or internal files. Do not use @x in
|
||||
features or widgets.
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Reaching for `@x` in features or widgets.** `@x` is for entities only.
|
||||
Use Strategy C (compose) or D (Public API) instead.
|
||||
- **Treating `@x` as a clean solution.** It is a compromise. If you find
|
||||
yourself adding multiple `@x` files between the same entities, the
|
||||
boundaries are probably wrong. Merge them.
|
||||
- **Bypassing the Public API to access internals.** Even when Strategy D is
|
||||
in use, importing from `@/features/auth/model/internal/*` defeats the
|
||||
purpose. Restrict yourself to what `index.ts` exports.
|
||||
- **Bidirectional cross-imports.** A imports B and B imports A is almost
|
||||
always a sign that the slices should be merged.
|
||||
|
||||
## See also
|
||||
|
||||
- `references/excessive-entities.md`: prevent the conditions that lead to
|
||||
entity-layer cross-imports in the first place.
|
||||
- `references/layer-structure.md`: layer rules and import directions.
|
||||
@@ -0,0 +1,287 @@
|
||||
# Excessive Entities
|
||||
|
||||
How to keep the `entities` layer clean and avoid over-extracting business
|
||||
logic into entities. Excessive entities cause ambiguity (what code belongs
|
||||
where), coupling, and constant import dilemmas as code scatters across
|
||||
sibling entities.
|
||||
|
||||
## Why this matters
|
||||
|
||||
The `entities` layer is one of the lower layers and is widely accessible.
|
||||
Every layer except `shared` can import from it. That global nature means
|
||||
changes to `entities` propagate widely, requiring careful design to avoid
|
||||
costly refactors. Adding an entity is cheap; removing one after many
|
||||
consumers depend on it is expensive.
|
||||
|
||||
## How to keep entities clean
|
||||
|
||||
### 0. Consider having no entities layer
|
||||
|
||||
An FSD application without an `entities` layer is still FSD. Skipping the
|
||||
layer simplifies the architecture and keeps it available for future scaling.
|
||||
|
||||
**Thin clients** (where the backend handles most data processing and the
|
||||
client mostly exchanges data) usually do not need an entities layer.
|
||||
**Thick clients** (significant client-side business logic) are better
|
||||
candidates for entities.
|
||||
|
||||
The classification is not strictly binary. Different parts of the same
|
||||
application may behave as thick or thin clients.
|
||||
|
||||
```text
|
||||
// Thin client without entities layer (still valid FSD)
|
||||
src/
|
||||
app/
|
||||
pages/
|
||||
dashboard/
|
||||
profile/
|
||||
shared/
|
||||
api/
|
||||
ui/
|
||||
```
|
||||
|
||||
### 1. Avoid preemptive slicing
|
||||
|
||||
FSD v2.1 encourages **deferred decomposition** of slices. Place code in the
|
||||
`model` segment of the consuming page, widget, or feature first. Move it to
|
||||
`entities` later, when business requirements stabilize and reuse is
|
||||
confirmed across multiple consumers.
|
||||
|
||||
The later code moves to `entities`, the less dangerous the refactor. Code
|
||||
in `entities` can affect every higher-layer slice that imports it.
|
||||
|
||||
```text
|
||||
// Iteration 1: code lives where it is used
|
||||
pages/profile/
|
||||
model/
|
||||
profile-validation.ts ← page-specific for now
|
||||
|
||||
// Iteration 2 (after the same logic is needed in 2+ places):
|
||||
entities/profile/
|
||||
model/
|
||||
profile-validation.ts ← extracted only after reuse is real
|
||||
```
|
||||
|
||||
### 2. Avoid unnecessary entities
|
||||
|
||||
Do not create an entity for every piece of business logic. Use types from
|
||||
`shared/api` and place logic in the `model` segment of the current slice.
|
||||
For genuinely reusable business logic, use the `model` segment within an
|
||||
entity slice while keeping data definitions in `shared/api`.
|
||||
|
||||
```text
|
||||
shared/
|
||||
api/
|
||||
endpoints/
|
||||
order.ts ← OrderDto type and request functions
|
||||
|
||||
entities/
|
||||
order/
|
||||
model/
|
||||
apply-discount.ts ← Business logic that uses OrderDto
|
||||
index.ts
|
||||
```
|
||||
|
||||
The DTO lives in `shared/api/endpoints/order.ts`. Business logic that
|
||||
operates on it (calculating discounts, applying promotions) lives in
|
||||
`entities/order/model/`. Do not mirror every API endpoint with a
|
||||
corresponding entity.
|
||||
|
||||
### 3. Exclude CRUD operations from entities
|
||||
|
||||
CRUD operations involve boilerplate code without significant business
|
||||
logic. Putting them in `entities` clutters the layer and obscures the code
|
||||
that genuinely matters. Place CRUD in `shared/api`:
|
||||
|
||||
```text
|
||||
shared/
|
||||
api/
|
||||
client.ts
|
||||
endpoints/
|
||||
order.ts ← getOrder, createOrder, updateOrder, deleteOrder
|
||||
products.ts ← Standard CRUD for products
|
||||
cart.ts ← Standard CRUD for cart
|
||||
index.ts
|
||||
```
|
||||
|
||||
For complex CRUD with atomic updates, rollbacks, or transactions, evaluate
|
||||
whether the operation is genuinely business logic. If so, the `entities`
|
||||
layer may be appropriate. If not, keep it in `shared/api`.
|
||||
|
||||
### 4. Store authentication data in shared
|
||||
|
||||
Prefer `shared` over creating a `user` entity for auth tokens and session
|
||||
DTOs. These are context-specific to authentication and unlikely to be
|
||||
reused outside that scope. Wrapping a login response in a `user` entity
|
||||
also tends to drag entities into cross-layer imports or `@x` chains,
|
||||
complicating the architecture.
|
||||
|
||||
The Auth guide also documents **In Entities** (a `user` entity) as a
|
||||
valid placement when the project already has an entities layer and the
|
||||
data is genuinely reused. **In Pages/Widgets** is discouraged for both
|
||||
guides.
|
||||
|
||||
**`shared/auth` (or `shared/api`) is the recommended default.** Choose
|
||||
it when:
|
||||
|
||||
- The project has no entities layer yet
|
||||
- Auth state is just a token plus minimal user info (id, email, role)
|
||||
- Token management logic (refresh, expiration) is the main concern, not
|
||||
user profile data
|
||||
|
||||
```text
|
||||
shared/
|
||||
auth/
|
||||
use-auth.ts ← Token + minimal user info
|
||||
index.ts
|
||||
api/
|
||||
client.ts ← API client reads token from shared/auth
|
||||
endpoints/
|
||||
order.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
This approach pairs naturally with an API client middleware that injects
|
||||
the token into authenticated requests.
|
||||
|
||||
**A `user` entity is the right call when:**
|
||||
|
||||
- The project already has an entities layer
|
||||
- Auth and profile data are tightly coupled (current user info is reused
|
||||
across pages for non-auth purposes like comments, posts, mentions)
|
||||
- Token management has complex business logic (invalidation policies,
|
||||
multi-device session tracking) that benefits from co-location with the
|
||||
user model
|
||||
|
||||
```text
|
||||
entities/
|
||||
user/
|
||||
model/
|
||||
current-user.ts ← Token + full user model + business logic
|
||||
user.ts ← Generic user type, used for other users too
|
||||
api/
|
||||
get-current-user.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
When using the entity approach, the API client (in `shared/api`) needs
|
||||
access to the token without violating the import rule. The official Auth
|
||||
guide describes three solutions: pass the token manually on each request,
|
||||
expose it through a context or `localStorage` with the key kept in
|
||||
`shared/api`, or inject the token into the API client whenever the entity
|
||||
store updates.
|
||||
|
||||
**Pages and widgets are discouraged.** Avoid placing the token store in a
|
||||
page's `model/` segment or in a widget. App-wide state belongs in Shared
|
||||
or Entities, not in route-bound or block-bound layers.
|
||||
|
||||
### Decision summary
|
||||
|
||||
| Project state | Recommended location |
|
||||
| --- | --- |
|
||||
| No entities layer (yet), simple token + minimal user info | `shared/auth` |
|
||||
| Entities layer exists, auth and profile tightly coupled | `entities/user` |
|
||||
| Complex token logic, no profile reuse yet | `shared/auth` (split from `shared/api`) |
|
||||
| Token storage in a single page or widget | Avoid; promote to Shared or Entities |
|
||||
|
||||
A `user` entity created **only** to wrap a login response is premature.
|
||||
Wait until profile data is consumed for non-auth purposes (avatars in
|
||||
comments, names in posts) before introducing the entity.
|
||||
|
||||
### 5. Minimize cross-imports
|
||||
|
||||
FSD permits cross-imports between entities via `@x`, but they introduce
|
||||
technical issues including circular dependencies. Design entities within
|
||||
**isolated business contexts** so cross-imports become unnecessary.
|
||||
|
||||
**Non-isolated context (avoid):**
|
||||
|
||||
```text
|
||||
entities/
|
||||
order/
|
||||
@x/
|
||||
model/
|
||||
order-item/
|
||||
@x/
|
||||
model/
|
||||
order-customer-info/
|
||||
@x/
|
||||
model/
|
||||
```
|
||||
|
||||
Three sibling entities all referencing each other through `@x`. This is a
|
||||
sign that the boundaries are wrong.
|
||||
|
||||
**Isolated context (preferred):**
|
||||
|
||||
```text
|
||||
entities/
|
||||
order-info/
|
||||
model/
|
||||
order-info.ts ← order, items, and customer info together
|
||||
index.ts
|
||||
```
|
||||
|
||||
One entity encapsulates the related logic. No `@x`, no cross-imports,
|
||||
no circular dependency risk.
|
||||
|
||||
The general rule: when several entities have `@x` dependencies on each
|
||||
other, treat that as a signal to merge the boundaries, not as something to
|
||||
manage.
|
||||
|
||||
## Decision tree for AI agents
|
||||
|
||||
```text
|
||||
A new piece of business logic needs a home.
|
||||
│
|
||||
├─ Is the project a thin client?
|
||||
│ └─ YES → Skip entities. Place in shared/ + page model.
|
||||
│
|
||||
├─ Is the logic used in only one place right now?
|
||||
│ └─ YES → Keep in the consuming slice's model/. Defer extraction.
|
||||
│
|
||||
├─ Is it a CRUD operation without business meaning?
|
||||
│ └─ YES → shared/api/endpoints/<resource>.ts
|
||||
│
|
||||
├─ Is it auth data (tokens, session, login DTOs)?
|
||||
│ ├─ Project has no entities layer yet?
|
||||
│ │ └─ YES → shared/auth/
|
||||
│ ├─ Auth and profile data tightly coupled, entities layer exists?
|
||||
│ │ └─ YES → entities/user/
|
||||
│ └─ Otherwise → shared/auth/ (default).
|
||||
│ Avoid placing in a page or widget.
|
||||
│
|
||||
├─ Is it just a TypeScript type for an API response?
|
||||
│ └─ YES → shared/api/. No entity needed for types alone.
|
||||
│
|
||||
└─ Is it reusable domain logic confirmed in 2+ consumers?
|
||||
└─ YES → Create entities/<name>/model/.
|
||||
Verify the boundary is isolated and does not require @x
|
||||
to communicate with sibling entities.
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Creating entities preemptively.** Wait for confirmed reuse in 2+
|
||||
consumers, not anticipated reuse.
|
||||
- **Mirroring every API endpoint with an entity.** API endpoints belong in
|
||||
`shared/api`. Entities exist for business logic, not for paralleling the
|
||||
backend structure.
|
||||
- **Creating a `user` entity *only* to wrap a login response.** A `user`
|
||||
entity is justified when profile data is reused across non-auth flows
|
||||
(avatars in comments, names in posts) or when token logic is genuinely
|
||||
tied to user business logic. Until that reuse appears, `shared/auth`
|
||||
is simpler. Storing tokens in a page or widget is discouraged regardless
|
||||
of the project shape.
|
||||
- **Splitting one domain into many entities (`order`, `order-item`,
|
||||
`order-customer-info`).** This produces `@x` chains. Merge into a single
|
||||
isolated context (`order-info` or `order`).
|
||||
- **Putting CRUD wrappers in entities.** They clutter the layer. CRUD goes
|
||||
in `shared/api/endpoints/`.
|
||||
|
||||
## See also
|
||||
|
||||
- `references/cross-import-patterns.md`: how to handle cross-imports when
|
||||
they appear, and why `@x` is a last resort.
|
||||
- `references/layer-structure.md`: layer responsibilities and the entities
|
||||
segment shape.
|
||||
@@ -0,0 +1,496 @@
|
||||
# Framework Integration
|
||||
|
||||
How to set up FSD within specific frameworks. Covers directory placement,
|
||||
routing integration, and framework-specific path alias configuration.
|
||||
|
||||
|
||||
## General Principle
|
||||
|
||||
Place FSD layers inside `src/` to avoid naming conflicts with framework
|
||||
directories. The FSD `app/` and `pages/` layers are **not** the same as
|
||||
framework directories with the same names (e.g., Next.js `app/`).
|
||||
|
||||
All FSD projects follow the same `@/<layer>/*` path alias convention. The
|
||||
exact configuration differs by framework. See each framework section
|
||||
below. Astro is the one exception, using a single `@/*` alias instead.
|
||||
|
||||
|
||||
## Next.js
|
||||
|
||||
FSD is compatible with both the App Router and the Pages Router. The main
|
||||
conflict is that Next.js owns the `app/` and `pages/` folder names, and both
|
||||
collide with FSD layer names. Resolve the conflict by moving the Next.js
|
||||
routing folders to the project root and importing FSD pages from `src/` into
|
||||
them.
|
||||
|
||||
### App Router
|
||||
|
||||
The Next.js `app/` folder lives at the project root. **Always create an
|
||||
empty `pages/` folder at the project root as well, even when you only use
|
||||
the App Router.** Without it, Next.js tries to use `src/pages/` as the Pages
|
||||
Router and the build breaks. Add a `pages/README.md` explaining why the
|
||||
folder exists.
|
||||
|
||||
#### Directory structure
|
||||
|
||||
```text
|
||||
my-nextjs-project/
|
||||
app/ ← Next.js App Router (routing only)
|
||||
layout.tsx
|
||||
page.tsx
|
||||
profile/
|
||||
page.tsx
|
||||
api/
|
||||
get-example/
|
||||
route.ts
|
||||
pages/ ← Empty, required even for App Router
|
||||
README.md
|
||||
src/
|
||||
app/ ← FSD app layer
|
||||
providers/
|
||||
index.tsx ← All providers (QueryClient, theme, etc.)
|
||||
styles/
|
||||
globals.css
|
||||
api-routes/ ← Route Handler implementations (see below)
|
||||
index.ts
|
||||
get-example-data.ts
|
||||
pages/ ← FSD pages layer
|
||||
home/
|
||||
ui/HomePage.tsx
|
||||
index.ts
|
||||
profile/
|
||||
ui/ProfilePage.tsx
|
||||
model/profile.ts
|
||||
api/fetch-profile.ts
|
||||
index.ts
|
||||
widgets/ ← FSD widgets layer (when needed)
|
||||
features/ ← FSD features layer (when needed)
|
||||
entities/ ← FSD entities layer (when needed)
|
||||
shared/ ← FSD shared layer
|
||||
ui/
|
||||
lib/
|
||||
api/
|
||||
db/ ← Database queries (see below)
|
||||
```
|
||||
|
||||
#### Wiring Next.js routes to FSD pages
|
||||
|
||||
```typescript
|
||||
// app/layout.tsx
|
||||
import { Providers } from '@/app/providers';
|
||||
import '@/app/styles/globals.css';
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body><Providers>{children}</Providers></body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
// app/example/page.tsx: re-export the FSD page (component + metadata)
|
||||
export { ExamplePage as default, metadata } from '@/pages/example';
|
||||
```
|
||||
|
||||
Always re-export both the component and `metadata`. Route files contain no logic.
|
||||
|
||||
### Pages Router
|
||||
|
||||
The Pages Router uses `pages/` at the project root. The FSD `src/` tree is
|
||||
unchanged. Routes re-export from `src/pages/`:
|
||||
|
||||
```text
|
||||
my-nextjs-project/
|
||||
pages/ ← Next.js Pages Router (routing only)
|
||||
_app.tsx
|
||||
api/example.ts ← API route re-export
|
||||
example/index.tsx
|
||||
src/
|
||||
app/
|
||||
custom-app/ ← Custom App component
|
||||
api-routes/ ← Route Handler implementations
|
||||
pages/
|
||||
example/
|
||||
ui/example.tsx
|
||||
index.ts
|
||||
```
|
||||
|
||||
```typescript
|
||||
// pages/example/index.tsx
|
||||
export { Example as default } from '@/pages/example';
|
||||
|
||||
// pages/_app.tsx: re-export the custom App from src/app/custom-app
|
||||
export { App as default } from '@/app/custom-app';
|
||||
```
|
||||
|
||||
The custom App component itself lives in `src/app/custom-app/` and exports
|
||||
`App` from its public API like any other FSD slice.
|
||||
|
||||
### Middleware and instrumentation
|
||||
|
||||
`middleware.js` and `instrumentation.js` must live at the **project root**,
|
||||
next to the Next.js `app/` and `pages/` folders. Next.js will not detect
|
||||
them inside `src/`.
|
||||
|
||||
### Route Handlers (API routes)
|
||||
|
||||
Use a dedicated `api-routes` segment in the FSD `app/` layer
|
||||
(`src/app/api-routes/`) to host the actual request handlers. The Next.js
|
||||
`app/api/*/route.ts` (App Router) or `pages/api/*.ts` (Pages Router) files
|
||||
become thin re-exports.
|
||||
|
||||
**App Router:**
|
||||
|
||||
```typescript
|
||||
// src/app/api-routes/get-example-data.ts
|
||||
import { getExamplesList } from '@/shared/db';
|
||||
|
||||
export const getExampleData = () => {
|
||||
try {
|
||||
const examplesList = getExamplesList();
|
||||
return Response.json({ examplesList });
|
||||
} catch {
|
||||
return Response.json(null, {
|
||||
status: 500,
|
||||
statusText: 'Ouch, something went wrong',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// src/app/api-routes/index.ts
|
||||
export { getExampleData } from './get-example-data';
|
||||
|
||||
// app/api/example/route.ts
|
||||
export { getExampleData as GET } from '@/app/api-routes';
|
||||
```
|
||||
|
||||
**Pages Router:**
|
||||
|
||||
```typescript
|
||||
// src/app/api-routes/get-example-data.ts
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
const config = { api: { bodyParser: { sizeLimit: '1mb' } }, maxDuration: 5 };
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) =>
|
||||
res.status(200).json({ message: 'Hello from FSD' });
|
||||
|
||||
export const getExampleData = { config, handler } as const;
|
||||
|
||||
// app/api/example.ts
|
||||
import { getExampleData } from '@/app/api-routes';
|
||||
export const config = getExampleData.config;
|
||||
export default getExampleData.handler;
|
||||
```
|
||||
|
||||
FSD is primarily a frontend methodology. If `api-routes` grows to many
|
||||
endpoints, consider moving the backend to a separate package in a monorepo.
|
||||
|
||||
### Database access
|
||||
|
||||
Place database queries in a `db` segment in `shared/` (`src/shared/db/`).
|
||||
Co-locate caching and revalidation logic with the queries themselves.
|
||||
|
||||
### Path aliases
|
||||
|
||||
```json
|
||||
// tsconfig.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/app/*": ["src/app/*"],
|
||||
"@/pages/*": ["src/pages/*"],
|
||||
"@/widgets/*": ["src/widgets/*"],
|
||||
"@/features/*": ["src/features/*"],
|
||||
"@/entities/*": ["src/entities/*"],
|
||||
"@/shared/*": ["src/shared/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Next.js reads `tsconfig.json` paths automatically. No `next.config.js`
|
||||
alias configuration is needed.
|
||||
|
||||
### Server Components and Public API splitting
|
||||
|
||||
FSD layers work inside both Server and Client Components. However, the
|
||||
standard single `index.ts` public API can cause problems in RSC environments
|
||||
because re-exporting client and server code from the same entry point may
|
||||
trigger bundler errors or unintended boundary crossings.
|
||||
|
||||
Split the public API into multiple entry points per environment:
|
||||
|
||||
```text
|
||||
entities/user/
|
||||
model/
|
||||
user.ts
|
||||
ui/
|
||||
UserAvatar.tsx ← 'use client', uses hooks
|
||||
UserProfileCard.tsx ← Server Component, no hooks
|
||||
api/
|
||||
user-queries.server.ts ← Server-only data fetching
|
||||
index.ts ← Shared exports (types, pure functions)
|
||||
index.client.ts ← Client component exports
|
||||
index.server.ts ← Server component + server-only exports
|
||||
```
|
||||
|
||||
```typescript
|
||||
// entities/user/index.ts: shared (types, pure logic, no components)
|
||||
export type { User } from "./model/user";
|
||||
export { formatUserName } from "./model/user";
|
||||
|
||||
// entities/user/index.client.ts: client components only
|
||||
export { UserAvatar } from "./ui/UserAvatar";
|
||||
|
||||
// entities/user/index.server.ts: server components + server-only code
|
||||
export { UserProfileCard } from "./ui/UserProfileCard";
|
||||
export { fetchUser } from "./api/user-queries.server";
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Consumers import from the appropriate entry point:
|
||||
|
||||
// In a Server Component (pages/profile/ui/ProfilePage.tsx)
|
||||
import { UserProfileCard } from "@/entities/user/index.server";
|
||||
import type { User } from "@/entities/user";
|
||||
|
||||
// In a Client Component (features/comment/ui/CommentAuthor.tsx)
|
||||
import { UserAvatar } from "@/entities/user/index.client";
|
||||
```
|
||||
|
||||
**Rules for split public APIs:**
|
||||
|
||||
1. **`index.ts`**: types, constants, and pure functions that work in both
|
||||
environments. Default import path.
|
||||
2. **`index.client.ts`**: components using `'use client'`, hooks, or
|
||||
browser APIs.
|
||||
3. **`index.server.ts`**: Server Components and server-only data fetching.
|
||||
4. The `index.[env].ts` pattern generalises beyond RSC. Any meta-framework
|
||||
with distinct runtime environments can use it (e.g., `index.edge.ts`).
|
||||
Verified for Next.js App Router; Nuxt and Astro compatibility is under
|
||||
review.
|
||||
5. Steiger support for multiple entry points is available or coming in an
|
||||
upcoming release. If Steiger flags these files, check for version
|
||||
updates.
|
||||
|
||||
**When NOT to split:** A slice with no client/server boundary concerns uses
|
||||
a single `index.ts`. Split only when a slice actually has both client and
|
||||
server exports.
|
||||
|
||||
|
||||
## Nuxt 3
|
||||
|
||||
### Directory structure
|
||||
|
||||
```text
|
||||
my-nuxt-project/
|
||||
pages/ ← Nuxt file-based routing
|
||||
index.vue ← Route entry, imports from FSD pages layer
|
||||
profile.vue
|
||||
src/
|
||||
app/ ← FSD app layer
|
||||
providers/
|
||||
pages/ ← FSD pages layer
|
||||
home/
|
||||
ui/HomePage.vue
|
||||
index.ts
|
||||
profile/
|
||||
ui/ProfilePage.vue
|
||||
model/profile.ts
|
||||
index.ts
|
||||
shared/ ← FSD shared layer
|
||||
ui/
|
||||
lib/
|
||||
api/
|
||||
```
|
||||
|
||||
### Wiring Nuxt routes to FSD pages
|
||||
|
||||
```vue
|
||||
<!-- pages/index.vue: thin route entry -->
|
||||
<template>
|
||||
<HomePage />
|
||||
</template>
|
||||
<script setup>
|
||||
import { HomePage } from "@/pages/home";
|
||||
</script>
|
||||
```
|
||||
|
||||
### Path aliases
|
||||
|
||||
In addition to the standard `tsconfig.json` mapping, Nuxt requires explicit
|
||||
runtime aliases in `nuxt.config.ts`:
|
||||
|
||||
```typescript
|
||||
// nuxt.config.ts
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineNuxtConfig({
|
||||
alias: {
|
||||
"@/app": resolve(__dirname, "src/app"),
|
||||
"@/pages": resolve(__dirname, "src/pages"),
|
||||
"@/widgets": resolve(__dirname, "src/widgets"),
|
||||
"@/features": resolve(__dirname, "src/features"),
|
||||
"@/entities": resolve(__dirname, "src/entities"),
|
||||
"@/shared": resolve(__dirname, "src/shared"),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
## Vite + React
|
||||
|
||||
### Directory structure
|
||||
|
||||
```text
|
||||
my-vite-project/
|
||||
src/
|
||||
app/ ← FSD app layer
|
||||
providers/
|
||||
router.tsx
|
||||
styles/
|
||||
main.tsx ← Entry point
|
||||
pages/
|
||||
shared/
|
||||
index.html
|
||||
vite.config.ts
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
### Path aliases
|
||||
|
||||
Mirror the standard `tsconfig.json` mapping in `vite.config.ts` so the
|
||||
Vite resolver agrees with TypeScript:
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@/app": resolve(__dirname, "src/app"),
|
||||
"@/pages": resolve(__dirname, "src/pages"),
|
||||
"@/widgets": resolve(__dirname, "src/widgets"),
|
||||
"@/features": resolve(__dirname, "src/features"),
|
||||
"@/entities": resolve(__dirname, "src/entities"),
|
||||
"@/shared": resolve(__dirname, "src/shared"),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
## Create React App (CRA)
|
||||
|
||||
CRA is no longer actively maintained. **Migrate to Vite for new projects.**
|
||||
|
||||
If you must stay on CRA, path aliases require ejecting or using `craco` to
|
||||
override the webpack config:
|
||||
|
||||
```javascript
|
||||
// craco.config.js
|
||||
const path = require("path");
|
||||
|
||||
module.exports = {
|
||||
webpack: {
|
||||
alias: {
|
||||
"@/app": path.resolve(__dirname, "src/app"),
|
||||
"@/pages": path.resolve(__dirname, "src/pages"),
|
||||
"@/widgets": path.resolve(__dirname, "src/widgets"),
|
||||
"@/features": path.resolve(__dirname, "src/features"),
|
||||
"@/entities": path.resolve(__dirname, "src/entities"),
|
||||
"@/shared": path.resolve(__dirname, "src/shared"),
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
## Astro
|
||||
|
||||
Astro uses `src/pages/` for file-based routing, which collides with the FSD
|
||||
`pages/` layer. Move the FSD pages layer to `src/_pages/` (with the
|
||||
underscore prefix) and reserve `src/pages/` for Astro routes.
|
||||
|
||||
### Directory structure
|
||||
|
||||
```text
|
||||
my-astro-project/
|
||||
src/
|
||||
pages/ ← Astro routing (thin entry points)
|
||||
404.astro
|
||||
index.astro
|
||||
_pages/ ← FSD pages layer
|
||||
home/
|
||||
ui/HomePage.astro
|
||||
index.ts
|
||||
widgets/
|
||||
features/
|
||||
entities/
|
||||
shared/
|
||||
```
|
||||
|
||||
### Wiring Astro routes to FSD pages
|
||||
|
||||
The Astro route file imports and renders the FSD page, nothing else:
|
||||
|
||||
```astro
|
||||
// src/pages/index.astro
|
||||
import { HomePage } from '@/_pages/home';
|
||||
<HomePage />
|
||||
```
|
||||
|
||||
### Path aliases (tsconfig.json)
|
||||
|
||||
Astro projects use a single `@/*` alias instead of one alias per layer. This
|
||||
is the convention the FSD Astro guide recommends:
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Imports then reference the layer path directly: `@/_pages/home`,
|
||||
`@/shared/ui`, `@/entities/user`.
|
||||
|
||||
### Working with integrations
|
||||
|
||||
Some Astro integrations (for example, Starlight) use content collections
|
||||
that expect content in fixed folders such as `src/content/docs/`. If the
|
||||
integration does not allow the path to be changed, leave it as-is. The
|
||||
content folder lives alongside FSD layers without collision:
|
||||
|
||||
```text
|
||||
src/
|
||||
_pages/ ← FSD pages layer
|
||||
content/ ← Integration content (Starlight, etc.)
|
||||
docs/
|
||||
getting-started.md
|
||||
shared/ ← FSD shared layer
|
||||
```
|
||||
|
||||
Let the integration handle its own routing and rendering, while FSD layers
|
||||
manage application-specific code.
|
||||
|
||||
|
||||
## Key Reminders for All Frameworks
|
||||
|
||||
1. **FSD lives in `src/`**: root-level `app/` and `pages/` belong to the
|
||||
framework's routing, not FSD.
|
||||
2. **Framework route files are thin wrappers**: they import and render FSD
|
||||
page components. Business logic stays in FSD pages.
|
||||
3. **Path aliases are required**: configure both the bundler and
|
||||
`tsconfig.json`.
|
||||
4. **Pages First still applies**: regardless of framework, start with code
|
||||
in FSD `pages/` and extract only when needed.
|
||||
@@ -0,0 +1,490 @@
|
||||
# Layer Structure Reference
|
||||
|
||||
Detailed folder structures, code examples, and naming conventions for each
|
||||
FSD layer. Use this reference when creating, reviewing, or reorganizing
|
||||
project structure.
|
||||
|
||||
---
|
||||
|
||||
## App Layer
|
||||
|
||||
App-wide initialization: providers, routing, global styles, entry point.
|
||||
Organized by segments only, no slices.
|
||||
|
||||
The methodology does not formally standardize App segment names. The
|
||||
common convention list (`ui`, `api`, `model`, `lib`, `config`) applies to
|
||||
all layers but is rarely a good fit here. In practice, projects use names
|
||||
that describe purpose: `routes`, `store`, `styles`, `providers`,
|
||||
`entrypoint`, etc. Choose names that match your stack (for example,
|
||||
`providers` for React/Vue provider components that wrap Redux,
|
||||
QueryClient, or theme contexts):
|
||||
|
||||
```text
|
||||
app/
|
||||
routes/ ← Route configuration (or router.tsx for single file)
|
||||
store/ ← Global state store (Redux configureStore, Zustand root)
|
||||
styles/ ← Global CSS, reset, theme variables
|
||||
providers/ ← Provider components (Redux Provider, QueryClientProvider)
|
||||
entrypoint.tsx ← Application entry point (main.tsx, index.tsx)
|
||||
```
|
||||
|
||||
A smaller project may collapse some of these into single files:
|
||||
|
||||
```text
|
||||
app/
|
||||
router.tsx ← Route configuration
|
||||
store.ts ← Store configuration
|
||||
styles/
|
||||
global.css
|
||||
providers.tsx ← All providers in one file
|
||||
index.tsx ← Entry point
|
||||
```
|
||||
|
||||
```typescript
|
||||
// app/router.tsx
|
||||
import { HomePage } from '@/pages/home';
|
||||
import { ProfilePage } from '@/pages/profile';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/', element: <HomePage /> },
|
||||
{ path: '/profile/:id', element: <ProfilePage /> },
|
||||
]);
|
||||
```
|
||||
|
||||
**Belongs in app:** Global providers (Redux store, QueryClient, theme),
|
||||
routing setup, global styles, error boundaries, analytics initialization.
|
||||
|
||||
**Does not belong:** Feature-specific code, business logic, page-level UI.
|
||||
|
||||
---
|
||||
|
||||
## Pages Layer
|
||||
|
||||
Route-level composition. In v2.1, pages **own substantial logic**: they are
|
||||
not thin wrappers. In early project stages, most code lives here.
|
||||
|
||||
```text
|
||||
pages/
|
||||
home/
|
||||
ui/
|
||||
HomePage.tsx
|
||||
HeroSection.tsx
|
||||
FeaturesGrid.tsx
|
||||
model/
|
||||
home-data.ts ← Page-specific state + logic
|
||||
api/
|
||||
fetch-home-data.ts ← Page-specific API calls
|
||||
index.ts
|
||||
profile/
|
||||
ui/
|
||||
ProfilePage.tsx
|
||||
ProfileForm.tsx
|
||||
ProfileStats.tsx
|
||||
model/
|
||||
profile.ts ← Profile state + validation logic
|
||||
api/
|
||||
update-profile.ts
|
||||
fetch-profile.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
**Belongs in pages:** Page-specific UI, forms, validation, data fetching,
|
||||
state management, business logic, API integrations. Even code that looks
|
||||
reusable stays here if it is simpler to keep local.
|
||||
|
||||
**Does not belong:** Code that is currently being reused across multiple
|
||||
pages with stable boundaries (extract to a lower layer when reuse is
|
||||
confirmed, not anticipated).
|
||||
|
||||
### Page Layout Patterns
|
||||
|
||||
A typical page composes widgets, features, and entities from lower layers,
|
||||
plus its own local UI components:
|
||||
|
||||
```typescript
|
||||
// pages/product-detail/ui/ProductDetailPage.tsx
|
||||
import { Header } from '@/widgets/header';
|
||||
import { AddToCart } from '@/features/add-to-cart';
|
||||
import { Product } from '@/entities/product';
|
||||
|
||||
export const ProductDetailPage = ({ productId }) => {
|
||||
const product = useProductDetail(productId); // local hook in this page
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Product.Card data={product} />
|
||||
<AddToCart productId={productId} />
|
||||
<RelatedProducts products={product.related} /> {/* local component */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
For pages that only need shared + page-local code (no extracted layers):
|
||||
|
||||
```typescript
|
||||
// pages/about/ui/AboutPage.tsx
|
||||
import { Card } from '@/shared/ui/Card';
|
||||
import { TeamSection } from './TeamSection'; // local to this page
|
||||
import { MissionStatement } from './MissionStatement';
|
||||
|
||||
export const AboutPage = () => (
|
||||
<main>
|
||||
<MissionStatement />
|
||||
<Card><TeamSection /></Card>
|
||||
</main>
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Widgets Layer
|
||||
|
||||
Composite UI blocks with their own logic, **reused across multiple pages**.
|
||||
Add this layer only when UI blocks actually appear in 2+ pages and sharing
|
||||
provides clear value.
|
||||
|
||||
```text
|
||||
widgets/
|
||||
header/
|
||||
ui/
|
||||
Header.tsx
|
||||
Navigation.tsx
|
||||
UserMenu.tsx
|
||||
model/
|
||||
header.ts ← Widget state
|
||||
api/
|
||||
fetch-notifications.ts
|
||||
index.ts
|
||||
sidebar/
|
||||
ui/
|
||||
Sidebar.tsx
|
||||
model/
|
||||
sidebar.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
**Belongs in widgets:** Navigation bars, sidebars, dashboards, footers,
|
||||
complex card layouts that combine data from multiple entities/features.
|
||||
|
||||
**Does not belong:** Simple UI primitives (→ `shared/ui/`), single-use
|
||||
page sections (→ keep in the page).
|
||||
|
||||
---
|
||||
|
||||
## Features Layer
|
||||
|
||||
Independent, reusable user interactions. **Create only when used in 2+ places.**
|
||||
|
||||
```text
|
||||
features/
|
||||
auth/
|
||||
ui/
|
||||
LoginForm.tsx
|
||||
RegisterForm.tsx
|
||||
model/
|
||||
auth.ts ← Auth state + logic
|
||||
api/
|
||||
login.ts
|
||||
register.ts
|
||||
index.ts
|
||||
add-to-cart/
|
||||
ui/
|
||||
AddToCartButton.tsx
|
||||
model/
|
||||
cart.ts
|
||||
index.ts
|
||||
like-post/
|
||||
ui/
|
||||
LikeButton.tsx
|
||||
model/
|
||||
like.ts
|
||||
api/
|
||||
toggle-like.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
**Feature composition**: features consume entities and are composed in
|
||||
higher layers:
|
||||
|
||||
```typescript
|
||||
// widgets/post-card/ui/PostCard.tsx
|
||||
import { UserAvatar } from '@/entities/user';
|
||||
import { LikeButton } from '@/features/like-post';
|
||||
import { CommentButton } from '@/features/comment-create';
|
||||
|
||||
export const PostCard = ({ post }) => (
|
||||
<article>
|
||||
<UserAvatar userId={post.authorId} />
|
||||
<h2>{post.title}</h2>
|
||||
<p>{post.content}</p>
|
||||
<div>
|
||||
<LikeButton postId={post.id} />
|
||||
<CommentButton postId={post.id} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entities Layer
|
||||
|
||||
Reusable business domain models. **Create only when used in 2+ places. Starting
|
||||
without this layer is completely valid.**
|
||||
|
||||
```text
|
||||
// Minimal entity: model only (most common form)
|
||||
entities/user/
|
||||
model/
|
||||
user.ts ← Types + domain logic
|
||||
index.ts
|
||||
|
||||
// Entity with UI (use with caution)
|
||||
// ⚠️ Adding UI to entities increases cross-import risk.
|
||||
// Other entities may want to import this UI, leading to @x dependencies.
|
||||
// Entity UI should only be imported from higher layers (features, widgets,
|
||||
// pages), never from other entities.
|
||||
entities/product/
|
||||
model/
|
||||
product.ts
|
||||
ui/
|
||||
ProductCard.tsx
|
||||
index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Layer Structure
|
||||
|
||||
Infrastructure with no business logic. Organized by segments only (no slices).
|
||||
Segments may import from each other.
|
||||
|
||||
```text
|
||||
shared/
|
||||
ui/ ← UI kit: Button, Input, Modal, Card
|
||||
lib/ ← Utilities: formatDate, debounce, classnames
|
||||
api/ ← API client, route constants, CRUD helpers, base types
|
||||
auth/ ← Auth tokens, login utilities, session management
|
||||
config/ ← Environment variables, app settings
|
||||
assets/ ← Branding assets shared across the app (use sparingly)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// shared/ui/Button/Button.tsx
|
||||
export const Button = ({ children, onClick, variant = 'primary' }) => (
|
||||
<button className={`btn btn-${variant}`} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
// shared/ui/Button/index.ts
|
||||
export { Button } from './Button';
|
||||
export type { ButtonProps } from './Button';
|
||||
```
|
||||
|
||||
Shared **may** contain application-aware code (route constants, API endpoints,
|
||||
branding assets, common types). It must **never** contain business logic,
|
||||
feature-specific code, or entity-specific code.
|
||||
|
||||
For asset placement specifically (images, icons, fonts, PDFs), see
|
||||
`references/asset-handling.md`.
|
||||
|
||||
---
|
||||
|
||||
## Segments
|
||||
|
||||
A segment groups related code within a slice (or within App/Shared). The
|
||||
standard segments cover the most common technical purposes:
|
||||
|
||||
- **`ui`**: UI display (components, date formatters, styles).
|
||||
- **`api`**: backend interactions (request functions, data types, mappers).
|
||||
- **`model`**: data model (schemas, interfaces, stores, business logic).
|
||||
- **`lib`**: library code that other modules in this slice need.
|
||||
- **`config`**: configuration files and feature flags.
|
||||
|
||||
Custom segments are allowed when needed (for example, `routes` and `i18n`
|
||||
in the Shared layer, or `auth` for token storage when split out from
|
||||
`shared/api`).
|
||||
|
||||
### Group by what it is *for*, not by what it *is*
|
||||
|
||||
Segment names describe **purpose**, not the kind of code they hold. This
|
||||
is the desegmentation principle:
|
||||
|
||||
```text
|
||||
// ❌ BAD: grouping by technical kind (what the code is)
|
||||
shared/
|
||||
components/ ← What kind of components?
|
||||
hooks/ ← Which feature do they serve?
|
||||
types/ ← Which domain do they describe?
|
||||
utils/ ← Utility for what?
|
||||
helpers/ ← Same problem
|
||||
actions/ ← Redux actions for what?
|
||||
|
||||
// ✅ GOOD: grouping by purpose (what the code is for)
|
||||
shared/
|
||||
ui/ ← For displaying UI
|
||||
api/ ← For talking to the backend
|
||||
lib/ ← For library code that supports the slice
|
||||
config/ ← For configuration
|
||||
```
|
||||
|
||||
A segment named `types/` cannot answer "types for what?" without inspecting
|
||||
the contents. A segment named `model/` says: this is the data model.
|
||||
Inside `model/`, files are named by domain (`user.ts`, `order.ts`), not by
|
||||
technical role.
|
||||
|
||||
This rule applies everywhere: in `shared/`, in slices, and when designing
|
||||
new custom segments.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Domain-based file naming
|
||||
|
||||
Within a segment, name files after the business domain, not the technical
|
||||
role:
|
||||
|
||||
```text
|
||||
// ❌ Technical-role naming: mixes domains
|
||||
model/types.ts ← Which types? User? Order?
|
||||
model/utils.ts
|
||||
api/endpoints.ts
|
||||
model/selectors.ts
|
||||
|
||||
// ✅ Domain-based naming: each file owns one domain
|
||||
model/user.ts ← User types + logic + store
|
||||
model/order.ts ← Order types + logic + store
|
||||
api/fetch-profile.ts ← Clear what this API does
|
||||
model/todo.ts ← Redux slice + selectors + thunks
|
||||
```
|
||||
|
||||
### Single-concern segments
|
||||
|
||||
If a segment contains only one domain concern, the filename may match the
|
||||
slice name:
|
||||
|
||||
```text
|
||||
features/auth/
|
||||
model/
|
||||
auth.ts ← Single concern, matches slice name
|
||||
```
|
||||
|
||||
### Index files as public API
|
||||
|
||||
Every slice must have an `index.ts` that re-exports its public interface:
|
||||
|
||||
```typescript
|
||||
// entities/user/index.ts
|
||||
export { UserAvatar } from "./ui/UserAvatar";
|
||||
export { useUser, type User } from "./model/user";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slice Groups
|
||||
|
||||
A **slice group** is a folder that contains related slices on the same
|
||||
layer, used purely to make the structure easier to navigate as the number
|
||||
of slices grows. A slice group is **not** a slice itself: it has no
|
||||
segments (`model/`, `ui/`, `api/`), no public API (`index.ts`), and no
|
||||
shared code. Slice isolation rules apply unchanged inside a group: sibling
|
||||
slices in the same group cannot import from each other.
|
||||
|
||||
Slice groups are optional. Use them only when the layer has grown large
|
||||
enough that a flat structure becomes hard to scan and there is an obvious
|
||||
grouping criterion.
|
||||
|
||||
### When to use
|
||||
|
||||
- Several slices share the same business context and are scattered across
|
||||
the layer.
|
||||
- The slice names clearly suggest they belong to the same topic.
|
||||
- The layer has grown to the point where it is hard to scan at a glance.
|
||||
|
||||
### When NOT to use
|
||||
|
||||
- Names alone are enough for quick navigation.
|
||||
- There is no natural grouping criterion.
|
||||
- Only two or three slices would end up in the group.
|
||||
|
||||
### Example: grouping payment-related entities
|
||||
|
||||
```text
|
||||
entities/
|
||||
payment/ ← Slice group (no public API)
|
||||
invoice/ ← Slice
|
||||
model/
|
||||
ui/
|
||||
index.ts
|
||||
receipt/ ← Slice (model/, ui/, index.ts)
|
||||
transaction/ ← Slice (model/, ui/, index.ts)
|
||||
user/ ← Slice (not in any group)
|
||||
product/ ← Slice
|
||||
```
|
||||
|
||||
Imports go through the full path:
|
||||
|
||||
```typescript
|
||||
import { Invoice } from "@/entities/payment/invoice";
|
||||
import { Receipt } from "@/entities/payment/receipt";
|
||||
```
|
||||
|
||||
The same pattern applies to the Pages layer. For example, grouping
|
||||
`pages/order/{list,detail,create}` when there are multiple pages on the same
|
||||
topic such as list, detail, create, and edit. This is one possible example
|
||||
and does not represent the default structure for the Pages layer.
|
||||
|
||||
### Features: use with caution
|
||||
|
||||
Slice groups can be applied to Features, but features often span multiple
|
||||
entities and lack a natural grouping criterion. A group like
|
||||
`features/cart/` tends to attract everything cart-related (DTOs, mappers,
|
||||
helpers) until it stops being a navigation aid and starts acting as the
|
||||
home for the entire cart domain, which weakens the principle that
|
||||
features are split by use case. Before grouping features, check that the
|
||||
group contains only feature slices and that two or three slices is not the
|
||||
entire content.
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Do not put `index.ts` on the group folder.** That promotes the group
|
||||
to a slice and breaks the layer's contract.
|
||||
- **Do not put shared `utils.ts`, `constants.ts`, or `types.ts` files
|
||||
inside the group.** A slice group has no shared code. Extract reusable
|
||||
code to `shared/` instead. If the layer is `entities` and the shared
|
||||
logic is genuinely domain logic, consider whether the boundaries are
|
||||
too granular and the slices should be merged into one isolated entity
|
||||
(see `references/excessive-entities.md`). The `@x` notation does not
|
||||
apply to slice groups. It is a cross-import surface between entity
|
||||
slices, not a sharing mechanism for siblings within a group.
|
||||
- **Do not relax slice isolation inside the group.** If two slices in the
|
||||
same group need to share code, extract it one layer down rather than
|
||||
adding a `_common/` file.
|
||||
|
||||
---
|
||||
|
||||
## Path Aliases
|
||||
|
||||
Configure path aliases so imports follow the `@/layer/slice` pattern:
|
||||
|
||||
```json
|
||||
// tsconfig.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/app/*": ["src/app/*"],
|
||||
"@/pages/*": ["src/pages/*"],
|
||||
"@/widgets/*": ["src/widgets/*"],
|
||||
"@/features/*": ["src/features/*"],
|
||||
"@/entities/*": ["src/entities/*"],
|
||||
"@/shared/*": ["src/shared/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For framework-specific alias configuration (Vite, Next.js, Nuxt, Astro),
|
||||
see `references/framework-integration.md`.
|
||||
@@ -0,0 +1,293 @@
|
||||
# Migration Guide
|
||||
|
||||
How to migrate to FSD v2.1 from either FSD v2.0 or a custom (non-FSD)
|
||||
architecture. This guide reflects the official `from-custom` step order:
|
||||
**pages first**, then everything else.
|
||||
|
||||
## Part 1: FSD v2.0 → v2.1 (non-breaking)
|
||||
|
||||
The v2.1 update emphasizes **"pages first"**: most logic stays in pages,
|
||||
reusable foundation in Shared. If reuse is needed across several pages,
|
||||
move it to a layer below. The migration is non-breaking and simplifies
|
||||
the codebase by relocating single-use code back to where it is consumed.
|
||||
|
||||
Another addition in v2.1 is the standardization of cross-imports between
|
||||
entities with the `@x` notation. See `references/cross-import-patterns.md`.
|
||||
|
||||
### Step 1. Audit existing slices
|
||||
|
||||
Use Steiger to detect slices that are used in only one place:
|
||||
|
||||
```bash
|
||||
npm install -D @feature-sliced/steiger
|
||||
npx steiger src
|
||||
```
|
||||
|
||||
Look for these rules:
|
||||
|
||||
- **`insignificant-slice`**: an entity or feature used by only one page.
|
||||
This rule will suggest merging that entity or feature into the page
|
||||
entirely.
|
||||
- **`excessive-slicing`**: too many slices in a single layer.
|
||||
|
||||
For each flagged slice, decide:
|
||||
|
||||
- Reused in 2+ places → keep in features/entities.
|
||||
- Used only in one page → mark for migration back into that page.
|
||||
|
||||
### Step 2. Move single-use code back to its consumer
|
||||
|
||||
Take single-use features and entities and inline them into the consuming
|
||||
page (or widget if that is the single consumer):
|
||||
|
||||
```text
|
||||
// Before (v2.0): feature used by only one page
|
||||
features/user-profile-form/
|
||||
ui/ProfileForm.tsx
|
||||
model/profile-form.ts
|
||||
api/update-profile.ts
|
||||
index.ts
|
||||
pages/profile/
|
||||
ui/ProfilePage.tsx ← Thin wrapper, just composes
|
||||
|
||||
// After (v2.1): code lives in the page that owns it
|
||||
pages/profile/
|
||||
ui/{ProfilePage,ProfileForm}.tsx
|
||||
model/profile.ts ← Merged form logic
|
||||
api/update-profile.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
For each moved slice:
|
||||
|
||||
1. Copy all files into the consuming page.
|
||||
2. Update the page's `index.ts` to export what is needed externally.
|
||||
3. Update all imports across the codebase to point to the new location.
|
||||
4. Delete the now-empty feature/entity directory.
|
||||
5. Run tests.
|
||||
|
||||
### Step 3. Keep genuinely reused code in place
|
||||
|
||||
Code confirmed to be used in 2+ places stays in features/entities. Do not
|
||||
move it. The point of v2.1 is reducing premature extraction, not removing
|
||||
reuse.
|
||||
|
||||
### Step 4. Deprecate the processes layer
|
||||
|
||||
The `processes` layer is deprecated. Migrate its code:
|
||||
|
||||
- **Multi-page workflows** (checkout, onboarding wizard): move
|
||||
orchestration logic to the page that initiates the workflow. If multiple
|
||||
pages share workflow state, create a feature for it.
|
||||
- **Background processes** (polling, sync): move to `app/` if global, or
|
||||
to the relevant page/feature if scoped.
|
||||
|
||||
```text
|
||||
// Before
|
||||
processes/
|
||||
checkout/model/checkout-flow.ts
|
||||
sync/model/background-sync.ts
|
||||
|
||||
// After
|
||||
features/checkout/model/checkout-flow.ts ← Used in 2+ pages
|
||||
app/sync/background-sync.ts ← Global concern
|
||||
```
|
||||
|
||||
### Post-migration verification
|
||||
|
||||
1. Run `npx steiger src`. All `insignificant-slice` warnings should be gone.
|
||||
2. Verify import directions. No upward or same-layer cross-imports.
|
||||
3. Check that no empty layer directories remain.
|
||||
4. Update documentation to reflect the new structure.
|
||||
|
||||
## Part 2: Custom architecture → FSD
|
||||
|
||||
This part follows the official `from-custom` migration order. The core
|
||||
philosophy is **pages first**: start by dividing the code by pages, then
|
||||
work outward.
|
||||
|
||||
### Before you start
|
||||
|
||||
The most important question to ask the team is: *do you really need it?*
|
||||
Some projects are perfectly fine without FSD. Reasons to consider the
|
||||
switch:
|
||||
|
||||
1. New team members struggle to reach a productive level.
|
||||
2. Modifications to one part of the code **often** break unrelated parts.
|
||||
3. Adding new functionality is difficult due to the volume of context to
|
||||
hold in mind.
|
||||
|
||||
**Avoid switching to FSD against the will of teammates**, even as a lead.
|
||||
Convince the team that the benefits outweigh migration and learning costs.
|
||||
Explain the migration plan to management; architectural changes are not
|
||||
immediately observable to them.
|
||||
|
||||
If the decision is made, set up a path alias for `src/` first. This guide
|
||||
uses `@` as an alias for `./src`.
|
||||
|
||||
### Step 1. Divide the code by pages
|
||||
|
||||
If `pages/` already exists, skip this step. Otherwise, create `pages/` and
|
||||
move as much component code as possible from `routes/` (or equivalent) into
|
||||
it. Aim for tiny route files that just re-export from page slices.
|
||||
|
||||
```text
|
||||
// Route file (thin)
|
||||
src/routes/products.[id].js
|
||||
export { ProductPage as default } from "@/pages/product"
|
||||
|
||||
// Page slice
|
||||
src/pages/product/
|
||||
ui/ProductPage.jsx
|
||||
index.js ← export { ProductPage } from "./ProductPage.jsx"
|
||||
```
|
||||
|
||||
Pages may reference each other for now. Tackle that later. Focus on
|
||||
establishing a prominent division by pages.
|
||||
|
||||
### Step 2. Separate everything else from pages
|
||||
|
||||
Create `src/shared/` and move everything that does **not** import from
|
||||
`pages/` or `routes/` there. Create `src/app/` and move everything that
|
||||
**does** import the pages or routes there, including the routes themselves.
|
||||
|
||||
The Shared layer has no slices, so segments may import from each other.
|
||||
|
||||
```text
|
||||
src/
|
||||
app/
|
||||
routes/
|
||||
products.jsx
|
||||
products.[id].jsx
|
||||
App.jsx
|
||||
index.js
|
||||
pages/
|
||||
product/
|
||||
ui/ProductPage.jsx
|
||||
index.js
|
||||
catalog/
|
||||
shared/
|
||||
actions/, api/, components/, containers/, constants/,
|
||||
i18n/, modules/, helpers/, utils/, reducers/, selectors/, styles/
|
||||
```
|
||||
|
||||
### Step 3. Tackle cross-imports between pages
|
||||
|
||||
Find all cases where one page imports from another. Resolve each in one of
|
||||
two ways:
|
||||
|
||||
1. **Copy-paste** the imported code into the depending page to remove the
|
||||
dependency.
|
||||
2. **Move to a Shared segment**:
|
||||
- UI kit code → `shared/ui/`
|
||||
- configuration constants → `shared/config/`
|
||||
- backend interaction → `shared/api/`
|
||||
|
||||
Copy-pasting is **not architecturally wrong**. Sometimes it is more correct
|
||||
to duplicate than to abstract into a new reusable module, because the
|
||||
shared parts of pages can drift apart over time. Still, the DRY principle
|
||||
holds for business logic: avoid copy-pasting code that must stay in sync
|
||||
across multiple places.
|
||||
|
||||
### Step 4. Unpack the Shared layer
|
||||
|
||||
The Shared layer can become bloated after Step 2. Find every object used in
|
||||
only one page and move it to that page's slice. **This applies to actions,
|
||||
reducers, and selectors too.** There is no benefit in grouping all actions
|
||||
together, but there is benefit in colocating relevant actions close to
|
||||
their usage.
|
||||
|
||||
```text
|
||||
src/
|
||||
pages/
|
||||
product/
|
||||
actions/, reducers/, selectors/, ui/ ← moved from shared
|
||||
index.js
|
||||
catalog/
|
||||
shared/ ← only objects that are reused
|
||||
actions/, api/, components/, ...
|
||||
```
|
||||
|
||||
### Step 5. Organize code by technical purpose (segments)
|
||||
|
||||
In FSD, division by technical purpose is done with **segments**. The common
|
||||
ones are:
|
||||
|
||||
- **`ui`**: everything related to UI display (components, date formatters,
|
||||
styles).
|
||||
- **`api`**: backend interactions (request functions, data types, mappers).
|
||||
- **`model`**: the data model (schemas, interfaces, stores, business
|
||||
logic).
|
||||
- **`lib`**: library code that other modules in the slice need.
|
||||
- **`config`**: configuration files and feature flags.
|
||||
|
||||
Custom segments are allowed when needed. **Do not create segments that
|
||||
group code by what it is**, like `components`, `actions`, `types`, or
|
||||
`utils`. Group code by what it is **for**, not by what it is. This is the
|
||||
desegmentation principle.
|
||||
|
||||
Reorganize each page to separate code by segments:
|
||||
|
||||
- The existing page UI files become the `ui` segment.
|
||||
- Actions, reducers, and selectors become the `model` segment.
|
||||
- Thunks and mutations become the `api` segment.
|
||||
|
||||
Reorganize the Shared layer too:
|
||||
|
||||
- `components/`, `containers/` → most of it becomes `shared/ui/`.
|
||||
- `helpers/`, `utils/` → group by function (dates, type conversions, etc.)
|
||||
and move groups to `shared/lib/`.
|
||||
- `constants/` → group by function and move to `shared/config/`.
|
||||
|
||||
## Optional steps
|
||||
|
||||
### Step 6. Form entities/features from Redux slices used on several pages
|
||||
|
||||
Reused Redux slices typically describe business concepts (products, users)
|
||||
or user actions (comments, likes):
|
||||
|
||||
- Business entities → **Entities layer**, one entity per folder.
|
||||
- User actions → **Features layer**.
|
||||
|
||||
Entities and features are meant to be independent. If your business domain
|
||||
contains inherent connections between entities (a song belongs to an
|
||||
artist), see the
|
||||
[business entities cross-references guide](https://fsd.how/docs/guides/examples/types#business-entities-and-their-cross-references).
|
||||
|
||||
API functions related to these slices can stay in `shared/api`.
|
||||
|
||||
### Step 7. Refactor your modules
|
||||
|
||||
The `modules/` folder typically holds business logic, similar in nature to
|
||||
the Features layer. Some modules describe large UI chunks (an app header)
|
||||
which belong in the Widgets layer.
|
||||
|
||||
### Step 8. Form a clean UI foundation in `shared/ui`
|
||||
|
||||
`shared/ui` should contain UI elements with no encoded business logic.
|
||||
Refactor components from `components/` and `containers/` to extract their
|
||||
business logic to higher layers. If business logic is not used in many
|
||||
places, copy-pasting back to consumers is an acceptable choice.
|
||||
|
||||
## Common pitfalls during migration
|
||||
|
||||
1. **Extracting too early.** Wait for real reuse, not anticipated reuse.
|
||||
The v2.1 philosophy is "pages first, extract later".
|
||||
2. **Creating empty layers.** Do not create `features/`, `entities/`, or
|
||||
`widgets/` directories until there is content for them.
|
||||
3. **Refactoring while migrating.** Separate relocation from refactoring.
|
||||
Move files first, improve them in separate commits.
|
||||
4. **Ignoring import direction.** Enforce import rules from day one with
|
||||
ESLint or Steiger.
|
||||
5. **Big-bang migration.** Migrate page by page, verifying each step. A
|
||||
hybrid structure (partly FSD, partly legacy) is acceptable during
|
||||
transition.
|
||||
6. **Grouping by technical role.** `components/`, `actions/`, `utils/` as
|
||||
segment names defeat the purpose of FSD. Group by what code is for.
|
||||
|
||||
## Migrating from FSD v1 to v2
|
||||
|
||||
This guide does not cover v1 → v2. See the official
|
||||
[v1 to v2 migration guide](https://fsd.how/docs/guides/migration/from-v1).
|
||||
The v1 → v2 transition introduced the entities and processes layers
|
||||
(processes was later deprecated in v2.1).
|
||||
@@ -0,0 +1,533 @@
|
||||
# Practical Examples
|
||||
|
||||
Concrete code patterns for common scenarios within FSD structure. Covers
|
||||
authentication, type definitions, API request handling, and state management
|
||||
integration (Redux, TanStack Query / React Query).
|
||||
|
||||
## Authentication
|
||||
|
||||
Auth is one of the most common sources of confusion in FSD. The key question
|
||||
is: what goes in `shared/`, what goes in `features/` or `pages/`?
|
||||
|
||||
### Auth data: `shared/auth/` or `shared/api/`
|
||||
|
||||
Tokens, session state, and login utilities are **infrastructure**, not
|
||||
business logic. Keep them in shared:
|
||||
|
||||
```typescript
|
||||
// shared/auth/token.ts
|
||||
const TOKEN_KEY = "auth_token";
|
||||
export const getToken = () => localStorage.getItem(TOKEN_KEY);
|
||||
export const setToken = (t: string) => localStorage.setItem(TOKEN_KEY, t);
|
||||
export const clearToken = () => localStorage.removeItem(TOKEN_KEY);
|
||||
|
||||
// shared/auth/session.ts
|
||||
export interface Session { userId: string; email: string; role: "admin" | "user" }
|
||||
// useSession depends on the auth provider (React Context, Zustand, etc.)
|
||||
export const useSession = (): Session | null => { /* ... */ };
|
||||
```
|
||||
|
||||
The `shared/auth/index.ts` re-exports from these files following the
|
||||
standard public API pattern.
|
||||
|
||||
### Auth UI: pages (single use) or features (multi-use)
|
||||
|
||||
Place the login form in the slice that consumes it. Single-use (only on the
|
||||
login page) goes in `pages/login/`; multi-use (dedicated page + modal login)
|
||||
goes in `features/auth/`:
|
||||
|
||||
```text
|
||||
pages/login/ ← Single-use
|
||||
ui/{LoginPage,LoginForm}.tsx
|
||||
model/login.ts ← Form state, validation
|
||||
api/login.ts ← POST /auth/login
|
||||
index.ts
|
||||
|
||||
features/auth/ ← Multi-use
|
||||
ui/{LoginForm,RegisterForm}.tsx
|
||||
model/auth.ts
|
||||
api/{login,register}.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
### When to use shared/auth vs a user entity
|
||||
|
||||
The official Auth guide presents two valid storage locations: **In Shared**
|
||||
(`shared/auth` or `shared/api`) and **In Entities** (a `user` entity).
|
||||
Pages and widgets are discouraged.
|
||||
|
||||
`shared/auth` is the simpler default. Choose it when the project has no
|
||||
entities layer yet, or when auth state is just a token plus minimal user info.
|
||||
|
||||
A `user` entity is the right call when the project already has an
|
||||
entities layer **and** auth and profile data are tightly coupled (profile
|
||||
reused for non-auth purposes like avatars in comments).
|
||||
|
||||
```text
|
||||
// Path A: shared/auth (simpler default)
|
||||
shared/auth/session.ts ← userId, email, role, token
|
||||
|
||||
// Path B: user entity (entities layer exists, profile reuse is real)
|
||||
entities/user/
|
||||
model/
|
||||
current-user.ts ← Current authenticated user + token
|
||||
user.ts ← Generic user type
|
||||
api/get-current-user.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
For the entity approach, the API client in `shared/api` cannot import from
|
||||
`entities/`. The official guide describes three solutions: pass the token
|
||||
manually, expose it through a context with the key kept in `shared/api`,
|
||||
or inject the token into the API client when the entity store updates.
|
||||
|
||||
A `user` entity created **only** to wrap a login response is premature.
|
||||
See `references/excessive-entities.md` for the full decision matrix.
|
||||
|
||||
## Type Definitions
|
||||
|
||||
### Where to define types
|
||||
|
||||
The location of type definitions follows the same rules as any other code:
|
||||
|
||||
| Type scope | Location |
|
||||
| --- | --- |
|
||||
| API response/request shapes shared across the app | Domain-named files in `shared/api/` (e.g., `shared/api/product.ts`) |
|
||||
| Types for a specific entity's domain model | `entities/<name>/model/<name>.ts` |
|
||||
| Types used only within one page | `pages/<name>/model/<name>.ts` |
|
||||
| Types used only within one feature | `features/<name>/model/<name>.ts` |
|
||||
| Generic utility types (e.g., `Nullable<T>`) | Domain-named files in `shared/lib/` (e.g., `shared/lib/nullable.ts`) |
|
||||
|
||||
Per Rule 4-4 (domain-based file naming), avoid grouping all types in
|
||||
`types.ts` or `utils.ts`. A file named `types.ts` cannot answer "types
|
||||
for what?" without inspection; a file named `product.ts` can.
|
||||
|
||||
### Example: API types in shared
|
||||
|
||||
```typescript
|
||||
// shared/api/product.ts: raw API response shapes
|
||||
export interface ProductDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
category: string;
|
||||
createdAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Domain types in entities
|
||||
|
||||
```typescript
|
||||
// entities/product/model/product.ts: domain model layered on top
|
||||
import type { ProductDTO } from "@/shared/api/product";
|
||||
|
||||
export interface Product extends ProductDTO {
|
||||
formattedPrice: string;
|
||||
isOnSale: boolean;
|
||||
}
|
||||
|
||||
export const fromDTO = (dto: ProductDTO): Product => ({
|
||||
...dto,
|
||||
formattedPrice: `$${dto.price.toFixed(2)}`,
|
||||
isOnSale: dto.price < 10,
|
||||
});
|
||||
```
|
||||
|
||||
**Key principle:** Raw API shapes go in `shared/api/`. Domain models with
|
||||
business logic go in `entities/`. If you only need the raw shape, do not
|
||||
create an entity just for types.
|
||||
|
||||
## API Request Handling
|
||||
|
||||
### Basic pattern: API calls in the consuming slice
|
||||
|
||||
```typescript
|
||||
// pages/product-detail/api/fetch-product.ts
|
||||
import { apiClient } from "@/shared/api/client";
|
||||
import type { ProductDTO } from "@/shared/api/product";
|
||||
|
||||
export const fetchProduct = (id: string): Promise<ProductDTO> =>
|
||||
apiClient.get(`/products/${id}`).then((r) => r.data);
|
||||
```
|
||||
|
||||
### Shared API client setup
|
||||
|
||||
```typescript
|
||||
// shared/api/client.ts
|
||||
import axios from "axios";
|
||||
import { getToken } from "@/shared/auth/token";
|
||||
|
||||
export const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_URL });
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
### CRUD helpers in shared
|
||||
|
||||
```typescript
|
||||
// shared/api/create-crud-api.ts
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export const createCrudApi = <T>(resource: string) => ({
|
||||
getAll: () => apiClient.get<T[]>(`/${resource}`).then((r) => r.data),
|
||||
getById: (id: string) => apiClient.get<T>(`/${resource}/${id}`).then((r) => r.data),
|
||||
create: (data: Partial<T>) => apiClient.post<T>(`/${resource}`, data).then((r) => r.data),
|
||||
update: (id: string, data: Partial<T>) => apiClient.put<T>(`/${resource}/${id}`, data).then((r) => r.data),
|
||||
remove: (id: string) => apiClient.delete(`/${resource}/${id}`),
|
||||
});
|
||||
|
||||
// Usage: export const productsApi = createCrudApi<ProductDTO>("products");
|
||||
```
|
||||
|
||||
### Request placement rule
|
||||
|
||||
Place each request function in the slice that owns the use case:
|
||||
|
||||
- **Page-specific data fetching** (e.g., dashboard stats only used on the
|
||||
dashboard) → `pages/<name>/api/`
|
||||
- **Feature-specific actions** (e.g., `toggleLike`) → `features/<name>/api/`
|
||||
- **Reusable domain queries** (e.g., `getUserById`) → `entities/<name>/api/`
|
||||
- **CRUD primitives** for a generic resource → `shared/api/create-crud-api.ts`
|
||||
|
||||
Do not put domain-specific request functions in `shared/api/`. Shared is
|
||||
infrastructure; the moment a function knows about a specific resource and
|
||||
its domain rules, it belongs in `entities/` or higher.
|
||||
|
||||
## State Management: Redux
|
||||
|
||||
### Where a Redux slice belongs
|
||||
|
||||
The `from-custom` migration guide draws a clean line: **business
|
||||
entities** (the things your app works with, like `todo`, `product`, `user`)
|
||||
go in the Entities layer; **user actions** (`add-todo`, `toggle-todo`,
|
||||
`like-post`) go in Features.
|
||||
|
||||
In v2.1, also remember the pages-first rule: if the slice is used by a
|
||||
single page, keep it in that page's `model/` segment until reuse appears.
|
||||
|
||||
### Business-entity slice in entities
|
||||
|
||||
```typescript
|
||||
// entities/todo/model/todo.ts
|
||||
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { apiClient } from "@/shared/api/client";
|
||||
|
||||
interface Todo { id: string; title: string; completed: boolean }
|
||||
interface TodoState { items: Todo[]; loading: boolean }
|
||||
|
||||
export const fetchTodos = createAsyncThunk("todos/fetch", async () =>
|
||||
(await apiClient.get<Todo[]>("/todos")).data,
|
||||
);
|
||||
|
||||
const todoSlice = createSlice({
|
||||
name: "todos",
|
||||
initialState: { items: [], loading: false } as TodoState,
|
||||
reducers: {
|
||||
setCompleted: (state, { payload }: { payload: { id: string; completed: boolean } }) => {
|
||||
const todo = state.items.find((t) => t.id === payload.id);
|
||||
if (todo) todo.completed = payload.completed;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchTodos.pending, (state) => { state.loading = true; })
|
||||
.addCase(fetchTodos.fulfilled, (state, action) => {
|
||||
state.items = action.payload;
|
||||
state.loading = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setCompleted } = todoSlice.actions;
|
||||
export const selectTodos = (state: RootState) => state.todos.items;
|
||||
export const todoReducer = todoSlice.reducer;
|
||||
```
|
||||
|
||||
The slice's public API re-exports what consumers need:
|
||||
|
||||
```typescript
|
||||
// entities/todo/index.ts
|
||||
export { todoReducer, selectTodos, setCompleted, fetchTodos } from "./model/todo";
|
||||
```
|
||||
|
||||
**Key:** The entire Redux slice (reducer + selectors + thunks) lives in a
|
||||
single domain-named file, not split across `reducers.ts`, `selectors.ts`,
|
||||
`thunks.ts`. That technical-role split reduces cohesion and is an
|
||||
anti-pattern in FSD.
|
||||
|
||||
### User-action slice in features
|
||||
|
||||
A user action that orchestrates the entity exposes a hook through its
|
||||
public API and consumes the entity's reducer:
|
||||
|
||||
```typescript
|
||||
// features/toggle-todo/model/use-toggle-todo.ts
|
||||
import { useDispatch } from "react-redux";
|
||||
import { setCompleted } from "@/entities/todo";
|
||||
|
||||
export const useToggleTodo = () => {
|
||||
const dispatch = useDispatch();
|
||||
return (id: string, current: boolean) =>
|
||||
dispatch(setCompleted({ id, completed: !current }));
|
||||
};
|
||||
```
|
||||
|
||||
### Registering slices in app
|
||||
|
||||
```typescript
|
||||
// app/providers/store.ts
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { todoReducer } from "@/entities/todo";
|
||||
import { userReducer } from "@/entities/user";
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
todos: todoReducer,
|
||||
user: userReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
```
|
||||
|
||||
The store imports each slice's reducer through its public API
|
||||
(`index.ts`), never reaching into `model/` directly (Rule 4-2). Do not
|
||||
let individual slices create their own stores.
|
||||
|
||||
## State Management: TanStack Query (React Query)
|
||||
|
||||
Guidance applies to `@tanstack/react-query` v5 (formerly React Query). The
|
||||
package name is `@tanstack/react-query`.
|
||||
|
||||
### Where to store query keys
|
||||
|
||||
Three placements are valid. Choose based on project size and whether the
|
||||
project already has an Entities layer.
|
||||
|
||||
**Option 1: Flat in `shared/api/queries/`** (small projects, few endpoints):
|
||||
|
||||
```text
|
||||
shared/api/
|
||||
queries/
|
||||
example.ts
|
||||
another-example.ts
|
||||
index.ts ← export { exampleQueries } from './queries/example';
|
||||
```
|
||||
|
||||
**Option 2: Per controller in `shared/api/<controller>/`** (many endpoints):
|
||||
|
||||
```text
|
||||
shared/api/example/
|
||||
index.ts ← export { exampleQueries } from './example.query';
|
||||
example.query.ts ← Query factory: keys + functions
|
||||
get-example.ts
|
||||
create-example.ts
|
||||
update-example.ts
|
||||
delete-example.ts
|
||||
```
|
||||
|
||||
**Option 3: Per entity in `entities/<entity>/api/`** when each request
|
||||
corresponds to a single entity, and the project already has an Entities
|
||||
layer. When entities reference each other, see
|
||||
`references/cross-import-patterns.md` for `@x` notation as a last resort.
|
||||
|
||||
### Where to store mutations
|
||||
|
||||
Do not mix mutations with queries. Two patterns are accepted:
|
||||
|
||||
1. **A mutation hook in the `api/` segment near the place of use.** Use
|
||||
`setQueryData` for cache updates:
|
||||
|
||||
```typescript
|
||||
// src/pages/example/api/use-update-example.ts
|
||||
export const useUpdateExample = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, newTitle }) => apiClient.patch(`/posts/${id}`, { title: newTitle }).then((r) => r.data),
|
||||
onSuccess: (newPost, { id }) => queryClient.setQueryData(POST_QUERIES.detail({ id }).queryKey, newPost),
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
2. **A `mutationFn` defined in `shared/` or `entities/`** and called from
|
||||
`useMutation` in the component.
|
||||
|
||||
### Query factory pattern
|
||||
|
||||
A query factory is an object whose values return query keys. Each key is
|
||||
wrapped in `queryOptions`, a built-in helper from `@tanstack/react-query` v5
|
||||
that lets you share `queryKey` and `queryFn` between `useQuery`,
|
||||
`useSuspenseQuery`, `prefetchQuery`, `setQueryData`, and similar APIs
|
||||
without rewriting them:
|
||||
|
||||
```typescript
|
||||
// src/shared/api/post/post.queries.ts
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { getPosts, getDetailPost, type DetailPostQuery } from "./get-posts";
|
||||
|
||||
export const POST_QUERIES = {
|
||||
all: () => ["posts"],
|
||||
lists: () => [...POST_QUERIES.all(), "list"],
|
||||
list: (page: number, limit: number) => queryOptions({
|
||||
queryKey: [...POST_QUERIES.lists(), page, limit],
|
||||
queryFn: () => getPosts(page, limit),
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
detail: (query?: DetailPostQuery) => queryOptions({
|
||||
queryKey: [...POST_QUERIES.all(), "detail", query?.id],
|
||||
queryFn: () => getDetailPost({ id: query?.id }),
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
Consume with `useQuery(POST_QUERIES.detail({ id }))`. For pagination,
|
||||
`placeholderData: prev => prev` prevents UI flicker when navigating pages.
|
||||
|
||||
**Benefits of a query factory:** all API requests for a domain live in one
|
||||
place (readability), every key and query function is reachable through the
|
||||
same object (convenient access), and refetching is a one-line call
|
||||
(`queryClient.invalidateQueries({ queryKey: POST_QUERIES.all() })`) without
|
||||
hunting down keys across the codebase.
|
||||
|
||||
### Infinite scroll
|
||||
|
||||
Use `infiniteQueryOptions` with `initialPageParam` and `getNextPageParam`.
|
||||
Add the infinite key to the same factory shown above:
|
||||
|
||||
```typescript
|
||||
import { infiniteQueryOptions } from "@tanstack/react-query";
|
||||
|
||||
// Inside POST_QUERIES:
|
||||
infinite: (limit: number) => infiniteQueryOptions({
|
||||
queryKey: [...POST_QUERIES.lists(), "infinite", limit],
|
||||
queryFn: ({ pageParam }) => getPosts(pageParam, limit),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => lastPage.skip + lastPage.limit < lastPage.total ? lastPage.skip / lastPage.limit + 1 : undefined,
|
||||
}),
|
||||
```
|
||||
|
||||
Consume with `useInfiniteQuery` and flatten via `data?.pages.flatMap(...)`.
|
||||
|
||||
### Suspense mode
|
||||
|
||||
`queryOptions` and `useSuspenseQuery` are compatible, and the factory does
|
||||
not change. Components use `useSuspenseQuery` instead of `useQuery` and skip
|
||||
`isLoading` entirely. Wrap interested subtrees with an `ErrorBoundary` +
|
||||
`Suspense` provider in the App layer:
|
||||
|
||||
```tsx
|
||||
// src/app/providers/suspense-provider.tsx
|
||||
import { Suspense } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
export const SuspenseProvider = ({ children }) => (
|
||||
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
||||
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
```
|
||||
|
||||
### Reading mutation state with useMutationState
|
||||
|
||||
`useMutationState` lets any component read the state of a mutation without
|
||||
passing props, useful for global save indicators. Store mutation keys next
|
||||
to the query factory:
|
||||
|
||||
```typescript
|
||||
// src/shared/api/post/post.queries.ts
|
||||
export const POST_MUTATIONS = {
|
||||
updateTitle: () => ["post", "update-title"],
|
||||
create: () => ["post", "create"],
|
||||
};
|
||||
```
|
||||
|
||||
Tag the mutation with `mutationKey`, then read its state from any component:
|
||||
|
||||
```tsx
|
||||
// src/features/update-post/api/use-update-post-title.ts
|
||||
export const useUpdatePostTitle = () =>
|
||||
useMutation({
|
||||
mutationKey: POST_MUTATIONS.updateTitle(),
|
||||
mutationFn: ({ id, newTitle }) => apiClient.patch(`/posts/${id}`, { title: newTitle }),
|
||||
});
|
||||
|
||||
// src/widgets/save-indicator/ui/save-indicator.tsx
|
||||
import { useMutationState } from "@tanstack/react-query";
|
||||
import { POST_MUTATIONS } from "@/shared/api/post";
|
||||
|
||||
export const SaveIndicator = () => {
|
||||
const isPending = useMutationState({
|
||||
filters: { mutationKey: POST_MUTATIONS.updateTitle(), status: "pending" },
|
||||
select: (m) => m.state.status,
|
||||
}).length > 0;
|
||||
return isPending && <span>Saving...</span>;
|
||||
};
|
||||
```
|
||||
|
||||
### QueryProvider in the app layer
|
||||
|
||||
```tsx
|
||||
// src/app/providers/query-provider.tsx
|
||||
import { QueryClient, QueryClientProvider, MutationCache, QueryCache } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({ onError: (e) => toast.error(e.message) }),
|
||||
mutationCache: new MutationCache({ onError: (e) => toast.error(e.message) }),
|
||||
defaultOptions: { queries: { staleTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000 } },
|
||||
});
|
||||
|
||||
export const QueryProvider = ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
```
|
||||
|
||||
`QueryCache.onError` and `MutationCache.onError` give one place to wire up
|
||||
global toast notifications instead of repeating error handling on every hook.
|
||||
|
||||
### Code generation
|
||||
|
||||
Tools that generate clients from an OpenAPI/Swagger spec are less flexible
|
||||
than hand-written factories. If your spec is clean and you adopt a generator,
|
||||
place the generated code in `@/shared/api/`.
|
||||
|
||||
### Custom API client
|
||||
|
||||
Standardize base URL, headers, and JSON handling in a single class in
|
||||
`shared/api/`:
|
||||
|
||||
```typescript
|
||||
// src/shared/api/api-client.ts
|
||||
export class ApiClient {
|
||||
#baseUrl: string;
|
||||
constructor(url: string) { this.#baseUrl = url; }
|
||||
|
||||
async #handle<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
get = <T>(path: string) => fetch(`${this.#baseUrl}${path}`).then((r) => this.#handle<T>(r));
|
||||
// post, put, delete follow the same pattern with method/headers/body.
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient(API_URL);
|
||||
```
|
||||
|
||||
**Key principle:** Place query and mutation hooks in the slice that owns the
|
||||
domain. Page-specific queries stay in the page. Shared queries go in
|
||||
`shared/api/` or `entities/<name>/api/` depending on whether the project has
|
||||
an Entities layer.
|
||||
|
||||
## See also
|
||||
|
||||
- [Sample project on GitHub](https://github.com/ruslan4432013/fsd-react-query-example)
|
||||
- [Query options API (tkdodo blog)](https://tkdodo.eu/blog/the-query-options-api)
|
||||
Reference in New Issue
Block a user