diff --git a/i18n/en/docusaurus-plugin-content-docs/current/guides/examples/entity-layer.md b/i18n/en/docusaurus-plugin-content-docs/current/guides/examples/entity-layer.md new file mode 100644 index 0000000000..66d30ed446 --- /dev/null +++ b/i18n/en/docusaurus-plugin-content-docs/current/guides/examples/entity-layer.md @@ -0,0 +1,704 @@ +--- +title: When to Create an Entity +sidebar_position: 4 +--- + +# When to Create an Entity + +An important principle for effective use of the Entities layer: **not everything should be an entity**. Don't create entities "just in case." + +This section will help you decide where to place code related to business entities, considering the context of your project and team. + +--- + +## Philosophy of the Approach + +FSD follows the **"Local First"** principle — start with local code in `pages/`, and extract to common layers only when there's **real necessity**. + +Understanding the business domain helps make more informed architectural decisions. However, understanding the domain **does not mean** immediately creating code structure. + +When code starts being reused, there are **three valid approaches** to organizing it: + +**Approach 0: Locality** (always recommended as a starting point) +- Code stays in `pages/` +- Used in only one place + +**Approach 1: Centralized API** (`shared/api`) +- API and types in one place +- Migration to `entities/` when complexity grows + +**Approach 2: Domain API** (`entities/*/api/`) +- Driven by business domain understanding — if an object has a unique business identifier and meaningful behavior, it warrants its own slice +- API placement inside the entity slice follows from that decision, not the other way around +- Full encapsulation from day one + +All three approaches align with **FSD philosophy**: avoid premature decomposition and add layers as needed. + +--- + +## Signs of a Business Entity + +Before deciding where to place code, it helps to recognize whether an object is a business entity. Business entities are the core concepts your product is built around — they have identity, behavior, and meaning to the people using the product, not just to the code. The following signs help identify them, though no single sign is sufficient on its own. + +**1. Unique Identity** + +A business entity can be distinguished from other instances of the same type by a unique attribute — something meaningful to the business, not just a database row: + +```typescript +// Business uniqueness — the identifier has meaning in the domain +Order { orderNumber: "ORD-2024-001" } // referenced in emails, invoices, support tickets +Product { sku: "LAPTOP-XPS-15" } // used in catalogues, warehouses, orders +Payment { type: "card", last4: "4242" } // meaningful to the customer and accounting +``` + +A technical `id` alone is not enough to define an entity. Consider a `LogEntry`: + +```typescript +LogEntry { id: 789, message: "User logged in", timestamp: "..." } +``` + +`LogEntry` has a unique `id`, but it has no business meaning, no lifecycle, and no relationships that matter to the domain. It's infrastructure — keep it local or in `shared/`. + +The presence of a unique identifier is a **hint**, not a rule. + +**2. Business Term** + +The object is a term the business uses when talking about the product. A good signal: if a product manager, support agent, or customer would use this word in a sentence, it's likely a business entity. + +| How the business says it | How it appears in code | +|--------------------------|------------------------| +| "create a user account" | `User` | +| "place an order" | `Order` | +| "issue an invoice" | `Invoice` | +| "renew the subscription" | `Subscription` | + +Technical objects that never appear in business conversations are not entities: + +```typescript +// NOT entities — these are implementation details +Form, Modal, Layout, Component, State, Config +``` + +**3. Stateful Behavior** + +The object has distinct states it can transition between: + +```typescript +Order { + status: 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled' +} + +Subscription { + status: 'trial' | 'active' | 'past_due' | 'cancelled' | 'expired' +} +``` + +**4. Relationships with Other Objects** + +``` +Order -> belongs to -> User +Order -> contains -> Products +User -> has -> Subscription +``` + +In code, these relationships appear as references between types: + +```typescript +interface Order { + id: string + userId: string // belongs to User + productIds: string[] // contains Products +} + +interface User { + id: string + subscriptionId: string // has Subscription +} +``` + +### Business Glossary (Recommended) + +Create a document (not code!) describing your application's business domain: + +```markdown +# Project Business Glossary + +## Order +- **Uniqueness:** order number (orderNumber) +- **States:** pending -> confirmed -> shipped -> delivered +- **Relationships:** belongs to User, contains Products +- **Rules:** can be cancelled only in pending/confirmed state + +## Product +- **Uniqueness:** SKU +- **Relationships:** belongs to Category, included in Orders +``` + +The glossary is a shared artifact — ideally maintained collaboratively by developers, product managers, and domain experts. The key principle is that code should reflect the domain language, not define it. + +**Purpose of the glossary:** +- Document domain understanding +- Synchronize understanding within the team +- Help make decisions about module naming +- It does NOT dictate code structure + +--- + +## Approach 0: Locality (Local First) + +### Main Principle + +> **Always start with local code. Extract to common layers only when reused.** + +This is **not a temporary solution** and **not technical debt**. This is the correct architecture for code used in one place — even if that code relates to a business entity like `User` or `Order`. + +### Structure + +``` +pages/ + user-profile/ + api/ + index.ts + profile.ts # API requests + DTO mapping + ui/ + index.ts + ProfilePage.tsx + ProfileForm.tsx +``` + +Avoid generic filenames like `types.ts` — they tend to become umbrella files that mix validation schemas, entity types, and other concerns. Name files after what they actually contain. + +### Example: Business Entity Kept Local + +```typescript title="pages/user-profile/api/profile.ts" +interface UserProfileDTO { + user_id: number + full_name: string + email: string + joined_days_ago: number + internal_flags: string[] // backend-specific, not needed in the UI +} + +export interface ProfileModel { + id: string + displayName: string // derived: formatted for display + email: string + isNewUser: boolean // derived: business rule applied at mapping time +} + +function mapProfile(dto: UserProfileDTO): ProfileModel { + return { + id: String(dto.user_id), + displayName: dto.full_name || 'Anonymous', + email: dto.email, + isNewUser: dto.joined_days_ago < 7, + } +} + +export async function getUserProfile(id: string): Promise { + const response = await fetch(`/api/users/${id}/profile`) + const dto: UserProfileDTO = await response.json() + return mapProfile(dto) +} +``` + +A separate domain type makes sense when the domain model genuinely differs from the DTO — derived fields, renamed properties, filtered-out backend internals. If your type would be a field-for-field copy of the DTO, skip the mapping and use the DTO type directly. Unnecessary mappers add friction: a backend change means updating the DTO, the domain type, and the mapper all at once for no benefit. + +```tsx title="pages/user-profile/ui/ProfilePage.tsx" +import { useState, useEffect } from 'react' +import { getUserProfile, type ProfileModel } from '../api' + +export function ProfilePage() { + const [profile, setProfile] = useState(null) + + useEffect(() => { + getUserProfile('123').then(setProfile) + }, []) + + return ( +
+

