Feature vs Layer Architecture
TL;DR
Two organizing principles for app code: layer-based (components/, hooks/, pages/, utils/) groups files by kind; feature-based (features/auth/, features/checkout/) groups files by what they’re for. Layer scales poorly past ~50 components — finding all “checkout” code requires hunting across 7 folders. Feature scales naturally — each feature is a self-contained slice. The senior answer is almost always feature-based for app code, layer-based for shared primitives, with explicit rules about cross-feature imports.
Interview Q&A
Q: Show the two layouts side by side.
A:
Layer-based:
src/
├── components/
│ ├── LoginForm.tsx
│ ├── CheckoutSummary.tsx
│ ├── ProductCard.tsx
│ └── ...
├── hooks/
│ ├── useAuth.ts
│ ├── useCart.ts
│ └── useProduct.ts
├── pages/
│ ├── login.tsx
│ ├── checkout.tsx
│ └── products.tsx
├── utils/
│ ├── auth.ts
│ ├── pricing.ts
│ └── tracking.ts
└── api/
├── auth.ts
├── cart.ts
└── products.ts
Feature-based:
src/
├── features/
│ ├── auth/
│ │ ├── components/LoginForm.tsx
│ │ ├── hooks/useAuth.ts
│ │ ├── api/auth.ts
│ │ ├── utils/auth.ts
│ │ └── index.ts # public API
│ ├── checkout/
│ │ ├── components/CheckoutSummary.tsx
│ │ ├── hooks/useCart.ts
│ │ ├── api/cart.ts
│ │ ├── utils/pricing.ts
│ │ └── index.ts
│ └── products/
│ ├── components/ProductCard.tsx
│ └── ...
├── shared/ # cross-feature primitives
│ ├── ui/
│ │ ├── Button.tsx
│ │ └── Modal.tsx
│ ├── hooks/
│ │ └── useDebounce.ts
│ └── lib/
│ └── fetcher.ts
└── pages/ # routing layer (or app/)
├── login.tsx # thin — wires feature/auth
├── checkout.tsx
└── ...
The feature layout has one folder per business concern, each holding its own components/hooks/api/utils. Routes/pages are thin wrappers.
Q: When does layer-based fall over?
A: Past ~30-50 components. Symptoms:
- “Where does this go?” debates because the boundary isn’t clear.
- Renaming a feature means touching 5+ folders.
- New devs scrolling through 200-component
components/lists. - Tests scattered far from source.
- Refactoring “checkout” means scattershot edits.
Layer works fine for small apps (<20 features). Feature scales to large apps.
Q: What’s “Feature-Sliced Design” (FSD)?
A: A formal feature-based methodology popular in Russian/Eastern European tech (used by some big React shops). Strict layer hierarchy within features:
src/
├── app/ # app-level providers, routing
├── pages/ # route components
├── widgets/ # composite UI blocks (Header, Sidebar)
├── features/ # user actions (LoginButton, AddToCart)
├── entities/ # business objects (User, Product)
└── shared/ # framework-agnostic primitives
Strict rule: a layer can only import from layers below it. app → pages → widgets → features → entities → shared. Enforce with ESLint.
Pros: very explicit, scales to massive teams, junior-friendly conventions. Cons: opinionated; not universal; learning curve for new hires.
Worth knowing the term — interviewers ask.
Q: What about colocation — tests and styles next to source?
A: Modern default: yes. A feature folder holds source + tests + styles together:
features/auth/
├── components/
│ ├── LoginForm.tsx
│ ├── LoginForm.test.tsx
│ ├── LoginForm.module.css
│ └── LoginForm.stories.tsx
Benefits: changing the component means touching one folder, not three (__tests__/, styles/, stories/). Deleting the feature deletes everything cleanly.
The old “tests live in __tests__/” pattern is largely abandoned — colocation won.
Q: What goes in shared/ vs features/?
A:
In shared/ |
In a feature/ |
|---|---|
Generic UI primitives (<Button>, <Modal>, <Input>) |
Domain-specific UI (<CheckoutSummary>, <LoginForm>) |
Generic hooks (useDebounce, useLocalStorage) |
Domain hooks (useCart, useAuth) |
Generic utilities (formatDate, clamp) |
Domain utils (calculatePricing, validateAuthToken) |
API client setup (fetcher, errorMapper) |
Domain endpoints (getCart(), login()) |
Rule of thumb: if it has zero domain knowledge, it’s shared/. If it knows the domain, it’s a feature.
When a primitive starts gathering domain logic (an “Address” form that knows shipping rules), move it into the feature that owns it.
Q: Cross-feature dependencies — allowed?
A: Default: no. Features should be self-contained and only depend on shared/. Cross-feature deps cause:
- Tight coupling — changing feature A breaks feature B.
- Unclear ownership.
- Hard to delete a feature.
When two features genuinely share logic, promote to shared/ or create a third feature that owns the shared concern. Don’t import directly from one feature into another.
Enforce with ESLint — see 05_module_boundaries.md.
Q: What’s the feature/index.ts for?
A: The feature’s public API. Everything outside the feature can only import from feature/index.ts; internals are private.
// features/auth/index.ts
export { LoginForm } from "./components/LoginForm";
export { useAuth } from "./hooks/useAuth";
export type { User } from "./types";
// internals NOT exported — login() helper, AuthContext, etc.
// Consumer
import { LoginForm, useAuth } from "@/features/auth";
Pair with the ESLint boundary rule “no imports of features/X/*/y, only features/X.”
Side effect: stable refactor surface — you can rearrange internals without breaking consumers.
Q: Routes / pages — feature-coupled or separate?
A: Separate, thin. Routes are the composition layer:
// pages/checkout.tsx (or app/checkout/page.tsx in Next App Router)
import { CheckoutSummary, useCart } from "@/features/checkout";
import { LoginPrompt, useAuth } from "@/features/auth";
export default function CheckoutPage() {
const { user } = useAuth();
if (!user) return <LoginPrompt redirect="/checkout" />;
return <CheckoutSummary />;
}
The route file just wires features together. Features don’t know about routes. Routes don’t know about feature internals. Trade dependencies for composition.
Q: When does feature-based hurt?
A:
- Small apps (~3-5 screens) — overhead exceeds benefit. Layer is fine.
- Heavily-shared business logic — when multiple features genuinely share 50% of code, the “feature” boundary is wrong; you have one big feature pretending to be three.
- Junior-heavy team without clear conventions — devs put files anywhere, defeating the structure.
Mitigations: write the convention in CLAUDE.md/CONTRIBUTING.md, enforce with lint, review for adherence.
Gotchas / edge cases
- “Feature” sprawl — features grow to hold tangential concerns until the boundaries dissolve. Periodic refactoring keeps them honest.
- Cross-feature types — a
Usertype used by auth + checkout. Either duplicate (rare), put inshared/types/(common), or put in the feature that owns the entity (FSD’s approach). - Routes leaking into features — a feature component that calls
useRouter()directly couples to routing. Pass router state as a prop, or use a thin route-aware wrapper in the page. - Test setup duplication — each feature reinventing
renderWithProvidersis a smell. Put test utilities inshared/test-utils/. - Renames — renaming a feature folder must update
index.tsimports across the app; IDE refactor + ESLint catches most.
What a senior is expected to say
- “Layer-based for small apps; feature-based once you’re past ~20 features. Layer scales poorly because related code lives in 5 folders.”
- “Each feature has a public API (
index.ts) and private internals. Outside code imports from the API only — enforced with ESLint.” - “Cross-feature imports are forbidden by default. If two features genuinely share, promote to
shared/or extract a third feature.” - “Routes/pages are the composition layer — thin wrappers wiring features. Features don’t know about routes.”
- “Colocate everything that belongs to a component: source, tests, styles, stories. One folder, easy to find, easy to delete.”
Cross-references
- Module boundary enforcement: 05_module_boundaries.md
- Monorepo layout (bigger scale): 02_monorepo_layout.md
- Linting setup (where boundaries get enforced): 06_linting_and_formatting.md
Further reading
- Feature-Sliced Design: https://feature-sliced.design/
- “Bulletproof React”: https://github.com/alan2207/bulletproof-react (popular feature-based example)
- Kent C. Dodds — “Colocation”: https://kentcdodds.com/blog/colocation