Feature vs Layer Architecture
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.
Show the two layouts side by side.
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.tsFeature-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.
When does layer-based fall over?
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.
What’s “Feature-Sliced Design” (FSD)?
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 primitivesStrict 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.
What about colocation — tests and styles next to source?
Modern default: yes. A feature folder holds source + tests + styles together:
features/auth/
├── components/
│ ├── LoginForm.tsx
│ ├── LoginForm.test.tsx
│ ├── LoginForm.module.css
│ └── LoginForm.stories.tsxBenefits: 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.
What goes in shared/ vs features/?
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.
Cross-feature dependencies — allowed?
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 Module Boundaries — eslint-plugin-boundaries, dependency-cruiser.
What’s the feature/index.ts for?
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.
Routes / pages — feature-coupled or separate?
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.
When does feature-based hurt?
- 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
- “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.
Related
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
Interview angle 5
- “Organise by layer or by feature?” - by layer while the app is small, by
feature once there are roughly twenty of them. Layering scales badly because a
single change touches
components/,hooks/,api/,types/andstore/- five folders holding one idea between them. - “What makes a feature folder a real boundary?” - a public API. An
index.tsexports what other code may use and everything else is private, and that boundary is enforced by lint rather than by good intentions. Without enforcement it is just a folder name. - “Can one feature import from another?” - not by default. If two features
genuinely need the same thing, promote it to
shared/or extract a third feature that both depend on. Feature-to-feature imports are how you get a graph nobody can reason about. - “Where do routes fit?” - as the composition layer: thin wrappers that wire features together. Features do not know about routes, which is what lets you move a feature between pages or render two of them side by side without touching either.
- “What lives next to a component?” - everything belonging to it: source, test, styles, stories. One folder makes it easy to find and, more importantly, easy to delete - and being able to delete a feature cleanly is the real test of the boundary.