Part 0 · 4 chapters · ~30 min

Engineering a Frontend

Architecture as the decisions that are expensive to change, feature-sliced design with its one import rule, the dependency graph as a CI artefact with the three rules worth failing a build on, and the public surface of a module: what to export, what to hide, and how to change it.

1

What architecture is, for a frontend

Architecture is the set of decisions that are expensive to change: where code lives and what may import what; how it is built, shipped and cached; how it is tested and observed; who owns which part. None of them is visible in a screenshot and all of them decide whether the tenth engineer can ship on their first week and whether the millionth user gets a page in two seconds.

the decisions this course covers
  1. Boundaries (this part): the repo structure and the import rules the tooling enforces.
  2. The pipeline (part 1): from source to a cached byte on a CDN, and the gates in between.
  3. Testing (part 2): which kinds, how many, at what cost, and how to keep them green.
  4. Observability (part 3): what the client reports and how it is attributed.
  5. Scale shapes (part 4): monorepos, microfrontends, design-system distribution, ownership.
  6. What breaks (parts 5 to 7): at a hundred thousand, a million, a hundred million users, as incident logs.
the test of a decision
Ask what it costs to reverse. A component's internals cost an afternoon; a state library costs a quarter; a repo split or a microfrontend boundary costs a year. Spend design time in proportion to the reversal cost, and write down the number that would make you reverse it.
2

Repo structure and feature-sliced design

the layers, bottom to top
  1. shared: knows nothing about the business: the UI kit, utilities, the API client, config.
  2. entities: the nouns (user, account, transaction): types, API calls, small UI (a transaction row), selectors.
  3. features: the verbs a user does (transfer-money, mark-reviewed, filter-transactions): a form, its state, its request.
  4. widgets: composed blocks (the transactions table with its filters and bulk actions).
  5. pages: routes that assemble widgets. app: providers, the router, global styles.
the rule, and the segments
  1. Downward only. A layer imports only from layers below it; a slice never imports a sibling slice. Something two features need moves down to entities or shared. That is how shared grows correctly and how a feature stays a deletable folder.
  2. Segments inside a slice: ui, model, api, lib, config. The index file is the public API; nothing outside imports a slice's internals. What is not exported does not exist to the rest of the app.
  3. Buys: deletable features, pages as lists of widgets, no cycles by construction, a findable home for everything. Costs: more folders and indexes, "entity or feature?" arguments (the answer is usually "lower"), duplication between siblings (cheaper than coupling).
  4. Range: from one engineer to a few dozen on one app. Below it, a flat src/; a component library is all shared; above it, one tree per app in a monorepo (part 4).
FEATURE-SLICED DESIGN: LAYERS, SLICES, SEGMENTS
a structure where the import direction is the rule
swipe the figure sideways, or tap expand for full screen
1/6
the layers
The layers, bottom to top: shared knows nothing about the business (a Button, a formatMoney, the fetch wrapper); entities are the nouns (user, account, transaction: their types, their API, their small UI like a transaction row); features are verbs a user does (transfer-money, mark-reviewed, filter-transactions); widgets compose entities and features into a block (the transactions table with its filters); pages assemble widgets per route; app wires providers, the router, global styles.
3

Boundaries the tooling enforces

