Feature-Sliced Design in Practice
Most articles about Feature-Sliced Design show you the theory. The layer diagram. The import direction rules. The terminology (slices, segments, public API). Then they show you a contrived example with three components and call it a day.
That's not what this is.
I've applied FSD across three different products at genuinely different scales and domains: TrustRail (a B2B BNPL platform), Medcord (a hospital management system), and WordShot (a real-time multiplayer word game). All three follow the same structural rules. All three look different. The interesting part is understanding why — and where the pattern holds, where it bends, and when you shouldn't use it at all.
Let's get cracking,
The Problem It Solves
Here's the folder structure I used to fall into on every project:
src/
├── components/ (47 files)
├── hooks/ (23 files)
├── utils/ (31 files)
├── types/ (18 files)
└── pages/ (12 files)
This feels tidy at the start. It's a disaster at scale.
Want to understand how multiplayer works in WordShot? You grep /components for multiplayer-related components, /hooks for multiplayer state, /utils for multiplayer helpers, and /types for the WebSocket event shapes. Those four folders tell you what type of thing each file is — they tell you nothing about which feature it belongs to.
Want to add a new feature? You touch files in four folders simultaneously, creating coupling between unrelated features at the filesystem level.
Want to remove the demo mode? You grep for demo across all four folders, hope you found everything, delete what you found, run the build, fix the broken imports you missed, repeat.
The folder-by-type structure is fine for small projects. The moment a project has more than about four independent features, it actively works against you.
The Core Rule
Feature-Sliced Design's core rule can be stated in one sentence:
A feature owns everything it needs to function. Code that's needed by two or more features lives in shared/. Code that's needed by one feature lives inside that feature.Corollaries:
- A feature never imports from another feature
- A feature can import from
shared/ shared/never imports from any feature- Code starts in the feature. It gets promoted to
shared/when the second consumer arrives — not before
That's it. Everything else — the naming conventions, the sub-folder structure, the decision about whether to use sub-features — is derived from this rule applied to your specific domain.
TrustRail: FSD at the Small End
TrustRail's frontend is a React application with five features. It's the simplest case — a clean, unambiguous application of the pattern.
src/
├── features/
│ ├── auth/
│ │ ├── api/
│ │ │ ├── use-login.ts
│ │ │ └── use-register.ts
│ │ ├── guards/
│ │ │ ├── auth-guard.tsx
│ │ │ └── guest-guard.tsx
│ │ ├── helpers/
│ │ │ ├── nigerian-banks.ts
│ │ │ ├── token-storage.ts
│ │ │ └── validation.ts
│ │ ├── providers/
│ │ │ └── auth-provider.tsx
│ │ ├── screen/
│ │ │ ├── login-screen.tsx
│ │ │ ├── register-screen.tsx
│ │ │ └── parts/
│ │ │ ├── business-details-step.tsx
│ │ │ └── document-upload-step.tsx
│ │ └── auth.routes.tsx
│ │
│ ├── dashboard/ (overview metrics, application summary)
│ ├── applications/ (list, details, approve/decline)
│ ├── trust-wallets/ (BNPL product configuration)
│ └── public/ (the applicant-facing form)
│
└── shared/
├── constants/
│ ├── api.ts
│ └── routes/routes.ts
├── helpers/
│ └── api-client.ts
└── types/
├── auth.ts
└── index.ts
Each feature contains its complete stack: API hooks (use-login.ts), screens (login-screen.tsx), component parts (business-details-step.tsx), helpers (nigerian-banks.ts), providers (auth-provider.tsx), and routes (auth.routes.tsx). Everything the auth feature needs to function is inside the auth folder.
shared/ is minimal and intentional. It contains three things: API constants, the API client instance (used by every feature's API hooks), and base types. Nothing that belongs to a single feature.
Notice what's not in shared/: no UI components, no business logic, no screens, no feature-specific helpers. The Nigerian banks list (nigerian-banks.ts) is in auth/helpers/ because only the registration form uses it. If another feature ever needed to display a bank selector, that file would move to shared/helpers/. It hasn't moved yet. It won't move until that second consumer exists.
The Naming Convention
Every file follows a consistent naming pattern across the entire project:
- API hooks:
use-{resource}.ts—use-login.ts,use-applications-list.ts,use-trust-wallet-details.ts - Screens:
{resource}-screen.tsx—login-screen.tsx,application-details-screen.tsx - Screen parts:
{part}-{type}.tsx—business-details-step.tsx,action-modal.tsx - Providers:
{name}-provider.tsx—auth-provider.tsx - Guards:
{condition}-guard.tsx—auth-guard.tsx,guest-guard.tsx - Routes:
{feature}.routes.tsx—auth.routes.tsx,applications.routes.tsx
This convention means a new developer navigating the codebase can predict file locations before finding them. "Where is the hook for fetching application details?" — features/applications/api/use-application-details.ts. No search required.
Medcord: FSD at the Large End
Medcord is a hospital management system. It handles patient registration, EMR records, lab orders, staff management, asset tracking, and workspace administration. It's substantially more complex than TrustRail — the kind of project where a poor folder structure becomes actively painful.
The top-level feature count is nine. But the more important distinction from TrustRail is that most features contain sub-features — a second level of the same pattern applied recursively.
src/
├── features/
│ ├── auth/
│ │ ├── api/
│ │ ├── features/
│ │ │ ├── forgot-password/
│ │ │ ├── login/
│ │ │ │ ├── parts/
│ │ │ │ │ ├── login-form.tsx
│ │ │ │ │ └── two-fa-step.tsx
│ │ │ │ └── screen/
│ │ │ │ └── login-screen.tsx
│ │ │ ├── register/
│ │ │ ├── reset-password/
│ │ │ └── setup-2fa/
│ │ └── shared/
│ │ └── parts/
│ │ └── auth-layout.tsx
│ │
│ ├── patients/
│ │ ├── features/
│ │ │ ├── patient-list/
│ │ │ ├── patient-profile/
│ │ │ ├── patient-register/
│ │ │ ├── patient-admitted/
│ │ │ ├── patient-checkedin/
│ │ │ └── patient-transfers/
│ │ └── shared/
│ │ └── types/
│ │ └── patient.ts
│ │
│ ├── emr/
│ │ ├── features/
│ │ │ ├── chart-overview/
│ │ │ ├── vitals/
│ │ │ ├── medications/
│ │ │ ├── procedures/
│ │ │ ├── history/
│ │ │ ├── immunizations/
│ │ │ ├── documents/
│ │ │ └── access-log/
│ │ └── shared/
│ │ ├── chart-layout.tsx
│ │ └── types/emr.ts
│ │
│ ├── labs/
│ ├── staff/
│ ├── assets/
│ ├── workspace/
│ ├── notifications/
│ └── queue/
│
└── shared/
├── api/
├── components/
├── guards/
├── hooks/
├── providers/
├── types/
└── widgets/
└── app-shell/
When to Use Sub-Features
The decision to make patients a domain with sub-features rather than a single flat feature comes down to one question: do these sub-capabilities have meaningfully independent lifecycles?
A patient list screen and a patient profile screen do not share state. A patient registration flow and a patient transfer flow have entirely different API hooks, different screen components, and different form logic. They happen to be about the same domain entity (the patient), but they're developed, tested, and modified independently.
If they lived in a flat patients/ folder, you'd have a folder with 30+ files that are only loosely related. Sub-features give each capability its own clean boundary. patient-register/ contains exactly what you need to understand the registration flow. patient-transfers/ contains exactly what you need to understand the transfer flow. Neither reaches into the other.
The rule I follow: introduce a sub-feature level when a top-level feature would otherwise contain more than about 15–20 files, or when distinct capabilities within that feature need to be understood independently.
Feature-Level shared/
Each top-level domain has its own shared/ folder for code shared across its sub-features but not needed by other top-level features:
emr/shared/chart-layout.tsx— the layout wrapper used by every EMR sub-screen (vitals, medications, procedures all share the same chrome)emr/shared/types/emr.ts— the TypeScript types for EMR entitiespatients/shared/types/patient.ts— the patient type shared across all patient sub-features
This is the same promotion rule applied one level down: code starts in the sub-feature, moves to the domain-level shared/ when the second sub-feature needs it, moves to the top-level shared/ when two top-level features need it.
The App-Level shared/
Medcord's top-level shared/ is more substantial than TrustRail's because the application is larger:
shared/
├── api/
│ └── use-hospital-by-slug.ts (used by auth + workspace + all features)
├── components/
│ └── entity-link.tsx (universal cross-entity navigation)
├── guards/
│ ├── auth-guard.tsx
│ └── hospital-guard.tsx
├── hooks/
│ ├── use-auth.ts
│ ├── use-hospital-slug.ts
│ └── use-permissions.ts
├── providers/
│ ├── auth-provider.tsx
│ └── user-bootstrap.tsx
├── types/ (shared domain types: Hospital, Patient, Staff...)
└── widgets/
└── app-shell/ (sidebar, topbar, user menu — used by every feature)
use-permissions.ts is in shared/hooks/ because RBAC permission checking is used across 6 different features (staff management, patient registration, EMR write access, lab order creation, asset management, workspace settings). Moving it to shared/ the first time two features needed it was the right call — it has never needed to move again.
WordShot: The Decision That Proved the Pattern
WordShot's FSD structure was discussed in the previous series of posts on this blog, but it's worth revisiting through the lens of a specific decision: demo mode.
Demo mode is an interactive walkthrough for first-time users. It shows the game flow step-by-step without making any API calls, without joining a real game room, without touching the WebSocket. It's entirely self-contained.
Before FSD, building demo mode in the old folder-by-type structure would have required:
- Adding demo-specific state to the existing Redux store (or creating a parallel store)
- Adding conditional rendering to game components to handle "am I in demo mode?" checks
- Threading a
isDemoModeprop through the component tree - Hoping that game logic changes don't break the demo flow
With FSD, demo mode was features/demo/. It contained its own state machine, its own screen components, its own mock data, its own routing. It shared only the base UI components from shared/ui/ that it visually needed. The game and multiplayer features were not touched.
The demo took two days to build because the structure made it easy. There was no untangling to do. No "wait, this component is used by both the real game and the demo, how do I make them diverge?" There was no entanglement in the first place.
When the decision to remove demo mode later came up, it was a folder deletion and three route changes. That's the proof.
The shared/ Discipline
The most common way FSD falls apart in practice is shared/ becoming a dumping ground.
I've seen codebases that claim to use FSD where shared/ contains:
- Feature-specific components that "might be reused someday"
- Utility functions used by exactly one feature
- A
misc/folder - Business logic that belonged in a feature service
When shared/ is polluted, the cross-feature import rule becomes unenforceable because everything is already in shared/. Features stop having clear boundaries. The original problem returns in a different location.
The discipline is: shared/ must earn every file it contains. A file enters shared/ when a second consumer appears. Not when you think it might be reused. Not because it "looks generic." When the second consumer actually arrives and needs it.
In practice, I've found that healthy shared/ layers contain:
- The API client instance (one, used everywhere)
- Auth primitives (the session hook, the auth provider, route guards)
- The app shell / layout chrome
- Base TypeScript types for domain entities that genuinely cross feature boundaries
- A small set of UI utilities (token-safe wrappers, error display primitives)
Healthy shared/ layers do not contain:
- Form components (forms are feature-specific in almost every real codebase)
- Anything with "business logic" in the filename or in the code
- More than about 30–40 files total in a mid-sized application
Where FSD Bends
Three real failure modes I've encountered:
The cross-feature import temptation. You're building applications in TrustRail and you need the list of trust wallets to render a dropdown. trust-wallets has a use-trust-wallets.ts hook that fetches exactly the data you need. The temptation is to import it directly. Don't.
The correct move: expose the data through a shared type and a prop or context, not through a direct import. If applications needs to know which trust wallet an application belongs to, that relationship lives in the application data itself, not in a cross-feature import.
If two features genuinely need to share data access at a deep level, that's a signal that the feature boundary was drawn incorrectly. Merge them, or extract the shared data concern to shared/.
The premature sub-feature. Adding sub-feature levels when a flat feature would work fine adds cognitive overhead without benefit. auth/features/login/screen/login-screen.tsx has one more path component than auth/screen/login-screen.tsx. If the auth feature has three flows (login, register, forgot password) and each is less than 10 files, a flat structure is cleaner.
The sub-feature level is worth introducing when the sub-capability count makes the flat structure unwieldy — Medcord's patients/ with six sub-features is clearer with the sub-level than without it.
The naming inconsistency. FSD's value compounds when naming is consistent. When half the features use use-{resource}.ts for hooks and the other half use {resource}Hook.ts, the predictability breaks. When some screens are {feature}-screen.tsx and others are {Feature}Page.tsx, grep becomes your primary navigation tool again.
Establish the convention once, at project start, in a brief README. Enforce it in code review. It takes a day to establish and saves weeks over the lifetime of the project.
When Not to Use FSD
Feature-Sliced Design has overhead. For the right project, the overhead pays for itself quickly. For the wrong project, it's bureaucratic noise.
Don't use FSD when: the project has fewer than 3–4 independent features. A landing page site with one interactive form. A prototype with one screen. A personal tool with one workflow. Flat folder structures are faster to navigate and faster to change for small projects.
Do use FSD when: the project is expected to grow, different parts of it are owned or will be owned by different people, features need to be added and removed without cross-feature contamination, or you've already experienced the folder-by-type pain and want to not experience it again.
The most predictable sign that a project needs FSD: you've opened the wrong file three times in the last day because two features have a component with the same name in the same components/ folder.
Evaluation
Across TrustRail, Medcord, and WordShot, FSD produced several consistent properties:
Feature isolation — Changes to one feature never broke another. In Medcord, shipping a major rework of the patient transfer flow required touching exactly five files, all inside patients/features/patient-transfers/. No other feature was touched.
Predictable navigation — A developer new to TrustRail could find any file in the codebase by following the naming convention without ever searching. Feature → sub-folder → file. The structure teaches itself.
Clean deletions — WordShot's demo mode removal was a folder deletion. In Medcord, the queue/ feature (a work-in-progress) could be removed at any point without touching anything else. Feature-scoped code deletes cleanly.
Proportional complexity — TrustRail's five-feature flat structure and Medcord's nine-domain two-level structure are both instances of the same pattern, at different scales. The pattern doesn't impose Medcord's complexity on TrustRail or TrustRail's simplicity on Medcord.
The Honest Conclusion
Feature-Sliced Design is not a silver bullet. It won't fix bad abstractions, poor naming, or unclear feature boundaries. What it does is give you a consistent, enforceable rule for where code lives — one that scales with project growth instead of collapsing under it.
The folder-by-type structure tells you what your code is. FSD tells you what it does and who owns it. At small scale, the distinction doesn't matter much. At medium and large scale, it's the difference between a codebase you can navigate and one you can only survive.
The structure is not the architecture. But the right structure makes the architecture legible.