Skip to content

About

An enforced architecture for enterprise React: any module can be thrown overboard and the app keeps flying — CI proves it on every push.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Latest commit

 

History

124 Commits

Folders and files

Repository files navigation

Jettison

CI Jettison test

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.

Why

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.

What it solves

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.

The architecture

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:

  1. Modules never import each other. If two need the same thing it moves down to shared or core — or gets duplicated, which is often cheaper than a coupling.
  2. A module is reachable only through its index.ts. Everything behind that door is private and refactorable without repo-wide impact.
  3. Only app composes. The router knows which modules exist. No module knows the shell does.

A matrix of every import in src/: importer down the side, imported across the top. The hatched regions, where a module imports another module or a layer imports one above it, are empty.

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.

The chapters

# 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/.

How it is enforced

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.

Try it

npm i && npm run dev

Then 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 store
  • import { releaseEditorRoutes } from '@modules/release-editor' inside catalog — modules may not import each other
  • import { pipelineStage } from '@modules/catalog/services/release-status' from anywhere outside catalog — a module is consumed only through its index.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

Adopting it

  1. Declare the layers. Four aliases in tsconfig.json and your bundler: @app/*, @modules/*, @shared/*, @core/*. Aliases make every cross-layer import recognisable, which is what lets a rule target it.
  2. Copy the enforcement. oxlint.config.ts and tools/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.
  3. Keep a violating fixture. This is the step people skip and the one that matters.
  4. 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.

The agent skill

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 reference application

The Low Orbit Records console: a catalogue of releases with stat tiles, filters and a dense table.

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.

Two browsers side by side after the same submit. In events mode the release is the first row on the distribution board; in naive mode the same board has one row fewer and no release.

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.

LinkedIn · GitHub · Live demo

About

An enforced architecture for enterprise React: any module can be thrown overboard and the app keeps flying — CI proves it on every push.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages