cf.completefrontend
My progress
FUNDAMENTALS · MODULE 17

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.ts and routes.tsx every 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.
Before and after. Left, folders by file type: one cart change edits CartList.tsx in components, useCart.ts in hooks, cart.ts in api, price.ts in utils, and index.ts in store, so five folders. Right, folders by feature: the same change edits only features/cart, while features/search, features/checkout, and shared are untouched.before: by file typeafter: by featurecomponents/CartList.tsxhooks/useCart.tsapi/cart.tsutils/price.tsstore/index.tsfeatures/cart/ui, model, api, libfeatures/search/untouchedfeatures/checkout/untouchedshared/untouchedone cart change: 5 foldersone cart change: 1 folder
Highlighted folders are the ones one small cart change edits. By type, the change and its review spread over five folders, and 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.

A grid. Columns are four features: cart, search, checkout, and profile. Rows inside each feature are ui, model, and api, with files like CartList, cartSlice, and fetchCart in the cart column, which is highlighted. Below all features sit two shared rows: entities with product and user, and shared with the http client, ui kit, and logger. Imports go down only, and the columns never import each other.columns never import each otherimports go downcartsearchcheckoutprofileuimodelapiCartListSearchBoxPayFormAvatarcartSliceuseSearchuseOrderuseUserfetchCartsearchApipostOrdergetUserentitiesproduct, usersharedhttp client, ui kit, logger
Every file sits in one cell. A cart change stays in the highlighted column. Code two features need moves down into entities or shared, never sideways into another column.

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 features or shared.
  • 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.

text
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
A feature-first layout. Inside each feature, small subfolders still group by type, which keeps the familiar shape at a smaller scale.

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.

A decision tree for placing new code. Is it a whole route? Yes: pages. No: is it used by one feature only? Yes: inside that feature's folder. No: does it have business words? Yes: entities. No: shared. A note says: not sure? Keep it in the feature until a second user appears.Is it a whole route?pages/yesnoUsed by one feature only?features/x/yesnoHas business words?entities/yesnoshared/not sure? keep it inthe feature until a2nd user appears
Ask the questions top to bottom. Most new code stops at the second question and stays in its feature, which is the right default.

Decision How should the top-level folders of a growing app be organized?

By file type

Top folders components, hooks, api, utils, store.

Each folder holds that kind of file for every feature.

Pros

  • Zero setup; every tutorial starts here.
  • Easy to find "all the hooks".

Cons

  • One feature spreads across every folder.
  • No natural owner per folder; merge conflicts grow with the team.
  • Hard to delete anything safely.

By feature (vertical slices)

One folder per feature with its own ui, model, api.

A shared folder for generic code, an index.ts per feature.

Pros

  • A change stays in one folder; teams own folders.
  • Features can be deleted, lazy-loaded, and tested alone.

Cons

  • You must decide where shared code goes.
  • Without lint rules, features still import each other's insides.

Feature-Sliced Design (a named convention)

Fixed layers: app, pages, widgets, features, entities, shared.

Slices inside layers, segments (ui, model, api, lib, config) inside slices.

Pros

  • A shared vocabulary and docs; new hires learn it once.
  • Clear import rule: a layer uses only layers below it.

Cons

  • More ceremony; small apps feel over-structured.
  • Arguments about whether something is a widget, feature, or entity.

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.

Four layers stacked top to bottom. UI holds CartList.tsx and AddButton.tsx. State holds cartSlice.ts and useCart.ts. Data access holds fetchCart.ts and mapCart.ts. Services holds http.ts and logger.ts. Arrows point down from each layer to the next, labelled uses. A red arrow on the right points up, labelled never upward.UICartList.tsxAddButton.tsxStatecartSlice.tsuseCart.tsData accessfetchCart.tsmapCart.tsServiceshttp.tslogger.tsusesneverupward
Each layer may use the layers below it. If a lower layer imports a higher one, the two are tied together, and neither can be changed or tested alone.

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.

