Files
main_frontend/.agents/skills/feature-sliced-design/references/cross-import-patterns.md
T
Daniil 21e936a827
dev / deploy (push) Successful in 2m15s
compute / deploy (push) Has been cancelled
chore: agentic upgrade
2026-05-17 02:11:33 +03:00

375 lines
12 KiB
Markdown

# 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.