After Introducing FSD (Feature-Sliced Design) to Our Team, "Where Does This Code Go?" Disappeared
If you've done frontend development long enough, you've probably been there. During a code review, you ask "Why is this component here?" and the answer comes back: "I just couldn't find anywhere else to put it." I've been there too. The components folder ballooned out of control, all kinds of miscellaneous code piled up inside utils, and every time a new team member joined, they'd ask "Where do I create this?" — over and over again. That's around the time we introduced Feature-Sliced Design (FSD) to the team.
This post doesn't stop at explaining FSD concepts. It's an honest account of what was confusing, what worked well, and what pitfalls we hit when we actually adopted it on a real team. Whether you've never heard of FSD, are considering adopting it, or are already using it — this post alone will give you the evidence you need to propose FSD to your team. Code examples are written in TypeScript.
Core Concepts
Layers, Slices, and Segments — A Three-Level Structure
When I first encountered FSD, I was surprised at how clear the official documentation was. The concept is simple: code is divided into three levels in order — Layer → Slice → Segment.
Layers are fixed at six levels under the current recommended structure. (The processes layer from earlier specs has been deprecated and is effectively unused in FSD v2.)
app → pages → widgets → features → entities → sharedDirection matters. Higher layers can reference lower layers, but the reverse is not allowed. The moment entities imports from features, that's a rule violation. It's no exaggeration to say this one-way dependency is the heart of FSD.
Slices are units within each layer divided by business domain. Inside the features layer, folders appear organized by feature — like auth, payment, and notification. Segments classify the interior of a slice by technical role; the standard composition is ui, model, api, lib, and config.
One useful thing to know: unlike other layers, the shared layer is the only layer that has no slices — only segments. Creating slices like shared/auth or shared/user doesn't conform to FSD rules. I was confused about this at first too, and created a shared/user folder that I had to undo later.
src/
├── app/ # App initialization, routing, global configuration
├── pages/ # Page components per route
│ └── auth/
│ └── ui/
├── widgets/ # Composite UI blocks that work independently
├── features/ # User interaction unit features
│ └── login/
│ ├── ui/ # Components
│ ├── model/ # State, store, business logic
│ └── api/ # API calls
├── entities/ # Business entities (User, Product, etc.)
└── shared/ # Common utilities, UI components, constants (no slices)
├── ui/
├── lib/
└── config/What is a Public API? It's the
index.tsfile located at the root of each slice. External code can only access the interior of a slice through this file; importing directly by path is considered a rule violation.
FSD 2.1's "Pages First" — A Win for Pragmatism
The FSD 2.1 update from 2024 was personally welcome. Previously, I had to make judgment calls like "this might be reused someday, so let's put it in features for now" — and that turned out to be more exhausting than expected. Version 2.1 is clear: code that isn't reused doesn't need to be promoted to a higher layer. Place UI and logic that belongs to a page inside the pages slice first, then refactor when actual reuse occurs.
This change sounds small, but it makes a significant difference in practice. It means you no longer have to predict "will this be reused later?" for every decision.
Practical Application
Now that we have the concepts, let's look at how we actually divided things up.
Example 1: Breaking Down a Login Feature with FSD Structure
"I'm building a login form — how do I divide it?" was the most common question we got when first introducing FSD. Below is the structure that actually took root on our team.
// features/login/ui/LoginForm.tsx
// Login form component — handles pure UI only
import { useLoginModel } from '../model/useLoginModel'
export const LoginForm = () => {
const { isLoading, handleSubmit } = useLoginModel()
return (
<form onSubmit={handleSubmit}>
<input type="email" placeholder="이메일" />
<input type="password" placeholder="비밀번호" />
<button type="submit" disabled={isLoading}>
{isLoading ? '로그인 중...' : '로그인'}
</button>
</form>
)
}// features/login/model/useLoginModel.ts
// Login business logic — separated from UI
import { useState } from 'react'
import { loginApi } from '../api/loginApi'
import { sessionModel } from '@/entities/session'
interface LoginCredentials {
email: string
password: string
}
export const useLoginModel = () => {
const [isLoading, setIsLoading] = useState(false)
// features → entities reference is allowed by the rules as it accesses a lower layer
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
const credentials: LoginCredentials = { email: '', password: '' }
const token = await loginApi.login(credentials)
sessionModel.setToken(token)
} finally {
setIsLoading(false)
}
}
return { isLoading, handleSubmit }
}// features/login/index.ts
// Public API — only expose what external code can access
export { LoginForm } from './ui/LoginForm'| File | Layer / Segment | Role |
|---|---|---|
LoginForm.tsx |
features / ui | Renders input UI |
useLoginModel.ts |
features / model | Login state and logic |
loginApi.ts |
features / api | Server communication |
index.ts |
features / (root) | Public API |
When I first proposed this structure to the team, the reaction wasn't entirely positive. Immediate feedback was "Why are there so many files?" The mistake I made then was trying to force four files even for small features. The core of FSD 2.1 is "split when you need to," but I caused team pushback by trying to follow the rules perfectly from the start. After that, I switched to placing things inside the pages slice first and promoting them only when actual reuse occurred — and the resistance dropped significantly.
Example 2: Cross-Slice References Within the Same Layer — Solved with @x
There's a practical problem you'll hit most often in FSD. It's the situation where entities/user and entities/product need to reference each other. Cross-references within the same layer are forbidden, but this kind of case comes up more often in practice than you'd expect. FSD 2.1 officially introduced the @x notation to solve this.
// entities/product/ui/ProductCard.tsx
// Situation where the User type is needed to display reviewer information in ProductCard
import type { User } from '@/entities/user/@x/product'
interface ProductCardProps {
productName: string
reviewer: User
}
export const ProductCard = ({ productName, reviewer }: ProductCardProps) => (
<div>
<h3>{productName}</h3>
<span>리뷰어: {reviewer.name}</span>
</div>
)// entities/user/index.ts — default Public API
export type { User } from './model/types'
export { UserAvatar } from './ui/UserAvatar'
// entities/user/@x/product.ts — API exposed exclusively to the product slice
export type { User } from './model/types'
// Selectively expose only what the product slice needsIt looks complex at first, but once you get used to it, the judgment "Oh, a reference appeared here — I need to create an @x file" comes naturally. The biggest benefit is that hidden dependencies are made explicit in the code.
One practical guideline to add: if @x files start accumulating three or more for a particular slice, it's worth reconsidering whether to move those types or components up to a higher layer (widgets or features). If @x is overused, the dependency graph eventually becomes complex, and the reason for using FSD starts to blur.
Example 3: Auto-Detecting Rule Violations with Steiger
Expecting every team member to hold all FSD rules in their head isn't realistic. So introducing a linter is practically mandatory. Setting up Steiger, made by the official FSD team, lets you catch violations at the CI stage.
pnpm add -D steiger @feature-sliced/steiger-plugin// steiger.config.ts
import { defineConfig } from 'steiger'
import fsd from '@feature-sliced/steiger-plugin'
export default defineConfig([
...fsd.configs.recommended,
{
files: ['./src/**'],
rules: {
'fsd/no-cross-imports': 'error', // Direct cross-slice references within the same layer
'fsd/public-api': 'error', // Imports that bypass index.ts
'fsd/no-processes': 'warn', // Use of the deprecated processes layer
},
},
])In our team's first week after adopting Steiger, we found 23 public-api violations in existing code. Everyone knew things weren't perfect, but seeing it all laid out at once was a shock — and it actually became a strong motivator for refactoring.
Pros and Cons
This was personally the hardest section to write. In the early adoption phase the downsides felt large, but six months in the benefits are much more visible. Since the experience varies considerably depending on team size and project type, take this as a reference.
Pros
| Item | Details |
|---|---|
| Clear code placement | The debate of "where does this code go?" effectively disappears |
| Independent parallel development | Slices are isolated, enabling simultaneous work among team members without conflicts |
| Faster onboarding | New team members can predict where code lives once they understand the structure |
| Gradual adoption | Partial application is possible on legacy codebases starting with new features |
| Dependency control | Layer rules proactively block unexpected side effects |
The benefit we felt most strongly in practice was onboarding. Previously, explaining the project structure to a new team member took half a day; after adopting FSD, a single link to the official docs and a 15-minute explanation was enough.
Drawbacks and Caveats
| Item | Details | Mitigation |
|---|---|---|
| High learning curve | The concepts themselves can feel unfamiliar to developers with under 2 years of experience | It's recommended to write a separate in-team FSD usage guide document |
| Upfront consensus cost | "Is this a feature or an entity?" discussions can go on for quite a while | Documenting case-based decision criteria early on is effective |
| Overhead for small projects | At MVP or prototype stage, you're just adding files | Consider adopting for projects with 3+ team members and a planned lifespan of 6+ months |
| Maintaining consistency | If team members interpret things differently, the structure breaks down quickly | Automating rule enforcement with a linter like Steiger is the realistic approach |
Honestly, the upfront consensus cost was much higher than expected. There were multiple times we spent over 30 minutes in team meetings debating "Is this feature a features or widgets?" In retrospect, all of that was the process of building a shared team vocabulary — but it was quite exhausting at the time.
What is over-engineering? It means applying a structure that is excessively complex relative to the current requirements. FSD is a powerful tool, but applying it to a small-scale project can actually increase maintenance costs.
The Most Common Mistakes in Practice
- Dumping everything into
shared— Under the rationale of "this might be shared,"sharedbecomes a garbage dump all over again. It's recommended to keepsharedto pure utilities with no business logic, UI components, and constants. On top of that, sincesharedhas no slices, creating folders with domain names likeshared/useris itself against the rules. - Misidentifying the boundary between
featuresandentities—entitiesholds the business domain itself (User, Order), whilefeaturesholds user interactions (place order, login). Asking "is this a verb or a noun?" resolves most cases. - Attempting a full migration from the start — Even the Kakao Pay team opted for a gradual transition. When you try to migrate existing code all at once, a half-finished structure tends to live on for a long time.
Closing Thoughts
FSD is a methodology that reduces the time spent thinking about where to put files, freeing that time for solving real problems. Of course, when you first adopt it, you'll paradoxically find yourself having even more discussions. That's normal. Those early debates are part of the process of building a shared team language. Six months in, no one on our team has suggested ditching FSD.
Start with something you can try as early as tomorrow:
- Start by exploring the interactive examples in the official documentation — At feature-sliced.design you can explore the structure visually. Starting from the "Tutorial" section will help you grasp the concepts much faster.
- Try building one new feature with FSD structure, and you'll have real evidence to propose to your team — Without touching existing code, write just the next feature you're adding using the
features/[feature-name]/ui,model,apistructure. - Attaching Steiger to your dev environment first makes problems in your current structure immediately visible — Install it with
pnpm add -D steiger @feature-sliced/steiger-plugin, analyze your existing codebase, and you'll immediately see where dependency issues lie locally before applying it to CI.
References
- Feature-Sliced Design Official Documentation | feature-sliced.design
- FSD v2.1 Release Notes — "Pages come first!" | GitHub
- FSD v2.0 → v2.1 Migration Guide | feature-sliced.design
- Kakao Pay Tech Blog — Adopting FSD Architecture | tech.kakaopay.com
- Steiger — Official FSD Architecture Linter | GitHub
- eslint-plugin-feature-sliced (conarti) | GitHub
- feature-sliced/awesome — FSD Ecosystem Resources | GitHub