{profile?.displayName}

+ {profile?.isNewUser && Newbie} +

{profile?.email}

+
+ ) +} +``` + +**Why keep it local, even though User is a business entity?** + +- Used only here so far +- No shared business logic — just display +- Unknown what other fields will be needed elsewhere +- YAGNI — don't create structure "for the future" + +### Triggers to Move Code to `shared/api` or `entities/` + +**1. Used in a second place (main trigger)** + +``` +pages/user-profile/api/profile.ts // getUserProfile() +pages/settings/api/profile.ts // getUserProfile() — duplicate! +``` + +**2. Other developers start copying your code** + +If colleagues are copying your code — that's a signal to extract a shared module. + +**3. Business refers to it as a central concept** + +If the business starts treating an object as a core concept across multiple features — time to create an Entity. + +--- + +## Approach 1: Centralized API (`shared/api`) + +In this approach, API functions and domain types live in `shared/api/`, grouped by entity. This is a good fit when entities are in active flux or the project is small. + +For a detailed guide with code examples, see [API Requests](https://fsd.how/docs/guides/examples/api-requests). + +### When to Use + +- Teams starting to work with FSD +- Small projects (fewer than ~10 screens) +- Projects with frequently changing business logic +- When it's unclear which entities have stabilized +- When using API code generators like [orval](https://orval.dev/) or [openapi-typescript](https://openapi-ts.dev/) — generated code naturally fits in `shared/api/` as a single source of truth for transport types + +### Structure + +``` +shared/ + api/ + client.ts # HTTP client setup + contracts.ts # ApiResponse, PaginationParams + user.ts # API for User + DTO mapping + order.ts # API for Order + DTO mapping + index.ts # re-exports +``` + +The key difference from Approach 2: domain types (`User`, `Order`) live in `shared/api/` alongside the API functions, rather than in `entities/*/model/`. + +### Triggers for Migrating to `entities/` + +`shared/api` works well as long as it stays focused on transport concerns. Once domain logic starts leaking in — permissions, aggregations, repeated derivations — it becomes harder to maintain and the separation of concerns breaks down. These are the signals to move toward `entities/`: + +**Business logic starts accumulating** + +```typescript +// shared/api/user.ts is growing beyond pure API: +export function isAdmin(user: User): boolean { ... } +export function canEditPost(user: User, post: Post): boolean { ... } +export function getUserPermissions(user: User): string[] { ... } + +// Time for entities/user/model/ +``` + +**Aggregation across entities is needed** + +```typescript +// Fetching a user and enriching with related data +// belongs in entities/user/model/, not shared/api/ +export async function getUserWithOrders(userId: string) { + const user = await getUserById(userId) + const orders = await getOrdersByUserId(userId) + return { ...user, orders } +} +``` + +**The same logic is duplicated in many places** + +```typescript +// 10+ files all doing: +const isAdmin = user.role === 'admin' +// Time to centralize in entities/user/model/ +``` + +--- + +## Approach 2: Domain API (`entities/*/api/`) + +In this approach, each entity lives fully inside its own slice — including the API, DTO mapping, domain types, and business logic. + +### When to Use + +- Medium and large projects +- Teams experienced with FSD +- Projects where backend API stability matters +- Long-lived enterprise applications + +### Why the Mapping Layer Matters + +The mapper in `entities/*/api/` separates the backend's transport shape (DTO) from your domain model. This means your domain code — components, business logic, tests — works with a stable interface regardless of how the backend names its fields. + +However, mappers are not a shield against all backend changes. When a field is **renamed**, the fix is indeed limited to one mapper file. But when a field is **added or removed**, you still need to update the domain model and the places that use it. The mapper doesn't change that. + +The real value is clarity: the DTO represents what the backend sends, and the domain type represents what your application needs. Keeping them separate makes each concern explicit and easier to reason about independently. + +### Structure + +``` +shared/ + api/ + index.ts + client.ts # HTTP client only + contracts.ts # infrastructure types only + +entities/ + user/ + api/ + index.ts + user-api.ts # API functions + DTO mapping + model/ + index.ts + user.ts # User domain type + permissions.ts # business rules + index.ts + + order/ + api/ + index.ts + order-api.ts + model/ + index.ts + order.ts + validation.ts + index.ts +``` + +Domain types (`User`, `Order`) belong in `model/`, named after the entity — `user.ts`, `order.ts`. Not in the `api/` segment. + +### Code Example + +```typescript title="entities/user/model/user.ts" +export interface User { + id: string + email: string + role: 'admin' | 'moderator' | 'viewer' + createdAt: Date +} +``` + +```typescript title="entities/user/api/user-api.ts" +import { apiClient } from 'shared/api/client' +import type { User } from '../model/user' + +interface UserDTO { + user_id: number + user_email: string + user_role: string + created_at: string +} + +function mapUserFromDTO(dto: UserDTO): User { + return { + id: String(dto.user_id), + email: dto.user_email, + role: dto.user_role as User['role'], + createdAt: new Date(dto.created_at), + } +} + +export async function getUserById(id: string): Promise { + const { data } = await apiClient.get(`/users/${id}`) + return mapUserFromDTO(data) +} + +export async function getUsers(): Promise { + const { data } = await apiClient.get('/users') + return data.map(mapUserFromDTO) +} +``` + +```typescript title="entities/user/index.ts" +export { getUserById, getUsers } from './api' +export type { User } from './model' +``` + +### Evolution as the Project Grows + +Entities grow incrementally — don't build the full structure upfront. + +**Step 1: API + types only** +``` +entities/user/ + api/user-api.ts + model/user.ts + index.ts +``` + +**Step 2: Business logic appears** +``` +entities/user/ + api/user-api.ts + model/ + user.ts + permissions.ts # added + index.ts +``` + +**Step 3: Reusable UI needed** +``` +entities/user/ + api/user-api.ts + model/ + user.ts + permissions.ts + ui/ + UserAvatar.tsx # added + index.ts +``` + +**Step 4: Entity becomes complex — split by responsibility** + +For large or long-lived entities, consider splitting within the slice: + +``` +entities/user/ + api/ + queries/ # read operations + mutations/ # write operations + model/ + user.ts + permissions/ + profile/ + ui/ + UserAvatar.tsx + index.ts +``` + +Or, if contexts diverge significantly, split into separate slices: + +``` +entities/ + user-profile/ + user-permissions/ +``` + +--- + +## Comparison Table + +| Criteria | Locality | Centralized API | Domain API | +|----------|----------|-----------------|------------| +| When to use | One place | 2–5 places | 3+ places or clear domain | +| Startup speed | Fast | Medium | Slower | +| Scalability | Low | Medium | High | +| Protection from backend changes | None | Medium | High | +| Entry barrier | Low | Medium | Medium–high | +| Suitable for MVP | Yes | Yes | May be excessive | +| Suitable for enterprise | No | Requires refactoring | Yes | + +--- + +## When NOT to Create an Entity + +### Used in One Place Only + +```typescript title="pages/admin-dashboard/ui/DashboardPage.tsx" +// Keep this local — no need for entities/dashboard-stats/ +interface DashboardStats { + todayRevenue: number + activeUsers: number + conversionRate: number +} +``` + +### Utility Functions + +```typescript title="shared/lib/formatters.ts" +// These are utilities, not business logic +export const formatDate = (date: Date) => + new Intl.DateTimeFormat('en-US').format(date) +``` + +### Pure Data Loading Without Logic + +If fetching data is all that happens — no filtering, no aggregation, no business rules applied to the result — there's no reason to introduce `model/`. The key criterion is the absence of business logic, not the tool used to fetch. + +```typescript +// No business logic here — just loading and displaying. +// An API function is sufficient, model/ is not needed. +const { data: users } = useQuery({ + queryKey: ['users'], + queryFn: getUsers, +}) +``` + +Once you find yourself deriving, filtering, or combining that data based on domain rules, that's when model/ earns its place. + +### A Single Computed Value + +```typescript +// pages/profile/ui/ProfilePage.tsx +// One derivation doesn't justify creating entities/user/model/ +const isAdmin = user.role === 'admin' +``` + +### UI-Specific Models + +```typescript +// These are UI concerns, not business entities +interface FormState { + email: string + password: string + rememberMe: boolean +} +``` + +--- + +## When to CREATE `model/` in an Entity + +Create `entities/*/model/` — regardless of whether the API lives in `shared/api` or `entities/*/api/` — when any of the following appear: + +### 1. Data Aggregation + +When a meaningful model needs to be constructed from multiple data sources, that composition logic belongs in `model/`. This is not just fetching related data — it's creating a new unified model that the rest of the application can work with as a single concept. + +```typescript title="entities/user/model/user-with-team.ts" +import { getUserById } from '../api' +import { getTeamById } from 'entities/team/@x/user' + +export async function getUserWithTeam(userId: string) { + const user = await getUserById(userId) + const team = await getTeamById(user.teamId) + + return { + ...user, + team, + isTeamLead: team.leaderId === user.id, + } +} +``` + +### 2. Business Rules + +When domain objects have rules that govern what operations are allowed — based on their current state, relationships, or time constraints — those rules belong in `model/`. Keeping them centralized prevents the same checks from being scattered and duplicated across the codebase. + +```typescript title="entities/order/model/validation.ts" +import type { Order } from './order.ts' + +// Business rule from glossary: +// Orders can be cancelled only in pending or confirmed state +export function canBeCancelled(order: Order): boolean { + return order.status === 'pending' || order.status === 'confirmed' +} + +// Business rule: refunds allowed within 14 days of delivery +export function canBeRefunded(order: Order): boolean { + if (order.status !== 'delivered' || !order.deliveredAt) return false + const daysSinceDelivery = + (Date.now() - order.deliveredAt.getTime()) / (1000 * 60 * 60 * 24) + return daysSinceDelivery <= 14 +} +``` + +### 3. Multiple Interconnected Business Rules + +```typescript title="entities/user/model/permissions.ts" +import type { User } from './user.ts' + +export function getPermissions(user: User) { + const isAdmin = user.role === 'admin' + const isModerator = user.role === 'moderator' + + const canAccessAdminPanel = + isAdmin || (isModerator && user.yearsOfService > 2) + + const maxUploadBytes = + user.subscription === 'premium' ? 100_000_000 + : user.subscription === 'basic' ? 10_000_000 + : 1_000_000 + + return { + isAdmin, + isModerator, + canAccessAdminPanel, + canEditPosts: canAccessAdminPanel || user.permissions.includes('edit_posts'), + canDeletePosts: isAdmin || (isModerator && user.department === 'content'), + canUploadFile: (fileSize: number) => fileSize <= maxUploadBytes, + } +} +``` + +### 4. State Transition Rules + +```typescript title="entities/subscription/model/transitions.ts" +import type { Subscription } from './subscription.ts' + +const ALLOWED_TRANSITIONS: Record = { + trial: ['active', 'cancelled'], + active: ['past_due', 'cancelled'], + past_due: ['active', 'cancelled'], + cancelled: [], +} + +export function canTransitionTo( + subscription: Subscription, + nextStatus: Subscription['status'] +): boolean { + return ALLOWED_TRANSITIONS[subscription.status].includes(nextStatus) +} + +export function isInGracePeriod(subscription: Subscription): boolean { + if (subscription.status !== 'past_due') return false + const daysPastDue = + (Date.now() - subscription.dueDate.getTime()) / (1000 * 60 * 60 * 24) + return daysPastDue <= 7 +} +``` + +--- + +## Golden Rule + +``` +1. Study the business domain + -> Create a business glossary (a document, not code) + +2. Start with local code + -> pages/ or features/ + +3. On reuse + -> shared/api OR entities/*/api/ (team's choice) + +4. When business logic appears + -> entities/*/model/ (aggregation or business rules) +``` + +Key principles: +- Understand the business domain — maintain a glossary +- Start locally — it's not technical debt +- Extract pragmatically — when there's real necessity +- Use business terms in module names +- Don't create structure upfront — glossary != folder structure +- Keep business logic in pure functions — easier to test and reuse across frameworks + +--- + +## Practical Checklist + +**1. Is this a business term or a technical term?** + +If it's a technical term (Form, Modal, Config) — not an entity. + +**2. Is it used in one place?** + +Keep it local. If used in 2+ places, proceed to step 3. + +**3. Which API approach fits your project?** + +- Need backend isolation? -> Approach 2 +- Fast iteration more important? -> Approach 1 + +**4. Do you need `model/`?** + +- Data aggregation? -> Yes +- Business rules or state transitions? -> Yes +- Just types and CRUD? -> `api/` is sufficient + +**5. Did you document the decision?** + +- Updated business glossary? +- Noted why you created (or didn't create) the entity? diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/guides/examples/entity-layer.md b/i18n/ru/docusaurus-plugin-content-docs/current/guides/examples/entity-layer.md new file mode 100644 index 0000000000..fa9859cff2 --- /dev/null +++ b/i18n/ru/docusaurus-plugin-content-docs/current/guides/examples/entity-layer.md @@ -0,0 +1,697 @@ +--- +title: Когда создавать Entity +sidebar_position: 4 +--- + +# Когда создавать Entity + +Важный принцип эффективного использования слоя Entities: **не всё должно быть сущностью**. Не создавайте сущности "на всякий случай". + +Этот раздел поможет вам принять решение о том, где размещать код, связанный с бизнес-сущностями, учитывая контекст вашего проекта и команды. + +--- + +## Философия подхода + +FSD следует принципу **"Local First"** — начинайте с локального кода в `pages/`, и выносите в общие слои только при **реальной необходимости**. + +Понимание бизнес-домена помогает принимать более осознанные архитектурные решения. Однако понимание домена **не означает** немедленного создания структуры кода. + +Когда код начинает переиспользоваться, существует **три валидных подхода** к его организации: + +**Подход 0: Локальность** (рекомендуется всегда начинать отсюда) +- Код остаётся в `pages/` +- Используется только в одном месте + +**Подход 1: Централизованное API** (`shared/api`) +- API и типы в одном месте +- Миграция в `entities/` при усложнении + +**Подход 2: Доменное API** (`entities/*/api/`) +- Определяется пониманием бизнес-домена — если объект имеет уникальный бизнес-идентификатор и значимое поведение, он заслуживает собственного слайса +- Размещение API внутри слайса сущности — следствие этого решения, а не основание для него +- Полная инкапсуляция с первого дня + +Все три подхода согласуются с **философией FSD**: избегать преждевременной декомпозиции и добавлять слои по мере необходимости. + +--- + +## Признаки бизнес-сущности + +Прежде чем принимать решение о размещении кода, стоит понять — является ли объект бизнес-сущностью. Бизнес-сущности — это ключевые понятия, вокруг которых построен продукт. Они обладают идентичностью, поведением и смыслом для пользователей продукта, а не только для кода. Следующие признаки помогают их распознать, хотя ни один из них сам по себе не является достаточным. + +**1. Уникальная идентичность** + +Бизнес-сущность можно отличить от других экземпляров того же типа по уникальному атрибуту — значимому для бизнеса, а не просто строке в базе данных: + +```typescript +// Бизнес-уникальность — идентификатор имеет смысл в домене +Order { orderNumber: "ORD-2024-001" } // фигурирует в письмах, счетах, обращениях в поддержку +Product { sku: "LAPTOP-XPS-15" } // используется в каталогах, складах, заказах +Payment { type: "card", last4: "4242" } // значим для покупателя и бухгалтерии +``` + +Технический `id` сам по себе не делает объект сущностью. Рассмотрим `LogEntry`: + +```typescript +LogEntry { id: 789, message: "User logged in", timestamp: "..." } +``` + +У `LogEntry` есть уникальный `id`, но нет бизнес-смысла, жизненного цикла и связей, важных для домена. Это инфраструктура — держите локально или в `shared/`. + +Наличие уникального идентификатора — это **подсказка**, а не правило. + +**2. Бизнес-термин** + +Объект — это термин, который бизнес использует в разговоре о продукте. Хороший признак: если менеджер продукта, сотрудник поддержки или клиент произносят это слово в предложении — скорее всего, это бизнес-сущность. + +| Как говорит бизнес | Как выглядит в коде | +|--------------------|---------------------| +| "создать аккаунт пользователя" | `User` | +| "оформить заказ" | `Order` | +| "выставить счёт" | `Invoice` | +| "продлить подписку" | `Subscription` | + +Технические объекты, которые никогда не звучат в бизнес-разговорах — не сущности: + +```typescript +// НЕ сущности — это детали реализации +Form, Modal, Layout, Component, State, Config +``` + +**3. Поведение с состояниями** + +Объект имеет чётко выраженные состояния, между которыми может переходить: + +```typescript +Order { + status: 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled' +} + +Subscription { + status: 'trial' | 'active' | 'past_due' | 'cancelled' | 'expired' +} +``` + +**4. Связи с другими объектами** + +``` +Order -> belongs to -> User +Order -> contains -> Products +User -> has -> Subscription +``` + +В коде эти связи выражаются как ссылки между типами: + +```typescript +interface Order { + id: string + userId: string // belongs to User + productIds: string[] // contains Products +} + +interface User { + id: string + subscriptionId: string // has Subscription +} +``` + +### Бизнес-глоссарий (рекомендуется) + +Создайте документ (не код!), описывающий бизнес-домен вашего приложения: + +```markdown +# Бизнес-глоссарий проекта + +## Заказ (Order) +- **Уникальность:** номер заказа (orderNumber) +- **Состояния:** pending -> confirmed -> shipped -> delivered +- **Связи:** принадлежит User, содержит Products +- **Правила:** можно отменить только в состоянии pending/confirmed + +## Продукт (Product) +- **Уникальность:** артикул (SKU) +- **Связи:** принадлежит Category, входит в Orders +``` + +Глоссарий — общий артефакт, который в идеале поддерживается совместно: разработчиками, менеджерами продукта и экспертами в предметной области. Ключевой принцип: код должен отражать язык домена, а не определять его. + +**Цель глоссария:** +- Документировать понимание домена +- Синхронизировать понимание в команде +- Помочь принимать решения об именах модулей +- Глоссарий НЕ диктует структуру кода + +--- + +## Подход 0: Локальность (Local First) + +### Главный принцип + +> **Всегда начинайте с локального кода. Выносите в общие слои только при повторном использовании.** + +Это **не временное решение** и **не технический долг**. Это правильная архитектура для кода, который используется в одном месте — даже если этот код относится к бизнес-сущности вроде `User` или `Order`. + +### Структура + +``` +pages/ + user-profile/ + api/ + profile.ts # API-запросы + маппинг DTO + ui/ + ProfilePage.tsx + ProfileForm.tsx +``` + +Избегайте общих имён файлов вроде `types.ts` — они склонны превращаться в "зонтичные" файлы, которые смешивают схемы валидации, типы сущностей и другие вещи. Называйте файлы по тому, что они содержат. + +### Пример: бизнес-сущность остаётся локально + +```typescript title="pages/user-profile/api/profile.ts" +interface UserProfileDTO { + user_id: number + full_name: string + email: string + joined_days_ago: number + internal_flags: string[] // специфика бэкенда, в UI не нужна +} + +export interface ProfileModel { + id: string + displayName: string // производное: форматировано для отображения + email: string + isNewUser: boolean // производное: бизнес-правило применяется при маппинге +} + +function mapProfile(dto: UserProfileDTO): ProfileModel { + return { + id: String(dto.user_id), + displayName: dto.full_name || 'Аноним', + email: dto.email, + isNewUser: dto.joined_days_ago < 7, + } +} + +export async function getUserProfile(id: string): Promise { + const response = await fetch(`/api/users/${id}/profile`) + const dto: UserProfileDTO = await response.json() + return mapProfile(dto) +} +``` + +Отдельный доменный тип оправдан, когда доменная модель реально отличается от DTO — производные поля, переименованные свойства, отфильтрованные внутренние данные бэкенда. Если тип будет копировать DTO поле в поле — пропустите маппинг и используйте DTO напрямую. Лишние маппинги создают трение: изменение бэкенда потребует обновить DTO, доменный тип и маппер одновременно — без какой-либо пользы. + +```tsx title="pages/user-profile/ui/ProfilePage.tsx" +import { useState, useEffect } from 'react' +import { getUserProfile, type ProfileModel } from '../api' + +export function ProfilePage() { + const [profile, setProfile] = useState(null) + + useEffect(() => { + getUserProfile('123').then(setProfile) + }, []) + + return ( +
+