ts
// 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,
    })),
  };
}
The server's field names stop at this file. Everything above it sees the app's own 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.

A sequence diagram across five lifelines: User, UI, State, Data, and Services. The user clicks Add. UI calls addLine(sku) on State. State calls postLine() on Data. Data calls POST /cart on Services. Services replies 201 with JSON to Data. Data maps it and replies with a CartLine to State. State tells UI to re-render.UserUIStateDataServicesclick AddaddLine(sku)postLine()POST /cart201 + JSONCartLinere-render
Each layer talks only to its neighbor below and gets an answer back. The server's JSON stops at the data layer, which hands State a clean 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.

LayerWhat to testWhat to fakeTypical tool
UIRenders the right text, roles, and states; calls the handler on clickThe state hook, or the network with MSWTesting Library, Storybook stories
StateTransitions, optimistic updates, rollback on errorThe data functionsPlain unit tests, a store in memory
Data accessRequest shape, response mapping, error mappingHTTP, with recorded responsesUnit tests with MSW or a fake client
ServicesRetries, auth header, token refreshfetch itselfUnit tests
Whole featureThe happy path across all layersOnly the serverA 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

  1. Downward only. A module may import from layers below it, never from layers above. shared imports nothing from the app; features may import entities and shared; pages may import features.
  2. No sideways imports. Two slices on the same layer do not import each other. features/cart never imports features/search.
  3. Public API only. Code outside a module imports only its entry point (features/cart), never a deep path like features/cart/model/store.
  4. 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.

Add imports:
A module graph in four layers: pages/search and pages/cart; features/cart, features/search, and features/wishlist; entities/product and entities/user; shared/ui and shared/api. The app's own imports all point down. Added imports: features/search → features/cart. 1 rule violation, no cycles.pages/searchpages/cartfeatures/cartfeatures/searchfeatures/wishlistentities/productentities/usershared/uishared/api
Thin arrows are the imports the app already has; they all point down. Added imports are green when every rule allows them and red when a rule breaks. Arrows that form a cycle turn red as well, even if each one alone looked fine.

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 AddToCartButton into 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.
tsx
// 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} />}
    />
  );
}
Search knows nothing about carts. Delete the cart feature and only this one line in the page changes.

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.

js
// 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.",
        }],
      }],
    },
  },
];
Two rules: nobody deep-imports a slice, and 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.

js
// .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)/" } },
  ],
};
The $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.

A comparison matrix of boundary tools against four questions: works in the editor, can express the no-sideways rule, finds cycles of any length, and setup cost. no-restricted-imports: in editor yes, sideways no, any cycle no, setup low. eslint-plugin-boundaries: in editor yes, sideways yes, any cycle no, setup medium. import/no-cycle: in editor yes, sideways no, any cycle yes, setup low. dependency-cruiser: in editor CI, sideways yes, any cycle yes, setup medium. Nx module boundaries: in editor yes, sideways yes, any cycle only between projects, setup high. TS project references: in editor yes, sideways yes, any cycle only between projects, setup high.in editorsidewaysany cyclesetupno-restricted-importsyesnonoloweslint-plugin-boundariesyesyesnomediumimport/no-cycleyesnoyeslowdependency-cruiserCIyesyesmediumNx module boundariesyesyesprojectshighTS project referencesyesyesprojectshigh
No single tool covers everything cheaply. Nx and project references see cycles between projects, not between files inside one. A common pair is an ESLint rule set for fast feedback in the editor plus dependency-cruiser in CI for the whole graph.

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.

ts
// 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.
Four names out of perhaps 30 files. The cart team can rewrite everything else without telling anyone.

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 Button from a shared/ui barrel 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": false in package.json (or list the files that have them).
  • Hidden cycles: a file inside features/cart that imports from @/features/cart (its own barrel) creates a cycle through the index. Inside a module, always import siblings by relative path.
