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
- Boundaries (this part): the repo structure and the import rules the tooling enforces.
- The pipeline (part 1): from source to a cached byte on a CDN, and the gates in between.
- Testing (part 2): which kinds, how many, at what cost, and how to keep them green.
- Observability (part 3): what the client reports and how it is attributed.
- Scale shapes (part 4): monorepos, microfrontends, design-system distribution, ownership.
- 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
- shared: knows nothing about the business: the UI kit, utilities, the API client, config.
- entities: the nouns (user, account, transaction): types, API calls, small UI (a transaction row), selectors.
- features: the verbs a user does (transfer-money, mark-reviewed, filter-transactions): a form, its state, its request.
- widgets: composed blocks (the transactions table with its filters and bulk actions).
- pages: routes that assemble widgets. app: providers, the router, global styles.
the rule, and the segments
- 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.
- 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.
- 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).
- 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 importthree rules worth failing a build on
- 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.
- 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.
- 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
- 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.
- 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).
- 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
- 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).
- 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. - 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).
- 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
- Deprecate, then remove: mark an export
@deprecatedwith the replacement named; a lint rule warns on use; remove when the graph shows zero importers. The dependency graph is the usage count. - 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.
- 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.