{profile?.displayName}

+ {profile?.isNewUser && Новичок} +

{profile?.email}

+
+ ) +} +``` + +**Почему локально, если User — это бизнес-сущность?** + +- Пока используется только здесь +- Нет общей бизнес-логики — только отображение +- Неизвестно, какие поля понадобятся в других местах +- YAGNI — не создаём структуру "на будущее" + +### Триггеры для переноса кода в `shared/api` или `entities/` + +**1. Второе использование (главный триггер)** + +``` +pages/user-profile/api/profile.ts // getUserProfile() +pages/settings/api/profile.ts // getUserProfile() — дубликат! +``` + +**2. Другие разработчики копируют ваш код** + +Если коллеги копируют ваш код — это сигнал к выделению общего модуля. + +**3. Бизнес ссылается на объект как на центральную концепцию** + +Если объект становится ключевым понятием в нескольких фичах — пора создавать Entity. + +--- + +## Подход 1: Централизованное API (`shared/api`) + +В этом подходе API-функции и доменные типы живут в `shared/api/`, сгруппированные по сущностям. Хорошо подходит, когда сущности ещё в процессе изменений или проект небольшой. + +Подробное руководство с примерами кода — в разделе [API Requests](https://fsd.how/docs/guides/examples/api-requests). + +### Когда использовать + +- Команды, начинающие работать с FSD +- Небольшие проекты (менее ~10 экранов) +- Проекты с часто меняющейся бизнес-логикой +- Когда неясно, какие сущности уже устоялись +- При использовании генераторов кода из OpenAPI-схемы ([orval](https://orval.dev/), [openapi-typescript](https://openapi-ts.dev/)) — сгенерированный код естественно живёт в `shared/api/` как единый источник транспортных типов + +### Структура + +``` +shared/ + api/ + client.ts # настройка HTTP-клиента + contracts.ts # ApiResponse, PaginationParams + user.ts # API для User + маппинг DTO + order.ts # API для Order + маппинг DTO + index.ts # re-exports +``` + +Ключевое отличие от Подхода Б: доменные типы (`User`, `Order`) живут в `shared/api/` рядом с API-функциями, а не в `entities/*/model/`. + +### Триггеры для миграции в `entities/` + +`shared/api` хорошо работает, пока остаётся сфокусированным на транспортных задачах. Как только туда начинает просачиваться доменная логика — права доступа, агрегации, повторяющиеся вычисления — поддерживать его становится сложнее, и разделение ответственности нарушается. Вот сигналы для перехода к `entities/`: + +**Начинает накапливаться бизнес-логика** + +```typescript +// shared/api/user.ts выходит за рамки чистого API: +export function isAdmin(user: User): boolean { ... } +export function canEditPost(user: User, post: Post): boolean { ... } +export function getUserPermissions(user: User): string[] { ... } + +// Пора в entities/user/model/ +``` + +**Нужна агрегация данных из нескольких сущностей** + +```typescript +// Получение пользователя с обогащением связанными данными +// должно жить в entities/user/model/, а не в shared/api/ +export async function getUserWithOrders(userId: string) { + const user = await getUserById(userId) + const orders = await getOrdersByUserId(userId) + return { ...user, orders } +} +``` + +**Одна и та же логика дублируется в разных местах** + +```typescript +// В 10+ файлах: +const isAdmin = user.role === 'admin' +// Пора централизовать в entities/user/model/ +``` + +--- + +## Подход 2: Доменное API (`entities/*/api/`) + +В этом подходе каждая сущность полностью живёт в своём слайсе — включая API, маппинг DTO, доменные типы и бизнес-логику. + +### Когда использовать + +- Средние и крупные проекты +- Команды с опытом работы с FSD +- Проекты, где важна защита от изменений backend API +- Долгоживущие enterprise-приложения + +### Почему важен слой маппинга + +Маппер в `entities/*/api/` разделяет транспортную форму данных бэкенда (DTO) и доменную модель. Это значит, что доменный код — компоненты, бизнес-логика, тесты — работает со стабильным интерфейсом независимо от того, как бэкенд называет свои поля. + +Однако маппер не защищает от любых изменений бэкенда. Если поле **переименовали** — правка действительно локализована в одном файле. Но если поле **добавили или удалили** — вам всё равно нужно обновить доменную модель и все места, где она используется. Маппер это не меняет. + +Реальная ценность в другом: DTO показывает, что присылает бэкенд, а доменный тип — что нужно вашему приложению. Разделение делает каждую из этих задач явной и позволяет рассуждать о них независимо. + +### Структура + +``` +shared/ + api/ + client.ts # только HTTP-клиент + contracts.ts # только инфраструктурные типы + +entities/ + user/ + api/ + user-api.ts # API-функции + маппинг DTO + model/ + user.ts # доменный тип User + permissions.ts # бизнес-правила + index.ts + + order/ + api/ + order-api.ts + model/ + order.ts + validation.ts + index.ts +``` + +Доменные типы (`User`, `Order`) принадлежат `model/`, называются по сущности — `user.ts`, `order.ts`. Не в сегменте `api/`. + +### Пример кода + +```typescript title="entities/user/model/user.ts" +export interface User { + id: string + email: string + role: 'admin' | 'moderator' | 'viewer' + createdAt: Date +} +``` + +```typescript title="entities/user/api/user-api.ts" +import { apiClient } from 'shared/api/client' +import type { User } from '../model/user' + +interface UserDTO { + user_id: number + user_email: string + user_role: string + created_at: string +} + +function mapUserFromDTO(dto: UserDTO): User { + return { + id: String(dto.user_id), + email: dto.user_email, + role: dto.user_role as User['role'], + createdAt: new Date(dto.created_at), + } +} + +export async function getUserById(id: string): Promise { + const { data } = await apiClient.get(`/users/${id}`) + return mapUserFromDTO(data) +} + +export async function getUsers(): Promise { + const { data } = await apiClient.get('/users') + return data.map(mapUserFromDTO) +} +``` + +```typescript title="entities/user/index.ts" +export { getUserById, getUsers } from './api/user-api' +export type { User } from './model/user' +``` + +### Эволюция при росте проекта + +Сущности растут постепенно — не стройте полную структуру заранее. + +**Шаг 1: только API + типы** +``` +entities/user/ + api/user-api.ts + model/user.ts + index.ts +``` + +**Шаг 2: появляется бизнес-логика** +``` +entities/user/ + api/user-api.ts + model/ + user.ts + permissions.ts # добавлено + index.ts +``` + +**Шаг 3: нужен переиспользуемый UI** +``` +entities/user/ + api/user-api.ts + model/ + user.ts + permissions.ts + ui/ + UserAvatar.tsx # добавлено + index.ts +``` + +**Шаг 4: сущность стала сложной — разбивка по ответственности** + +Для больших или долгоживущих сущностей стоит разбить слайс: + +``` +entities/user/ + api/ + queries/ # операции чтения + mutations/ # операции записи + model/ + user.ts + permissions/ + profile/ + ui/ + UserAvatar.tsx + index.ts +``` + +Или, если контексты сильно расходятся, разбить на отдельные слайсы: + +``` +entities/ + user-profile/ + user-permissions/ +``` + +--- + +## Сравнительная таблица + +| Критерий | Локальность | Централизованное API | Доменное API | +|----------|-------------|----------------------|--------------| +| Когда использовать | Одно место | 2–5 мест | 3+ мест или чёткий домен | +| Скорость старта | Высокая | Средняя | Ниже | +| Масштабируемость | Низкая | Средняя | Высокая | +| Защита от изменений backend | Нет | Средняя | Высокая | +| Порог входа | Низкий | Средний | Средне-высокий | +| Подходит для MVP | Да | Да | Может быть избыточно | +| Подходит для enterprise | Нет | Потребует рефакторинга | Да | + +--- + +## Когда НЕ создавать Entity + +### Используется только в одном месте + +```typescript title="pages/admin-dashboard/ui/DashboardPage.tsx" +// Держите локально — entities/dashboard-stats/ не нужна +interface DashboardStats { + todayRevenue: number + activeUsers: number + conversionRate: number +} +``` + +### Утилитарные функции + +```typescript title="shared/lib/formatters.ts" +// Это утилиты, не бизнес-логика +export const formatDate = (date: Date) => + new Intl.DateTimeFormat('ru-RU').format(date) +``` + +### Просто загрузка данных без логики + +Если всё что происходит — это загрузка и отображение данных, без фильтрации, агрегации и применения бизнес-правил к результату, вводить `model/` незачем. Ключевой критерий — отсутствие бизнес-логики, а не инструмент для запросов. + +```typescript +// Бизнес-логики нет — только загрузка и отображение. +// Достаточно API-функции, model/ не нужна. +const { data: users } = useQuery({ + queryKey: ['users'], + queryFn: getUsers, +}) +``` + +Как только вы начинаете фильтровать, комбинировать или преобразовывать эти данные по доменным правилам — вот тогда model/ оправдана. + +### Одиночное вычисляемое значение + +```typescript +// pages/profile/ui/ProfilePage.tsx +// Одно вычисление не оправдывает создание entities/user/model/ +const isAdmin = user.role === 'admin' +``` + +### UI-специфичные модели + +```typescript +// Это UI-логика, не бизнес-сущности +interface FormState { + email: string + password: string + rememberMe: boolean +} +``` + +--- + +## Когда СОЗДАВАТЬ `model/` в Entity + +Создавайте `entities/*/model/` — независимо от того, где лежит API (`shared/api` или `entities/*/api/`) — когда появляется любое из следующего: + +### 1. Агрегация данных + +Когда значимая модель должна быть собрана из нескольких источников данных, логика этой сборки принадлежит `model/`. Это не просто загрузка связанных данных — это создание единой модели, с которой остальная часть приложения работает как с одним понятием. + +```typescript title="entities/user/model/user-with-team.ts" +import { getUserById } from '../api/user-api' +import { getTeamById } from 'entities/team/api/team-api' + +export async function getUserWithTeam(userId: string) { + const user = await getUserById(userId) + const team = await getTeamById(user.teamId) + + return { + ...user, + team, + isTeamLead: team.leaderId === user.id, + } +} +``` + +### 2. Бизнес-правила + +Когда у доменных объектов есть правила, определяющие допустимые операции — на основе текущего состояния, связей или временных ограничений — эти правила принадлежат `model/`. Централизация предотвращает дублирование одних и тех же проверок по всей кодовой базе. + +```typescript title="entities/order/model/validation.ts" +import type { Order } from './types' + +// Бизнес-правило из глоссария: +// Заказ можно отменить только в состоянии pending или confirmed +export function canBeCancelled(order: Order): boolean { + return order.status === 'pending' || order.status === 'confirmed' +} + +// Бизнес-правило: возврат возможен в течение 14 дней после доставки +export function canBeRefunded(order: Order): boolean { + if (order.status !== 'delivered' || !order.deliveredAt) return false + const daysSinceDelivery = + (Date.now() - order.deliveredAt.getTime()) / (1000 * 60 * 60 * 24) + return daysSinceDelivery <= 14 +} +``` + +### 3. Множественные взаимосвязанные бизнес-правила + +```typescript title="entities/user/model/permissions.ts" +import type { User } from './types' + +export function getPermissions(user: User) { + const isAdmin = user.role === 'admin' + const isModerator = user.role === 'moderator' + + const canAccessAdminPanel = + isAdmin || (isModerator && user.yearsOfService > 2) + + const maxUploadBytes = + user.subscription === 'premium' ? 100_000_000 + : user.subscription === 'basic' ? 10_000_000 + : 1_000_000 + + return { + isAdmin, + isModerator, + canAccessAdminPanel, + canEditPosts: canAccessAdminPanel || user.permissions.includes('edit_posts'), + canDeletePosts: isAdmin || (isModerator && user.department === 'content'), + canUploadFile: (fileSize: number) => fileSize <= maxUploadBytes, + } +} +``` + +### 4. Правила переходов между состояниями + +```typescript title="entities/subscription/model/transitions.ts" +import type { Subscription } from './types' + +const ALLOWED_TRANSITIONS: Record = { + trial: ['active', 'cancelled'], + active: ['past_due', 'cancelled'], + past_due: ['active', 'cancelled'], + cancelled: [], +} + +export function canTransitionTo( + subscription: Subscription, + nextStatus: Subscription['status'] +): boolean { + return ALLOWED_TRANSITIONS[subscription.status].includes(nextStatus) +} + +export function isInGracePeriod(subscription: Subscription): boolean { + if (subscription.status !== 'past_due') return false + const daysPastDue = + (Date.now() - subscription.dueDate.getTime()) / (1000 * 60 * 60 * 24) + return daysPastDue <= 7 +} +``` + +--- + +## Золотое правило + +``` +1. Изучите бизнес-домен + -> Создайте бизнес-глоссарий (документ, не код) + +2. Начните с локального кода + -> pages/ или features/ + +3. При переиспользовании + -> shared/api ИЛИ entities/*/api/ (выбор команды) + +4. При появлении бизнес-логики + -> entities/*/model/ (агрегация или бизнес-правила) +``` + +Ключевые принципы: +- Понимайте бизнес — ведите глоссарий +- Начинайте локально — это не технический долг +- Выносите прагматично — только при реальной необходимости +- Используйте бизнес-термины в именах модулей +- Не создавайте структуру заранее — глоссарий не равно папки +- Держите бизнес-логику в чистых функциях — проще тестировать и переиспользовать + +--- + +## Практический чеклист + +**1. Это бизнес-термин или технический термин?** + +Если технический (Form, Modal, Config) — не сущность. + +**2. Используется в одном месте?** + +Держите локально. Если в 2+ местах — переходите к п.3. + +**3. Какой подход API подходит проекту?** + +- Важна защита от изменений backend? -> Подход 2 +- Важна скорость итераций? -> Подход 1 + +**4. Нужен ли `model/`?** + +- Агрегация данных? -> Да +- Бизнес-правила или переходы состояний? -> Да +- Только типы и CRUD? -> достаточно `api/` + +**5. Задокументировали решение?** + +- Обновили бизнес-глоссарий? +- Отметили, почему создали (или не создали) сущность?