An enforced architecture for enterprise React. Any module can be thrown overboard — delete its folder, strip its registration lines — and the application still compiles, builds and runs. CI proves it on every push.
Live demo · The chapters · Decisions and their costs · The agent skill
jettison (v.) — to throw cargo overboard, deliberately, to keep the ship flying.
Most React architectures are described, agreed on, and then violated one convenient import at a time. Six months later the diagram in the wiki describes a codebase that no longer exists.
Two positions follow from that.
An architecture that is not enforced is a suggestion. Every rule a linter can check ships as an error, next to the rationale for it. The rest are labelled review-enforced where they are stated, because a rule whose status goes unwritten is the one that quietly becomes advice.
Modularity must be falsifiable. "Loosely coupled" is not a property you assert, it is one you test. So CI deletes each module in turn and requires the rest of the app to build without it.
| Sounds familiar | What answers it |
|---|---|
| "I touched billing and checkout broke." | Layers. Imports flow one way, and crossing them fails lint. |
| "We can't remove this feature, nobody knows what depends on it." | The jettison test. Every module is provably removable. |
| "Where does this file go?" — answered differently in every review | One module shape, and folders that appear only when earned. |
| A 300-line component whose business rules need a mounted app to test | A fixed split between rendering, orchestration and decisions. |
| The wiki page that described the codebase two years ago | Rules ship as errors, and a test suite asserts each one still fires. |
| "It works on the edit screen but the list doesn't update." | Mutations own their cache effects; cross-module sync travels as events. |
| "It works fine" — until someone tries the wizard without a mouse | Every accessibility rule the linter ships, at error; reachability asserted in a browser. |
| An agent that writes a module a day and reads the conventions once | A skill that carries them, with the unlintable half marked unlintable. |
Four layers, and imports flow one way: app → modules → shared → core.
| Layer | What lives there | May import |
|---|---|---|
app |
The shell that composes: router, store, providers, layouts | everything below |
modules |
A business capability, whole: routes, screens, endpoints, services, state | shared, core |
shared |
Business-agnostic and reusable: UI kit, event vocabulary, utils | core |
core |
Infrastructure with no domain knowledge: API client, cache utils, config | nothing above it |
Three corollaries do most of the work:
- Modules never import each other. If two need the same thing it moves down to
sharedorcore— or gets duplicated, which is often cheaper than a coupling. - A module is reachable only through its
index.ts. Everything behind that door is private and refactorable without repo-wide impact. - Only
appcomposes. The router knows which modules exist. No module knows the shell does.
Generated from the real import graph, and CI fails if the committed copy is stale. The hatched cells are the imports the architecture forbids; a number appearing there would show up here in red.
Every module has the same internal shape, and no folder exists before it is needed:
modules/<name>/
├── index.ts # public API: routes, and nothing else unless deliberate
├── routes.tsx # the module's route tree, screens lazy-loaded
├── api/ # the endpoints this module owns, and their cache effects
├── screens/ # one folder per routed screen, composition only
├── features/ # self-contained chunks of behaviour
├── components/ hooks/ services/ state/
└── types.ts constants.ts
Inside a component the split is fixed: views render, hooks orchestrate, services decide. A .tsx file never fetches, dispatches or navigates. A service is plain TypeScript with no React and no store, which is why the logic that loses money is the part with unit tests.
| # | Chapter | Claim |
|---|---|---|
| 1 | Layers & the jettison test | Code flows one way, and every module is jettisonable |
| 2 | Module anatomy | Every module has the same shape; features are mini-modules |
| 3 | The component pattern | Views render, hooks orchestrate, services decide |
| 4 | The data layer | One client, module-owned endpoints, cross-module sync as events |
The chapters name no library. The concrete choices, and what each one costs, are in docs/adr/.
oxlint.config.ts holds the whole boundary system as one annotated config: layers, module privacy, view and service restrictions, the type-evidence rules (anti-slop by Dillon Mulroy, vendored under MIT the way upstream asks to be), and every accessibility rule the linter ships. The two rules no linter ships live in tools/oxlint/jettison/, 125 lines of rule code and the reasoning around it, because a layer is a path prefix and so is an alias.
fixtures/ keeps one deliberately violating file per rule, with a Vitest suite that fails if a rule stops firing. A boundary config that matches nothing looks exactly like one that is satisfied.
e2e/ drives the console in a real browser for the claims that live in the interaction and nowhere else: a submitted release surviving the refetch that would clobber it, the same journey in ?cache=naive landing on a board without it, a withdrawal crossing module boundaries as an event, and a popup that stays open under a press a hand would make. Each spec was verified to fail when the behaviour it names is removed — a green browser test that cannot go red is the same lie as a lint rule matching nothing.
The jettison test runs a matrix job per module: delete the folder, run unregister-module.mjs, require type-check and build to pass without it.
skills/jettison/ states the same rules to the author who now writes most of the code and reads the least documentation. It is explicit about which half a linter catches and which half it cannot — a service arriving without its test, a view-model that left a useMemo in the view, a cache write into another module's queries — because a rule an agent cannot verify is one it will confidently violate. One command to install.
npm i && npm run devThen break a rule. Add any of these and run npm run lint:
import { useSelector } from 'react-redux'in any screen or component — a view never touches the storeimport { releaseEditorRoutes } from '@modules/release-editor'insidecatalog— modules may not import each otherimport { pipelineStage } from '@modules/catalog/services/release-status'from anywhere outsidecatalog— a module is consumed only through itsindex.ts<div onClick={open}>Open</div>in any screen — a control the keyboard cannot reach is not shipped
Then break the claim the name makes:
rm -rf src/modules/analytics
node scripts/unregister-module.mjs analytics
npm run type-check && npm run build # still green, without a module
git restore . && git clean -fd src- Declare the layers. Four aliases in
tsconfig.jsonand your bundler:@app/*,@modules/*,@shared/*,@core/*. Aliases make every cross-layer import recognisable, which is what lets a rule target it. - Copy the enforcement.
oxlint.config.tsandtools/oxlint/jettison/. Change the alias-to-folder map and the rules follow your layout. In a migration only adopted folders are classified, so legacy code stays untouched until it moves. - Keep a violating fixture. This is the step people skip and the one that matters.
- Add the jettison test. Wrap each registration line in a
// jettison:…marker region; the script strips them mechanically.
You do not need the rest of the stack — RTK Query, MSW, shadcn, nuqs, or oxlint itself. The layers, the module shape, the component pattern and the jettison test are the architecture; this repo is one implementation of it.
It answers the placement questions before a file exists, writes services before views, and carries the lint plugin and config in assets/ — so "adopt Jettison here" wires up steps 1 to 4 above instead of reciting them.
/plugin marketplace add kkatsi/jettison-react # then: /plugin install jettison@kkatsi
Or npx skills add kkatsi/jettison-react for any other agent.
It names no library, and CI checks the copies it carries against the originals in this repo — a shipped skill that has drifted from the architecture it describes is worse than none.
The console of Low Orbit Records, a fictional indie label: a release wizard with drafts and asynchronous audio processing, catalogue and distribution management kept consistent across modules, streaming analytics. Its backend is a service worker that answers slower than it writes, because a demo that hides eventual consistency proves nothing. Add ?cache=naive to the demo to watch Chapter 4's failure happen on purpose.
Same journey, same click, two cache strategies: one board is missing the release that was just submitted, and stays missing until something else happens to refetch. Chapter 4 is why.
Scope, stated plainly: long-lived enterprise SPAs. No SSR, no RSC, by thesis. Authentication and permissions are out of scope too — the mock backend carries a simulated session and no roles, which is why the one place a real token would be refreshed says so in a comment instead of doing it.
The starting point is bulletproof-react — feature folders, unidirectional flow, colocation — extended where enterprise codebases actually bleed: enforcement, falsifiable modularity, and governance that survives turnover. These rules are a clean-room generalization of conventions that ran in a 1,300-file production enterprise SPA: no code, docs or domain specifics were carried over. What is here was rebuilt to be publishable, and to be checkable.
MIT licensed. Built by Kostas Katsinaris — senior frontend engineer.

