Structuring a large frontend
How to split a big frontend into feature modules and layers with one-way import rules, so that many people can change it, test it, and delete parts of it without breaking the rest.
The problem: a codebase nobody dares to change
Picture a shop app two years in. It started as a weekend project with four folders: components, hooks, api, and utils. Now it has 300,000 lines, 40 engineers in six teams, and components holds 900 files. A product manager asks for a small change: show a "saved for later" list under the cart.
The engineer who picks it up edits components/CartList.tsx, hooks/useCart.ts, api/cart.ts, utils/price.ts, and the global store in store/index.ts: five folders for one feature. But utils/price.ts is also imported by checkout, the admin panel, and search. They change one rounding rule, and the next morning the checkout total in production is off by a cent.
The symptoms of a codebase without structure
None of this is a bug in one file. It is a structure problem: code is grouped by kind of file, not by what it does, and anything can import anything. The symptoms:
- Shotgun changes: one small change touches many folders, so pull requests are large and hard to review.
- Fear of deleting: nobody knows what still uses
utils/format.ts, so old code is never removed and the bundle keeps growing. - Merge conflicts: six teams edit
store/index.tsandroutes.tsxevery day. - Slow onboarding: a new engineer needs weeks to learn where things live, because the answer is "everywhere".
- Surprise breakage: a hidden import chain lets a change in one place break an unrelated screen.
utils/price.ts is also used by checkout. By feature, it stays in one folder that one team owns.What a good structure buys you
A modular frontend fixes this with three simple ideas. First, group code by feature, so one change lives in one folder. Second, split each feature into layers (UI, state, data access, services), so each file has one kind of job. Third, set dependency rules: imports may only point in one direction, and a tool checks them on every commit.
The goal is not pretty folders. It is changeability: how cheaply people who did not write a feature can add to it, change it, test it, and delete it a year from now. Every rule in this lesson is judged against that.
This lesson works inside one app (or one package of a monorepo). Splitting code into separate packages or separately deployed apps is a different tool, covered in the monorepos and micro-frontends lessons. Get the structure inside one app right first: a messy app split into five deployables is five messy apps.
The mental model: slices across, layers down
Keep one picture in your head: a grid. The columns are features (cart, search, checkout, profile). The rows are layers (UI at the top, then state, then data access, then services at the bottom). Every file in the app sits in exactly one cell of that grid.
Two rules make the grid work. Inside a column, imports go down: UI uses state, state uses data access, data access uses services, never the reverse. Across columns, features don't import each other at all; when two need the same thing, it moves down into a shared row.
An everyday comparison: a city
Think of a city. Neighborhoods (features) have their own shops and streets, and you can rebuild one without closing the others. Below them sit shared utilities such as water, power, and roads (the shared layers). A neighborhood uses the power grid, but the grid does not know which neighborhoods exist, so a bakery can close without the power station noticing.
That one-way knowledge is the whole trick. Code that is depended on (the grid) must be stable and generic; code that depends on others (the bakery) can be specific and change often. When arrows point from volatile code to stable code, a change in volatile code does not ripple outward.
Three words you will hear
- Module: a folder with a public entry point (often
index.ts) and private insides; other code imports only the entry point. - Layer: a horizontal band of modules with the same kind of job and the same import rights, such as
featuresorshared. - Boundary: the line between two modules that a rule protects. A boundary is only real if a tool fails the build when someone crosses it.
You will also hear coupling (how much one module must know about another) and cohesion (how much the code inside one module belongs together). A good structure has high cohesion inside each feature and low coupling between features. Grouping by file type does the opposite: a hooks folder holds hooks that share nothing but their kind, and features become coupled through it.
Organizing by feature instead of by file type
The first decision is the top-level folders. Most apps start by type: components, hooks, services, utils. It feels tidy at first. Once the app has a few dozen components, each feature is spread across every folder and nothing tells you which files belong together.
Grouping by feature (also called feature folders or vertical slices) flips it: each folder holds everything one feature needs, of every type. The cart folder has its components, hooks, API calls, types, and tests side by side. This is called colocation: code that changes together lives together.
src/
app/ # bootstrap: providers, router, global styles
pages/ # one folder per route, composes features
cart-page/
features/
cart/
ui/ # CartList.tsx, CartItem.tsx
model/ # cart store slice, selectors, hooks
api/ # fetchCart(), addItem(), response mapping
lib/ # pure helpers only cart uses
index.ts # the public API: what other code may import
saved-for-later/
search/
entities/ # shared business objects: product, user
shared/ # no business logic: ui kit, http client, config
The type folders did not disappear; they moved inside each feature, where they are small and hold one feature's code. Deleting features/saved-for-later removes the whole feature: its components, store slice, API calls, and tests.
What counts as a feature?
A feature is a piece of product a user would name: "add to cart", "search", "comment on a post". A good test: could one small team own it, and would a product manager write a ticket about it? "Buttons" is not a feature (it belongs in shared/ui). "Product" is usually not a feature either: it is a business object used by many features, so it belongs in entities.
Size matters too. A feature with two files may not need its own folder, and one with hundreds of files is a sub-app. Split a large feature along user actions, for example cart into cart and promo-codes.
Decision How should the top-level folders of a growing app be organized?
What we pick: Under about 20 screens and 3 engineers, by type is fine. Past that, go by feature with a shared folder and lint rules. Adopt Feature-Sliced Design (or your own written version of it) when several teams share one app and you want a common vocabulary more than you want flexibility.
Check your understanding · Exercise 1
Which of these should be their own folder under features/ in a shop app? Select all that apply.
Layers inside a feature: UI, state, data, services
Feature folders answer "which feature is this code for?" Layers answer "what kind of job does this code do?" Four layers cover almost every frontend, and each may use the layers below it but must not know about the layers above.
UI: render and forward events
Components turn state into markup and clicks into calls. A UI component should not call fetch, know a URL, or reshape server JSON; it gets data from a hook or props and calls a function when the user acts. The common mistake is the 400-line component that fetches, caches, validates, and renders: you cannot test its rendering without mocking the network, or reuse its logic elsewhere.
State: what the screen knows right now
Store slices, selectors, query hooks (TanStack Query, SWR, RTK Query), form state, and state machines. This layer decides when to fetch, how to cache, and how to apply an optimistic update; it calls the data layer and never touches the DOM. Avoid one giant store that every feature writes to; the state categories lesson shows how to keep server, URL, and UI state apart.
Data access: talk to the server, translate the answer
Functions like fetchCart() and addItem(id, qty) that build the request, call the HTTP client, validate the response, and map it into the shape the app wants. Mapping here is what protects the rest of the app: if the backend renames unit_price_cents to priceCents, you change one mapper, not 30 components.
// features/cart/api/fetchCart.ts
import { http } from "@/shared/api";
import type { Cart } from "../model/types";
type CartDto = { items: { sku: string; qty: number; unit_price_cents: number }[] };
export async function fetchCart(): Promise<Cart> {
const dto = await http.get<CartDto>("/api/cart");
return {
lines: dto.items.map((i) => ({
sku: i.sku,
quantity: i.qty,
price: i.unit_price_cents / 100,
})),
};
}
Cart type.Services: the plumbing every feature shares
The HTTP client with auth headers and retries, the logger, the analytics wrapper, feature flag reads, storage helpers, and config. It has no business words in it (no "cart", no "order"), which lets every feature use it safely. In Feature-Sliced Design this is the shared layer.
CartLine.Testing each layer the cheap way
Layers are also a testing plan. Each layer has an obvious seam where you can replace the layer below it, so each test stays small and fast.
| Layer | What to test | What to fake | Typical tool |
|---|---|---|---|
| UI | Renders the right text, roles, and states; calls the handler on click | The state hook, or the network with MSW | Testing Library, Storybook stories |
| State | Transitions, optimistic updates, rollback on error | The data functions | Plain unit tests, a store in memory |
| Data access | Request shape, response mapping, error mapping | HTTP, with recorded responses | Unit tests with MSW or a fake client |
| Services | Retries, auth header, token refresh | fetch itself | Unit tests |
| Whole feature | The happy path across all layers | Only the server | A few integration or end-to-end tests |
Check your understanding · Exercise 2
A component converts created_at (an ISO string from the server) into a Date before rendering. Where should that conversion live? Pick one.
Dependency rules: imports point one way
Folders alone change nothing: a feature folder that imports from three other features is a tangle with nicer names. What makes the structure hold is a small set of dependency rules, written statements of which module may import which, checked by a tool.
The four rules most teams use
- Downward only. A module may import from layers below it, never from layers above.
sharedimports nothing from the app;featuresmay importentitiesandshared;pagesmay importfeatures. - No sideways imports. Two slices on the same layer do not import each other.
features/cartnever importsfeatures/search. - Public API only. Code outside a module imports only its entry point (
features/cart), never a deep path likefeatures/cart/model/store. - No cycles. If A imports B, nothing that B imports (directly or through others) may import A.
These rules echo layered and clean architecture from the backend world: dependencies point toward the more stable, more general code. Here the most stable code sits at the bottom (shared) and the most volatile at the top (pages that change every sprint).
Feature-Sliced Design's layer order
Feature-Sliced Design (FSD) is a public convention that writes these rules down. Its layers, top to bottom, are app, pages, widgets, features, entities, and shared (an older processes layer is deprecated). A module may import only from layers strictly below its own. app and shared have no slices, just segments such as ui, api, and lib.
Two details matter, though the methodology still evolves. Current FSD guidance keeps code in the page that uses it until another page needs it, instead of creating features up front. And when two entities truly need each other (an order shows a user), FSD allows a cross-import only through a special public API written with @x, such as entities/user/@x/order.ts, so the exception stays visible.
1 rule violation, no cycles.
features/search → features/cart: sideways: two features slices may not import each other
How features talk without importing each other
Teams soon ask: "the product card in search needs an add-to-cart button, so search must import cart, right?" No. Four patterns connect features while keeping every arrow pointing down.
- Compose in a higher layer. The search page imports both features and passes
AddToCartButtoninto the result list as a prop or slot. Search renders whatever it is given. - Share down. If both need the same data, it moves to
entities/product, which both may import. - Invert the dependency. The lower module defines the shape it needs (
onAddToCart(sku)), and the higher module passes the function in. The lower module owns the interface; the higher one owns the implementation. - Events or the URL. For loose, one-way signals ("cart changed"), publish an event or change the URL and let interested features react. Use this sparingly: events are harder to trace than props.
// pages/search-page/ui/SearchPage.tsx: the page composes two features
import { SearchResults } from "@/features/search";
import { AddToCartButton } from "@/features/cart";
export function SearchPage() {
return (
<SearchResults
renderAction={(product) => <AddToCartButton sku={product.sku} />}
/>
);
}
Why cycles hurt more than they look
A cycle is A imports B and B imports A, often through a chain of several files. In ES modules, one module in the cycle runs before another has finished, so a value read at the top level may not be ready yet: ReferenceError: Cannot access 'X' before initialization. Cycles also break hot reload in some setups, tie chunks together so neither lazy-loads alone, and stop you testing one module without the other.
Check your understanding · Exercise 3
Using the layer order pages, features, entities, shared, which imports are allowed? Select all that apply.
Enforcing boundaries with tools
A rule that lives only in a wiki page does not last. Someone in a hurry imports features/cart/model/store from search, the review misses it, and the next person copies the pattern. A boundary is real only when a tool fails the build on a violation, ideally in the editor while you type.
Level 1: ESLint import rules
The cheapest start needs no plugin. ESLint's built-in no-restricted-imports rule can ban import patterns per folder. With flat config you add one block per layer, and each block lists what that layer may not import.
// eslint.config.js (flat config)
const deepImports = {
group: ["@/features/*/*", "@/entities/*/*"],
message: "Import the module's public API (its index), not its insides.",
};
export default [
{
files: ["src/**"],
rules: {
"no-restricted-imports": ["error", { patterns: [deepImports] }],
},
},
{
files: ["src/shared/**"],
rules: {
"no-restricted-imports": ["error", {
patterns: [deepImports, {
group: ["@/entities/*", "@/features/*", "@/pages/*", "@/app/*"],
message: "shared must not import business code.",
}],
}],
},
},
];
shared imports no business code. A later block replaces the rule's options for the files it matches, so the shared block repeats the deep-import pattern. The sideways rule needs a plugin, because a pattern cannot say "any feature except my own".For richer rules, eslint-plugin-boundaries lets you name element types (app, pages, features, entities, shared) by folder pattern and then write allow-lists such as "features may import entities and shared". It understands "same slice" versus "other slice", which plain patterns cannot. For cycles, import/no-cycle from eslint-plugin-import (or its faster fork, eslint-plugin-import-x) works in the editor but can be slow on very large codebases, so many teams check cycles in CI instead.
Level 2: whole-graph checkers
Most ESLint rules look at one file at a time. dependency-cruiser builds the whole import graph and checks rules against it: forbidden paths, cycles of any length, orphan modules nobody imports, and dependencies missing from package.json. It can also draw the graph, a fast way to show a team how tangled things are.
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{ name: "no-cycles", severity: "error", from: {}, to: { circular: true } },
{
name: "no-feature-to-feature",
severity: "error",
from: { path: "^src/features/([^/]+)/" },
to: { path: "^src/features/([^/]+)/", pathNot: "^src/features/$1/" },
},
{ name: "shared-is-a-leaf", severity: "error",
from: { path: "^src/shared/" }, to: { path: "^src/(app|pages|features|entities)/" } },
],
};
$1 back-reference means "a different feature than the one importing". This is how the sideways rule is written.If you follow FSD, its official linter Steiger checks FSD-specific rules (layer order, public APIs, slice naming). In an Nx monorepo, the @nx/enforce-module-boundaries lint rule works on whole libraries: you tag each one (type:feature, scope:checkout) and list which tags may depend on which, which gives you layer rules and domain rules at once. It also reports cycles between libraries, though not between files inside one library.
Level 3: let the compiler or the package system refuse
The strongest boundaries are ones the build cannot cross. With TypeScript project references, each module is its own tsconfig project with composite: true, it may use only projects listed in its references, and tsc rejects references that form a cycle. In a monorepo, the exports field in each package's package.json hides everything not listed, so a deep import fails to resolve. These cost more setup and slow refactors, so most teams use them only at the biggest seams.
Check your understanding · Exercise 4
Your team wants to catch a cycle that goes through six files across three features. Which tools would catch it? Select all that apply.
Public APIs per module, and the cost of barrel files
Each module gets one front door: a file, usually index.ts, that lists what the rest of the app may use. Everything else in the folder is private, like the private members of a class.
// features/cart/index.ts: the cart's public API
export { CartSummary } from "./ui/CartSummary";
export { AddToCartButton } from "./ui/AddToCartButton";
export { useCartCount } from "./model/useCartCount";
export type { CartLine } from "./model/types";
// Not exported: the store slice, the API calls, the mappers, CartItemRow.
The payoff is freedom to refactor. If the cart team moves from a Redux slice to TanStack Query, nothing outside the folder changes, because nothing outside could see the slice. A small index.ts also documents what the feature offers.
Barrel files have a real cost
A file that only re-exports other files is called a barrel. Barrels are how public APIs are usually built, and they have costs. To import one name from a barrel, a tool must load the barrel, and the barrel imports every file it re-exports.
- Dev server: Vite serves source files as separate native ES modules during development, so importing
Buttonfrom ashared/uibarrel of 150 components makes the browser fetch, and Vite transform, all 150 files. - Tests: Jest and Vitest give each test file its own module registry, so a test that imports a big barrel loads the whole barrel graph again in every test file.
- Production bundles: tree-shaking drops unused re-exports only when the bundler knows the skipped files have no side effects, so mark packages with
"sideEffects": falseinpackage.json(or list the files that have them). - Hidden cycles: a file inside
features/cartthat imports from@/features/cart(its own barrel) creates a cycle through the index. Inside a module, always import siblings by relative path.
sideEffects.Some tools now work around this. Next.js has an experimental optimizePackageImports option that rewrites imports from listed packages into direct imports, with libraries such as lucide-react and date-fns on its default list. That it exists shows how common the problem is.
Rules that keep the benefits and drop the costs
- One barrel per slice, not per folder.
features/cart/index.tsis the public API. Don't addindex.tsfiles insideui/,model/, andapi/. - Named exports, not
export *. An explicit list is the API.export *exports everything by accident, including the private helpers you meant to hide. - Big libraries get per-item entry points. For a
shared/uiwith 150 components, import@/shared/ui/buttonrather than@/shared/ui. FSD recommends this forshared/uifor the same reason.
Check your understanding · Exercise 5
A team's unit tests got 3 times slower after they added src/shared/index.ts with export * from for 40 folders. What helps? Select all that apply.
Domain boundaries and the shared kernel
Layers split code by kind of job. Domains split it by business area: catalog, ordering, payments, accounts. In a large app the most expensive mistakes are domain mistakes, where two business areas share one model that means different things to each.
One word, two meanings
Take "product". In the catalog it has photos, a description, reviews, and variants. In the cart it is a line: a SKU, a quantity, and the price at the time it was added; in the admin panel it has stock levels and supplier codes. If all three share one big Product type, every change for one area risks breaking the other two, and the type fills up with optional fields.
Domain-driven design calls each area with its own consistent language a bounded context. The frontend lesson is simple: let each domain own its own types, even when they share a name. CartLine and CatalogProduct can both carry a sku, and that shared sku is how they refer to each other.
CatalogProduct has photos and reviews, CartLine has a quantity and the price when added. They refer to each other only by sku. The shared kernel stays small and changes slowly.The shared kernel: small on purpose
Some things really are shared: the current user, money formatting, the design system, the HTTP client. Domain-driven design calls such a core the shared kernel; in FSD terms it lives in entities and shared. Keep it small and slow to change, with every change reviewed by more than one team. A kernel that grows every sprint means features are pushing their own code down to dodge the no-sideways rule.
| Question | Keep it in the feature | Move it to entities or shared |
|---|---|---|
| Who uses it? | One feature | Two or more features, today (not "maybe someday") |
| Does it have business words? | Yes, specific to this flow | Entities: yes, a core object. Shared: no business words at all |
| How often does it change? | Often, with the feature | Rarely; changes need wider review |
| Who owns it? | The feature team | A named owner, often a platform team |
Share only when a second user really exists, not "maybe someday". For small helpers, a copy is often cheaper still: the rule of three says move code down when the third copy appears. Duplicated code costs two places to fix; wrong shared code costs a coupling between teams that is hard to undo.
Translate at the edge of each domain
Backend services each have their own shapes, and sometimes their own words for the same thing. Each domain's data layer translates them into its own types, so a backend change stops at one mapper. DDD calls this an anti-corruption layer; in a frontend it is usually just the mapper functions from the layers chapter, or a backend for frontend (BFF) that shapes responses per screen.
Think first
Your checkout needs to show the user's saved addresses, which the account feature already loads and displays. Should checkout import from features/account?
No. That is a sideways import, and it ties checkout to account's internals.
Move the address data and its fetching into entities/address (or entities/user), which both may import. Each feature keeps its own UI: account an editable list, checkout a compact picker. If checkout must let the user add an address, the page can compose account's AddAddressForm into checkout through a slot.
Keeping it changeable: deleting, migrating, and the anti-patterns
Structure is not a one-time decision. Every app drifts: deadlines push code into the wrong place, and a clean layout from year one becomes a tangle by year three. Three habits keep it changeable: designing for deletion, migrating in small steps, and spotting anti-patterns early.
Design for deletion
Many features are experiments that later get removed. A feature that is easy to delete is also easy to change, test, and lazy-load, because all of these need few, clear connections to the rest of the app. Removing any feature should take:
- Deleting its folder.
- Removing its route (one line in the router) and any slot where a page composes it.
- Removing its feature flag, if it has one.
- Nothing else: the type checker and lint pass with no other edits.
If deleting a feature needs edits in shared, the global store, or another feature, that is a leak, and the list of edits maps exactly where to fix the structure.
Migrating without a rewrite: the strangler fig
Teams that inherit a tangled app are tempted by a big rewrite, which often runs late because the old app keeps changing meanwhile. The safer pattern is the strangler fig, which Martin Fowler named after vines that grow around a host tree until they replace it: build the new structure next to the old, move one piece at a time, and delete each old piece once nothing uses it.
1,200 files grouped by type, and no rules, so nothing stops a new tangle.
Inside one app, create features, entities, and shared next to the old components and utils, with lint rules on for the new folders only. Move one feature per pull request, starting with the one that changes most often, where the payoff is biggest. When the old folders are empty, delete them and turn the rules on everywhere.
Anti-patterns and how you notice them
| Anti-pattern | What it looks like | How you notice | Fix |
|---|---|---|---|
The utils dumping ground | utils/index.ts with 80 unrelated functions, half with business logic | Everything imports it; any change re-runs all tests | Move business helpers into their feature; split the rest by topic (shared/lib/money, shared/lib/dates) |
| Circular dependencies | A imports B imports C imports A | "Cannot access before initialization" errors, broken hot reload, chunks that cannot split | Cycle check in CI; move the shared type or constant down a layer |
| The god store | One global store with every feature's state and actions | Every feature edits store/index.ts; a change re-renders unrelated screens | One slice per feature, registered by the feature; server data in a query cache |
| Shared grows forever | shared gains business words like cart or order | shared changes in most pull requests | Rule of three; move business code back up to entities or features |
| Feature-to-feature imports | features/search imports features/cart/model | Lint errors silenced with disable comments | Compose in the page, or move the shared part down |
| Over-layering | A static page has a store, a service, and a repository | Files that only pass values through | Add layers when they have a real job |
Two cheap numbers show drift early. Files touched per pull request: if a typical feature change touches more than about 10 files across 3 or more top-level folders, boundaries are leaking. Fan-in: how many modules import a file. A shared file imported by 400 modules must be very stable; if it also changes weekly, it is in the wrong place.
Check your understanding · Exercise 6
Deleting the wishlist feature requires edits to: the router, shared/lib/analytics.ts (it has a trackWishlistAdd function), and features/product-page (it imports WishlistButton directly). Which of these are leaks? Select all that apply.
In real codebases, and in a system design interview
What well-known projects do
- Angular's style guide tells you to organize a project by feature areas rather than by type of file.
- Bulletproof React, a widely read open-source reference app, uses
app,features, and shared folders, and forbids cross-feature and upward imports with theimport/no-restricted-pathsESLint rule. It now also skips feature barrels and imports files directly, for dev server speed. - Feature-Sliced Design publishes its layers, rules, and the Steiger linter.
- Nx-based monorepos tag libraries by type and scope and enforce the rules with
@nx/enforce-module-boundaries, which turns this lesson's grid into lint errors across many packages.
The details differ, but the shape repeats: features own their code, a small shared core sits below, imports go one way, and a tool says no. Listen for those four things when a team describes its structure.
When it comes up in an interview
Structure is rarely the whole question, but it is a common follow-up: "Now 30 engineers will work on this; how do you organize the code?" Senior and staff interviews also ask it directly: "How would you structure a large frontend?" or "Our app is a tangle; what would you do?"
- Draw the grid. Features as columns (name the product's real ones), layers as rows, arrows only downward, no sideways arrows.
- State the rules. Downward only, no sideways imports, public API per slice, no cycles.
- Say how they are enforced. ESLint boundaries or dependency-cruiser in CI; Nx tags if it is a monorepo.
- Show how features talk. Composition in the page, shared entities, events for loose signals.
- Close with change. How you would delete a feature, and how you would migrate an existing tangle with a strangler fig.
| Level | What a strong answer covers |
|---|---|
| Junior | Group by feature, keep API calls out of components, explain why a utils folder becomes a problem |
| Mid | Layers with clear jobs, the four dependency rules, public APIs, composing features in pages, testing per layer |
| Senior | Enforcement with tools and a baseline, domain boundaries and team ownership, barrel costs, cycles, strangler migration, when to stop at one app versus split into packages or micro-frontends |
Check your understanding · Exercise 7
An interviewer says: "Our 5-year-old React app has 1,200 files in components/ and constant regressions." Which first steps make sense? Select all that apply.
Summary
- Structure is about changeability: how cheaply people who did not write a feature can change, test, and delete it later.
- Group by feature so a change lives in one folder; keep small type folders like
ui,model, andapiinside each feature. - Layers give each file one job: UI renders, state decides, data access talks to the server and maps responses, services are generic plumbing.
- Four rules hold it together: imports go downward only, no sideways imports between features, only public APIs, no cycles.
- Rules are real only when a tool enforces them: ESLint boundaries, dependency-cruiser, Nx tags, or TypeScript project references, with a baseline for old code.
- Features meet in a higher layer (pages compose them) or through a small shared kernel below them, never by importing each other.
- Barrels make good public APIs but cost load time in dev and tests; keep one per slice with named exports, never a root barrel.
- Fix a tangled app with a strangler fig: new structure next to the old, one feature per pull request, delete the old folders at zero.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
How would you organize the code of a large frontend that 30 engineers work on?
By feature: one folder per product feature (cart, search, checkout) with its own UI, state, and data access, plus a public index.ts. Below that, entities for shared business objects like user and product, and shared with no business words: the UI kit, the HTTP client, config.
Imports go one way: pages use features, features use entities and shared, and features never import each other; pages compose them through props and slots. ESLint boundary rules and a dependency-cruiser cycle check in CI enforce it, so the structure survives deadlines.
Question 2
Two features need to interact: search results need an add-to-cart button. How do you wire that without coupling them?
The search page composes both. Search exposes a slot such as renderAction(product), and the page passes cart's AddToCartButton into it. Search never imports cart, so either can change or be deleted alone.
Data both need moves down into entities/product. For loose one-way signals, like refreshing a badge after the cart changes, an event or a shared query cache key works, but I keep those rare because they are harder to trace.
Question 3
What are the downsides of barrel files, and do you still use them?
Importing one name from a barrel loads every module it re-exports. That slows native-ESM dev servers and test runners, can defeat tree-shaking when modules are not marked side-effect free, and creates cycles when a module imports its own barrel.
I still use one small barrel per feature as its public API, with named exports. I avoid root barrels like shared/index.ts, give big libraries per-item entry points, and use relative paths inside a module.
Question 4
You join a team whose app has constant circular dependency errors and a 3,000-line global store. What do you do?
First, stop new damage: add dependency-cruiser with a baseline of known cycles, so CI fails only on new ones. Then break the worst cycles, usually by moving a shared type or constant down a layer.
Split the store into one slice per feature, registered by that feature, and move server data into a query cache, which often removes most of the store. Go feature by feature, as a strangler fig, starting where change is most frequent.
Question 5
When would you not bother with layers and feature folders?
For a small app, a prototype, or a team of one to three people, grouping by type is fine and faster. Layers aren't needed everywhere either: a static page needs no store or data layer.
I add structure when the signals appear: changes touching many folders, merge conflicts on the same files, or fear of deleting code. The move is cheap at 50 files and expensive at 5,000.
References
- Feature-Sliced Design: overview of layers, slices, and segments The public methodology for layers and import rules.
- Feature-Sliced Design: Public API Public APIs, the
@xcross-import notation, and the known problems with index files. - Martin Fowler: StranglerFigApplication The original description of incremental replacement.
- dependency-cruiser Validate and visualize the whole import graph, including cycles.
- eslint-plugin-boundaries Element types and allow-lists for architecture rules in ESLint.
- Nx: Enforce module boundaries Tag-based dependency rules across libraries in a monorepo.
- TypeScript handbook: Project references
- Vite: Performance guide Why barrel files slow down the dev server.
- Bulletproof React An open-source reference app with feature folders and import rules.
Read next
- MonoreposOne repository for many apps and shared packages, plus what makes it scale: workspaces that link packages, task runners that build only what changed and cache the rest, folder owners, and a rule for versioning internal code.
- Micro-frontendsSplitting one web app into parts that separate teams build and deploy on their own, then combining them at build time, on the server, or in the browser: how each approach works, what it costs in bytes and complexity, and why most teams should not do it.
- Design systemsA shared set of design tokens, accessible components, patterns, docs, and rules that many teams build on, so a company ships one consistent product instead of many slightly different ones, and can change its look in one place.
- Kinds of stateServer data, UI toggles, the URL, form drafts, and live streams are five different kinds of state, each with its own home: knowing which is which is the first step of every frontend design.
- Local vs global stateWhere each piece of state lives decides what re-renders when it changes: keep it next to its readers, lift it only as far as needed, and use context or a store with selectors only for state that many distant parts share.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- A frontend testing strategyHow to split a frontend's tests between static checks, unit, integration, and end-to-end tests, so they catch the bugs that matter without making every change slow.