A bar chart of modules loaded to use one Button component in an example app, by how it is imported: direct file, 1; feature index, 12; shared/ui index, 150; root shared index, 400.modules loaded to use one Buttondirect file1feature index12shared/ui index150root shared index400
Example numbers for a mid-size app. In a dev server that serves native modules, or a test runner, every one of those modules is fetched or parsed. A production bundler can drop the unused ones only if they are marked side-effect free with 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.ts is the public API. Don't add index.ts files inside ui/, model/, and api/.
  • 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/ui with 150 components, import @/shared/ui/button rather than @/shared/ui. FSD recommends this for shared/ui for 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.

Three domains at the top, each with its own type: catalog with CatalogProduct, cart with CartLine, and checkout with OrderDraft. They don't import each other. Below them a dashed zone labelled shared kernel holds entities/product, entities/user, and shared. Catalog and cart use entities/product; cart and checkout use entities/user; both entities use shared.catalogCatalogProductcartCartLinecheckoutOrderDraftshared kernelentities/productentities/usershared
Each domain owns its own idea of a product: 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.

QuestionKeep it in the featureMove it to entities or shared
Who uses it?One featureTwo or more features, today (not "maybe someday")
Does it have business words?Yes, specific to this flowEntities: yes, a core object. Shared: no business words at all
How often does it change?Often, with the featureRarely; changes need wider review
Who owns it?The feature teamA 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?

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:

  1. Deleting its folder.
  2. Removing its route (one line in the router) and any slot where a page composes it.
  3. Removing its feature flag, if it has one.
  4. 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.

Step 1 of 5: Before
Migration step 1, Before. Old folders: components with 900 files, utils with 200, store with 100. New feature folders moved so far: none. Files left in old folders: 1200. Known violations: not measured.old: by typecomponents/ 900utils/ 200store/ 100new: not startedfeatures/cart/features/search/features/checkout/features/profile/old files: 1,200violations: unknown
The old and new structures live side by side. Each step moves one area, the old folders shrink, and the baseline of known violations can only go down.

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-patternWhat it looks likeHow you noticeFix
The utils dumping groundutils/index.ts with 80 unrelated functions, half with business logicEverything imports it; any change re-runs all testsMove business helpers into their feature; split the rest by topic (shared/lib/money, shared/lib/dates)
Circular dependenciesA imports B imports C imports A"Cannot access before initialization" errors, broken hot reload, chunks that cannot splitCycle check in CI; move the shared type or constant down a layer
The god storeOne global store with every feature's state and actionsEvery feature edits store/index.ts; a change re-renders unrelated screensOne slice per feature, registered by the feature; server data in a query cache
Shared grows forevershared gains business words like cart or ordershared changes in most pull requestsRule of three; move business code back up to entities or features
Feature-to-feature importsfeatures/search imports features/cart/modelLint errors silenced with disable commentsCompose in the page, or move the shared part down
Over-layeringA static page has a store, a service, and a repositoryFiles that only pass values throughAdd 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 the import/no-restricted-paths ESLint 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?"

  1. Draw the grid. Features as columns (name the product's real ones), layers as rows, arrows only downward, no sideways arrows.
  2. State the rules. Downward only, no sideways imports, public API per slice, no cycles.
  3. Say how they are enforced. ESLint boundaries or dependency-cruiser in CI; Nx tags if it is a monorepo.
  4. Show how features talk. Composition in the page, shared entities, events for loose signals.
  5. Close with change. How you would delete a feature, and how you would migrate an existing tangle with a strangler fig.
LevelWhat a strong answer covers
JuniorGroup by feature, keep API calls out of components, explain why a utils folder becomes a problem
MidLayers with clear jobs, the four dependency rules, public APIs, composing features in pages, testing per layer
SeniorEnforcement 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, and api inside 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?

Question 2

Two features need to interact: search results need an add-to-cart button. How do you wire that without coupling them?

Question 3

What are the downsides of barrel files, and do you still use them?

Question 4

You join a team whose app has constant circular dependency errors and a 3,000-line global store. What do you do?

Question 5

When would you not bother with layers and feature folders?

References

Read next