frontend / project structure / 01_feature_vs_layer.md

Feature vs Layer Architecture

6 min read source

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. apppageswidgetsfeaturesentitiesshared. 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 User type used by auth + checkout. Either duplicate (rare), put in shared/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 renderWithProviders is a smell. Put test utilities in shared/test-utils/.
  • Renames — renaming a feature folder must update index.ts imports 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

Further reading