Combining Multiple Entities in FSD Widgets — Composite Queries and Cross-Slice Dependency Design (Feature-Sliced Design)
When you first introduce FSD (Feature-Sliced Design), there's a wall you hit almost immediately. You've set up the layer structure, created a User entity and an Order entity inside entities — and then you freeze at the question: "Where do I put a dashboard widget that displays both at the same time?" I've been there. I tried composing them directly inside the entities layer, ran into a cross-slice violation warning, and only then realized: "Oh, this belongs in a different layer."
This article is written for developers who have already adopted FSD or are considering it. It assumes a working familiarity with the basic concepts — layers, slices, and segments — so if you're brand new, start with the Feature-Sliced Design official documentation to build the right mental model first.
By the end of this article, you'll be able to identify cross-slice violations in an existing codebase and follow along at the code level to see how to move that logic into the widgets layer. We'll work through a single User-Order-Product domain example covering: how to compose parallel queries with TanStack Query's useQueries, how to manage inter-entity type references explicitly with @x notation, and how to decide which layer a shared component belongs in.
Core Concepts
FSD Layer Hierarchy and Unidirectional Dependencies
FSD divides code into 6 layers and allows imports only from top to bottom.
app → pages → widgets → features → entities → sharedThis rule keeps the dependency graph in DAG (Directed Acyclic Graph) form. When you modify any slice, the impact propagates only upward — the layers below never need to be touched.
The catch is the rule that says "slices within the same layer cannot reference each other." In real-world domains, this bites you when Order needs to know about the Product type, or when User and Order need to appear together on one screen.
The cross-slice problem: A situation where slice A in a given layer needs to reference slice B in the same layer. A naive approach violates FSD rules.
The Role of the Widgets Layer
In the past, widgets were often understood narrowly as "the layer that just composes components." Recent FSD guidance has shifted that perspective. Widgets are now designed to be autonomous units that directly own their own stores, API calls, and business logic.
widgets/
user-order-dashboard/
api/ ← composite query hooks
model/ ← widget-scoped store/types
ui/ ← components
index.ts ← public APIBecause widgets can reference all of shared → entities → features, they are the highest-level reusable unit — which makes them the natural home for logic that wires multiple entities together.
@x Notation — Making Inter-Entity Type References Explicit
When type-level references are needed within the entity layer, the approach recommended by the FSD guide is @x notation. It surfaces the fact that "A cross-references B" directly in the file path.
entities/product/@x/order.tsThis file acts as a dedicated public API that the product slice exposes exclusively to the order slice. Because it's separate from the regular index.ts, you can tell at a glance from the file tree which slices are connected to which. Steiger (the FSD-specific architecture linter, discussed below) understands this pattern: it warns on cross-imports through index.ts but permits references through @x paths.
The
@xnotation is intended exclusively for the entities layer. Cross-imports in features or widgets are generally a signal of a design error.
So what does this actually look like in code? Let's work through a progressively more complex set of scenarios using a single service — User, Order, and Product — as our example.
Practical Application
Example 1: A Composite Query Fetching User and Order Simultaneously from a Widget
This is the most common scenario. A single dashboard widget needs to display user information alongside that user's order list at the same time.
Folder structure
src/
entities/
user/
api/
userQueries.ts
index.ts
order/
api/
orderQueries.ts
index.ts
widgets/
user-order-dashboard/
api/
useUserOrderDashboard.ts
ui/
UserOrderDashboard.tsx
index.ts ← widget's public APIentities layer: owns only individual query factories
// entities/user/api/userQueries.ts
import { queryOptions } from '@tanstack/react-query';
import { fetchUser } from './userApi';
export const userQueries = {
detail: (userId: string) =>
queryOptions({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
}),
};// entities/order/api/orderQueries.ts
import { queryOptions } from '@tanstack/react-query';
import { fetchOrdersByUser } from './orderApi';
export const orderQueries = {
byUser: (userId: string) =>
queryOptions({
queryKey: ['orders', 'byUser', userId],
queryFn: () => fetchOrdersByUser(userId),
}),
};widgets layer: composition logic lives here
The reason to use useQueries instead of calling useQuery twice is that it runs both requests in parallel while managing loading and error states together as an array. This is especially useful for UX: if only one query fails, the other data can still be partially rendered.
// widgets/user-order-dashboard/api/useUserOrderDashboard.ts
import { useQueries } from '@tanstack/react-query';
import { userQueries } from '@/entities/user';
import { orderQueries } from '@/entities/order';
export function useUserOrderDashboard(userId: string) {
const [userResult, ordersResult] = useQueries({
queries: [
userQueries.detail(userId),
orderQueries.byUser(userId),
],
});
return {
user: userResult.data,
orders: ordersResult.data ?? [],
isLoading: userResult.isLoading || ordersResult.isLoading,
// isError is true if either query fails
// for partial failure handling, check userResult.isError / ordersResult.isError individually
isError: userResult.isError || ordersResult.isError,
};
}// widgets/user-order-dashboard/ui/UserOrderDashboard.tsx
import { useUserOrderDashboard } from '../api/useUserOrderDashboard';
import { Skeleton } from '@/shared/ui'; // domain-agnostic generic loading UI
import { UserProfile } from '@/entities/user/ui'; // UI specific to the user slice
import { OrderList } from '@/entities/order/ui'; // UI specific to the order slice
interface Props {
userId: string;
}
export function UserOrderDashboard({ userId }: Props) {
const { user, orders, isLoading } = useUserOrderDashboard(userId);
if (isLoading) return <Skeleton />;
return (
<section>
<UserProfile user={user} />
<OrderList orders={orders} />
</section>
);
}// widgets/user-order-dashboard/index.ts
// External consumers access the widget only through this path
export { UserOrderDashboard } from './ui/UserOrderDashboard';| Role | Location | Reason |
|---|---|---|
Individual query factories (queryOptions) |
entities/<slice>/api/ |
Single domain responsibility |
Composite query hook (useQueries) |
widgets/<name>/api/ |
Combining multiple entities is an upper-layer concern |
Generic loading UI (Skeleton) |
shared/ui/ |
Domain-agnostic generic component |
Domain UI (UserProfile, OrderList) |
entities/<slice>/ui/ |
UI coupled to a single entity |
| Composite rendering component | widgets/<name>/ui/ |
Unit that renders combined data from multiple entities |
Example 2: Order Referencing the Product Type via @x Notation
Let's grow the same service a bit further. Now Order needs to include a list of ordered products. The Order model needs to carry ProductSummary as a field type — but a naive approach would be an intra-layer cross-import, violating the rules. The @x notation solves this.
Folder structure
src/entities/
product/
@x/
order.ts ← dedicated public API for the order slice
model/
types.ts
index.ts
order/
model/
types.ts ← imports only from product/@x/order
index.tsDefining the dedicated public API for product
// entities/product/model/types.ts
export interface Product {
id: string;
name: string;
price: number;
stock: number;
}
// Define only the minimal types to expose to the order slice separately
export interface ProductSummary {
id: string;
name: string;
price: number;
}// entities/product/@x/order.ts
// Exports permitted exclusively for the order slice
export type { ProductSummary } from '../model/types';Referencing from order
// entities/order/model/types.ts
import type { ProductSummary } from '@/entities/product/@x/order';
export interface Order {
id: string;
userId: string;
products: ProductSummary[]; // type imported via product/@x/order
totalAmount: number;
createdAt: string;
}The OrderList component from Example 1 then receives this Order type as props and renders the products array. At the widget level, the User data and the Order+Product structure connect naturally.
The key to this pattern is importing exclusively from product/@x/order.ts, never from product/index.ts. Steiger flags regular cross-imports through index.ts as violations while allowing references through @x paths.
Example 3: Deciding Where to Place a Cross-Slice Shared Component
Honestly, this tripped me up every time at first. When a component like UserOrderCard mixes two domains, the line between entities, widgets, and shared felt blurry. The table below sets out the criteria.
| Situation | Placement | Example |
|---|---|---|
| UI coupled to a specific entity | entities/<slice>/ui/ |
UserProfile, ProductBadge |
| UI that combines multiple entities | widgets/<name>/ui/ |
UserOrderDashboard |
| Generic UI unrelated to any domain | shared/ui/ |
Button, Modal, Skeleton |
| Sub-components used only within a specific widget | inside widgets/<name>/ui/ |
OrderListItem (dashboard-only) |
"When the scope of sharing is ambiguous, it's recommended to start at a higher layer." It's much safer to begin in widgets and move something down to shared only once reuse is confirmed.
Since our team adopted these criteria, the question "Why is this component in entities?" has become noticeably rare in PR reviews. When placement rules are written down, reviewers and authors can speak the same language.
Pros and Cons
Advantages
| Item | Detail |
|---|---|
| Clear dependency direction | Unidirectional import rules eliminate spaghetti architecture at the source |
| High cohesion | A widget owns its UI, logic, and API in one folder, reducing navigation cost |
| Incremental adoption | Start pages-first and add layers only as needed |
| Automated enforcement | Steiger catches rule violations early in CI |
| Clear test boundaries | Slice isolation makes unit test scoping intuitive |
Drawbacks and Caveats
There are pitfalls that come up frequently when first adopting FSD.
Overuse of @x is the first. If you reach for cross-imports out of convenience, dependencies grow complex again. The rule is to use @x only in the entities layer, and only when there's no alternative. Adding "@x additions require a stated rationale" to your PR review checklist keeps this manageable naturally.
Bloat in shared is also common. Dumping code of uncertain reusability into shared turns it into a garbage-dump layer. Start with code inside the widget, and only move it to shared once actual reuse is confirmed — that's far safer.
Ambiguity about where a composite query belongs also comes up. When it's hard to tell whether a query belongs in a widget or a feature, ask two questions: Is it reused elsewhere? Does it stand alone semantically (does it have meaning on its own)? Those criteria resolve most cases.
The learning curve is honestly non-trivial. For consistency to hold, the whole team needs to internalize the three-tier structure of layers, slices, and segments plus the import rules. The realistic approach is to enforce them automatically with Steiger + ESLint rules, combined with internal team documentation.
Over-engineering on small projects is also real. Forcing the full layer structure onto a project where pages + shared would suffice just adds complexity. Adopt incrementally, calibrated to your team size and domain complexity.
Steiger: An FSD-specific architecture linter. It automatically detects layer import violations, missing public APIs,
@xnotation misuse, and more — in both the CLI and the IDE. Try it immediately withnpx steiger ./src.
The Most Common Mistakes in Practice
-
Writing composite queries directly inside entities — Importing
orderQueriesinsideentities/user/api/is a cross-slice violation. Composition must always happen in an upper layer (widgets or pages). -
Referencing the same entity layer directly without
@x— Writingimport { Product } from '@/entities/product'insideentities/order/model/types.tsis a rule violation. References must go through an@xpath. -
Placing domain-coupled components in
shared/ui/— When a component with domain meaning likeUserOrderCardends up in shared, shared ends up referencing entities — reversing the layer direction.
Closing Thoughts
After introducing this structure to our team, PR reviews changed in a concrete way. The positional debates — "Why is this logic in entities?", "Where is this component imported from?" — nearly disappeared. When dependency direction is clear, there's a shared basis for discussion, and that shared basis brings consistency across the entire codebase.
Three steps you can start right now:
-
Add Steiger to your project — Install it with
pnpm add -D steiger, then runnpx steiger ./srcto immediately see how many FSD rule violations your current codebase has. -
Audit where your existing cross-entity logic lives — If there are queries or components in entities or features that wire multiple domains together, start by moving that code into the widgets layer.
-
Create
@xfolders wherever inter-entity type references exist — Createentities/<slice>/@x/<other-slice>.tsfiles that re-export only the types to expose, and update the referencing side to import exclusively from those paths. The dependency map will start to become clear at a glance.
References
- Feature-Sliced Design official docs - Layers
- Feature-Sliced Design official docs - Cross-imports guide
- Feature-Sliced Design official docs - Public API
- FSD with React Query official guide
- FSD v2.0 → v2.1 migration guide
- Steiger - FSD Architecture Linter | GitHub
- Feature-Sliced Design 2.1 — Changing How We Organize Frontend | Medium
- Mastering FSD: Lessons from Real Projects | DEV Community
- FSD with TanStack Query - Slicing Data | DEV Community