code
// .dependency-cruiser.cjs: the boundary as a rule the build enforces. three rules, each fails CI
module.exports = {
  forbidden: [
    { name: 'no-circular', severity: 'error', from: {}, to: { circular: true } },
    { name: 'fsd-downward-only', severity: 'error', comment: 'a layer imports only from layers below it',
      from: { path: '^src/(shared)' },               to: { path: '^src/(entities|features|widgets|pages|app)' } },
    { name: 'fsd-entities',  severity: 'error', from: { path: '^src/entities' }, to: { path: '^src/(features|widgets|pages|app)' } },
    { name: 'fsd-features',  severity: 'error', from: { path: '^src/features' }, to: { path: '^src/(widgets|pages|app)' } },
    { name: 'fsd-widgets',   severity: 'error', from: { path: '^src/widgets' },  to: { path: '^src/(pages|app)' } },
    { name: 'no-sibling-slices', severity: 'error', comment: 'features do not import other features; move the shared thing down',
      from: { path: '^src/features/([^/]+)/' }, to: { path: '^src/features/([^/]+)/', pathNot: '^src/features/$1/' } },
    { name: 'public-api-only', severity: 'error', comment: 'import a slice through its index',
      from: { pathNot: '^src/(entities|features|widgets)/([^/]+)/' }, to: { path: '^src/(entities|features|widgets)/[^/]+/(?!index\\.ts$).+' } },
  ],
  options: { tsPreCompilationDeps: true, tsConfig: { fileName: 'tsconfig.json' }, exclude: 'node_modules' },
}
// package.json: "lint:deps": "depcruise src --config .dependency-cruiser.cjs"   (in CI next to eslint and tsc)
// tsconfig paths: "@shared/*": ["src/shared/*"], "@entities/*": ["src/entities/*"], … so the layer is in every import
three rules worth failing a build on
  1. No cycles. A imports B imports A: neither can be built, tested or deleted alone; the bundler emits them together. The report names the path; the fix moves the shared piece down or inverts the dependency with a prop or a slot.
  2. No forbidden edges. Upward (a widget importing a page), sibling (feature to feature), and deep (past an index). The rules file names the allowed (from, to) pairs; the error names the rule, which teaches it.
  3. Fan-in thresholds off the shared layer. A feature's helper with fourteen external importers is shared in disguise: infrastructure in the wrong place. The threshold catches it before it calcifies.
the graph as a number
  1. Pages with high fan-out are normal (they assemble); a widget importing twenty features is a god widget to split. The graph makes the smell reviewable.
  2. Generate on every PR; render as SVG into the PR when the delta is large; keep the rules beside the lint config. The same graph is the input to bundle analysis: what is in the entry chunk is a walk from the entry over it (part 1).
  3. Path aliases per layer (@entities/user) make the layer visible in every import, so a reviewer sees an upward import without opening the file.
the rule about rules
A boundary that only code review enforces is a wish; by the tenth contributor it is gone. If it matters, it fails CI, with a message that explains itself.
THE DEPENDENCY GRAPH AS A CI ARTEFACT
cycles, fan-in, fan-out, and the three rules worth failing a build on
swipe the figure sideways, or tap expand for full screen
1/6
the graph
The graph: nodes are modules, edges are imports; direction matters. A healthy FSD graph is a DAG that flows down the layers: pages at the top with high fan-out, shared at the bottom with high fan-in, nothing in between with both.
4

The public surface of a module

what a slice exposes, and what it hides
  1. Exports are a contract: a component, a hook, a type, a selector, an API function. Each export is something another slice may now depend on; removing it is a breaking change to every importer. Export the minimum; export types freely (they cost nothing at runtime and document the shape).
  2. Hide the store: a slice's state container is internal; expose hooks (useTransferDraft()) and actions, not the store instance. Importers cannot then couple to its shape, and the store can change library without a migration across the app.
  3. Hide the API shape: a slice's api segment returns domain types, not raw responses; the mapping from the wire to the domain lives in one place (the Big-company FE course's BFF makes this a server concern too).
  4. Side-effect-free modules: a module that runs code on import (registering a global, starting a timer, reading localStorage) is not tree-shakeable and surprises every importer. Export functions that do the thing; call them from app.
versioning inside one repo
  1. Deprecate, then remove: mark an export @deprecated with the replacement named; a lint rule warns on use; remove when the graph shows zero importers. The dependency graph is the usage count.
  2. Codemods for wide changes: a rename across forty importers is a jscodeshift or ts-morph script, run once, reviewed as one PR. Editing forty files by hand is how the rename stays half done.
  3. The index as a changelog: a slice's public API changes are visible as diffs to one file; review them like an API.
what the next part does with this
The graph of these public surfaces is what the bundler walks. A tight public API and side-effect-free modules are what make tree shaking and code splitting work; part 1 shows the bytes they save.