A16 · Part 3 · Advanced
Architecture at scale
Organising by feature, drawing boundaries, and the decisions that are hard to reverse.
08On this page
Organise by feature, not by kind
// ✗ every change touches five folders
components/ hooks/ utils/ types/ api/
// ✓ a feature is one folder, deletable in one move
src/
features/
checkout/
components/ hooks/ api.ts types.ts index.ts
catalogue/
shared/
ui/ hooks/ lib/
app/
Grouping by kind means a feature is scattered across the tree and nobody can tell what belongs to what. Grouping by feature means the answer to "can I delete this?" is visible, and a new person can be pointed at one folder.
The boundary rules that stop it decaying
- A feature may import from
shared/. It must not import from another feature's internals. - If two features need the same thing, it moves to
shared/— a deliberate promotion, not a shortcut import. - Each feature exposes a small
index.ts. Everything else is private.
// eslint-plugin-boundaries or an import/no-restricted-paths rule
"no-restricted-imports": ["error", {
patterns: ["@/features/*/!(index)"] // only the public entry
}]
Conventions decay; lint rules do not. One rule that fails the build is worth more than a paragraph in a README nobody reads twice.
Layers, not just folders
UI renders and handles interaction. Domain holds the rules — pricing, permissions, validation — as plain functions with no React in them. Data talks to the network. The value of separating them is that the domain becomes testable without rendering anything, and swapping REST for GraphQL touches one layer.
The dependency direction
app → features → shared // ✓ one direction, always
shared/ must never import from features/. The moment
it does, everything depends on everything, imports go circular, and the build
graph stops meaning anything. This is the rule worth enforcing above all
others.
Monorepos
| Worth it when | Not worth it when |
|---|---|
| Several apps share a component library | One app |
| Types are shared between frontend and backend | Nothing is shared |
| An atomic change must cross packages | Teams release independently on purpose |
The cost is real: CI gets slower without task caching, tooling gets more complicated, and versioning between internal packages becomes a decision somebody has to own. A workspace with three packages is fine; adopt Turborepo or Nx when the CI time makes you.
Micro-frontends: usually the wrong answer
Splitting a frontend into independently deployed applications solves an organisational problem — several teams that cannot coordinate releases. It costs you a shared runtime, duplicated dependencies, cross-app routing and state, and a debugging story that spans repositories.
If you have one team, you do not have the problem it solves. Module federation is not a way to make a large codebase feel smaller.
Decisions that are expensive to reverse
| Decision | Why it is hard to undo |
|---|---|
| Framework and rendering model | Touches every file and every deployment assumption |
| Styling approach | Every component; and runtime CSS-in-JS blocks Server Components |
| State model | Migrating a store threads through every consumer |
| Folder structure and boundaries | Every import; and imports encode assumptions |
| The data layer's shape | Every component that fetches |
Spend real time on these five. Everything else — which date library, which icon set, whether a component takes three props or four — is cheap to change and not worth a meeting.
Write the reasons down
docs/decisions/
0003-server-components-for-the-catalogue.md
One short file per significant decision: the context, the options, the choice, and the trade accepted. Six months later, when someone asks why the app is not using the obvious thing, the answer exists — and if the context has changed, the decision can be revisited on purpose rather than drifted away from.
Guardrails over discipline
- Lint the import boundaries so a violation fails CI.
- Budget the bundle in CI, so a regression is caught the day it lands.
- Enforce the type checker on every push, with
stricton. - Codeowners on
shared/, because that is where accidental coupling enters.
Every one of these replaces a rule people are expected to remember with one a machine checks.
Interview answer, one sentence
"Organise by feature so a feature is one deletable folder, keep the dependency direction one-way — app to features to shared, never back — and enforce that with a lint rule rather than a convention. Then spend the real thinking on the five decisions that are expensive to reverse, and write down why each was made."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series