Deciding where frontend code lives — tier model, move mechanics, extension points, side effects, naming, and the traps. Use when moving code between modules, carving new modules, fixing boundary violations, adding a barrel or an endpoint, or reviewing module-shape decisions.
Install
npx skillscat add metabase/metabase/fe-modularization Install via the SkillsCat registry.
Frontend modularization
The module is the unit of blast radius: dependency direction, test selection,
bundle cones, and review scope all follow module boundaries. Every decision
here serves one goal: make the file locations tell the truth about who owns
what and who may depend on whom.
Hard rules (check these before anything else)
- NEVER add an
export … fromre-export at an old path, and NEVERexport *
from a barrel. Move the code, codemod every call site, delete the old path,
all in the same PR. - NEVER import past a module's
index.tswhen that module hasenforcePublicApi: true, and never pastmetabase/ui's index at all. If
the barrel is heavy, the fix is making the module side-effect free (below),
not a deep import. - NEVER rewrite a consumer to a lower-level idiom to make the linter pass. If
the fix makes the consumer worse, the plan is wrong. - NEVER give a new module
enforceSharedTiers: false; new modules ship
enforced withenforcePublicApi: trueand an explicitindex.ts. - NEVER move code without the consumer-tier check (procedure step 2).
- ALWAYS delete a module's
enforceSharedTiers: falseline in the PR that
takes it to zero violations. - ALWAYS measure
bun run module-boundariesbefore and after, and report the
numbers. - ALWAYS import
dayjsfrommetabase/dayjs, routing frommetabase/router,
Mantine frommetabase/ui, react-redux hooks frommetabase/redux; the
raw specifiers are lint-forbidden.
Files that are ground truth
| what | where |
|---|---|
module elements, tiers, enforceSharedTiers, enforcePublicApi |
frontend/lint/module-boundaries.mjs |
| shared sub-tiers and levels, cluster rules | frontend/lint/shared-tiers.mjs |
| standalone boundaries lint (all violations, incl. grandfathered) | bun run module-boundaries (config eslint.config.module-boundaries.mjs) |
| PR lint (only enforced modules fail CI) | bun run lint-eslint-pure |
| side-effect-free directories and their exceptions | frontend/build/shared/rspack/side-effect-free-modules.js (SIDE_EFFECT_FREE_PATHS, SIDE_EFFECT_PATHS) |
| the lint rules that enforce that promise | metabase/no-module-side-effects, metabase/no-base-api-access in frontend/lint/eslint-plugin-metabase/rules/ |
| public-api enforcement | metabase/enforce-module-public-api rule, driven by getPublicApiModules() |
The model
Tiers: lib < basic < shared < feature < app. Imports point downward, never
sideways at feature tier (features may not import features; app and EE may
import anything). The shared tier is subdivided in shared-tiers.mjs into
shared-utils (U0…), shared-platform (P0…), and shared-domain, each ordered
into levels. A module imports only strictly lower levels of its sub-tier
plus the sub-tiers below; same-level peers are deliberately forbidden, which
is what makes cycles structurally impossible. A domain may hold two seats
when consumers demand it: a low core and a high surface (metabase-lib below
questions below query_builder; viz-core below visualizations).
Enforcement is per module: enforceSharedTiers: false on an element exempts
it from the level rules (counted by bun run module-boundaries, invisible to
PR lint). Modules with enforcePublicApi: true may only be imported from
outside via metabase/<module>, and import relatively inside.
Deciding where code lives (apply in order; first decisive test wins)
- Who owns the concept? Code lives with the module that owns its
concept, not the module that renders or calls it. A mode encoding dashboard
click behaviour is dashboard code even if viz executes it. Metabot
conversation state is metabot state even though the store registers it. - Consumer-tier check. The destination must sit at or below the file's
lowest consumer. A single consumer below the proposed home vetoes the
move; report it, don't force it. - Only four legitimate fixes for a bad edge: move the code to its
terminal home; invert through a designed extension point (a prop, an
injected component, a contract type owned by the socket); fix the tier
placement when the declared level is wrong; delete a thin wrapper (a rename
with no derivation isn't an abstraction — deleting it is the migration). - A bridge lives above the lower endpoint. Code that needs both sides of
a boundary belongs in or above the higher module. - "Genuinely shared" is proven by consumers. A shared address needs two
or more consumers the tier rules can't serve another way. One consumer
pretending to be shared is a feature module at the wrong address.
Extension points
The socket's owner defines the contract; every plug lives with its extender.
Visualization owns the mode prop and click interfaces, each surface supplies
its mode; the editor owns its extension contract, metabot supplies tiptap
extensions; querying owns TemplateTagsSidebarProps, parameters implements
it.
- Prefer plain props/injection at composition sites.
PLUGIN_*registries are
for edition gating; single-reader slots move out to their reading module;
slots with many readers live in the module that owns the concept (every
slot the whitelabel plugin fills lives inwhitelabel), tier permitting.
Keep contracts type-light so implementations can load lazily. - Identical injection at every callsite is acceptable until a fourth consumer
or second slot appears; then the composition deserves its own module above
both parts. Don't pre-build the wrapper.
Store shape
A module that has redux state owns it:
store/is private: reducer, plain creators/action types, and selectors,
typed against the module's own state (QueryBuilderStoreState), never the
globalState. Thunks live inactions/and importstore/, never the
reverse.- The barrel is the state API: selectors for reads, creators/thunks for
writes. - Store roots (
reducers-main,reducers-public, the SDK store) import each
slice from its module barrel and derive their own state type; no slice
registry, nodeclare modulemerging. - Cross-module writes: a callback prop when a composition point exists;
otherwise dispatch the owner's exported creator; an event only when the
emitter must not know its listeners. Rawdispatch({ type: "…" })strings
are banned. A reducer or listener matching another module's internal
action is the coupling to remove. - Slices that mirror one endpoint get deleted (the RTK cache is the state); a
fold over many sources with one writer (the metadata store) is kept as a
module-private slice behind the barrel.
Endpoints
Endpoints migrate to their owning module via Api.injectEndpoints from the
module's api/ file (settings, metabot, transforms precedents); metabase/api
trends toward the client only. There is one Api object per backend because
tag invalidation only works within one instance. Consumers reach endpoints
only through the owner's exports (useGetCardQuery, cardApi.endpoints.…),
never by name through the base object; metabase/no-base-api-access allows
injection only in metabase/api and **/api/** / **/api.ts, and reaching by
name only in metabase/api and test support (invalidateTags /resetApiState are fine anywhere). Each endpoint move carries its
metadata-store hookup: the owner calls entitiesFetched from its ownonQueryStarted.
Import-time side effects
Directories in SIDE_EFFECT_FREE_PATHS are marked sideEffects: false for
rspack, so importing one export from their barrel no longer drags the whole
barrel into a bundle. The price: nothing in those directories may do work at
import time, or production silently drops it. metabase/no-module-side-effects
enforces this over every listed directory (module-scope calls, writes to
imported objects or globals, bare imports, control flow at import; escape
hatches are /* #__PURE__ */ and the rule's pure-callee list). Files that
must run at import are listed in SIDE_EFFECT_PATHS (files or directories).
- A library the app configures gets a facade module owning the vendor
import, the registrations, and the types, with the raw specifier
lint-forbidden outside it:metabase/router,metabase/ui,metabase/redux,metabase/dayjs. Facades are the effect, so they never
enterSIDE_EFFECT_FREE_PATHS. Never re-register a vendor plugin locally
(dayjs.extend,echartsuse). - A patch a component needs is an explicit registration module,
bare-imported by the provider that needs it and listed inSIDE_EFFECT_PATHS(Mantine'sPopover.Dropdownreplacement, imported byThemeProvider). - Effects with no value to import (global CSS, polyfills, the CSP nonce,
EE plugin bootstrap) live in the entry's side-effects file and nowhere else. - Registries are filled from the composition root at boot
(initializePlugins,registerVisualizations), never by a module
registering itself at import. - RTK injection stays at import; when a module with an
api/folder is
declared side-effect free, list<module>/api/inSIDE_EFFECT_PATHS.
Naming
The feature keeps the plain product-surface name; a shared carve is named for
what the shared part actually is: the concept when the concept is the shared
thing (settings, current-user, whitelabel, dayjs), or a qualified
capability when the plain noun is taken (search-ui, metrics-ui,viz-core, data-studio-ui). Never two modules distinguished only by tier
prefix; never generic -shared / -feature suffixes.
Procedure for a move
- Enumerate consumers honestly (hook-name greps miss the last three):
plus sibling files importing the moved file relatively. Classify each bygrep -rn "metabase/<module>" frontend/src enterprise/frontend/src frontend/test e2e grep -rn "jest.mock(\"metabase/<module>" frontend enterprise/frontend grep -rn "addMatcher(.*<endpoint>" frontend/src enterprise/frontend/src
module and tier frommodule-boundaries.mjs/shared-tiers.mjs. - Consumer-tier check: destination at or below the lowest consumer. If
not, stop and report the blocking consumer. - Measure before:
bun run module-boundaries 2>&1 | tail -1on the base
(write the number down). - Move:
git mv; codemod every importer tometabase/<module>(outside)
or a relative path (inside); add named exports to the destination barrel
for every symbol an outside consumer needs (each must have a real
consumer). Delete the old path. If the module didn't exist: add its element
inmodule-boundaries.mjs(ordered before any element it's carved from,
first match wins), its level inshared-tiers.mjs,enforcePublicApi: true, anindex.ts. If the move empties a module's violations, delete
itsenforceSharedTiers: falseline. If the module has anapi/folder
and is side-effect free, list<module>/api/inSIDE_EFFECT_PATHS. - Verify (all of these, report the numbers):
grep -rn "<old path>" frontend enterprise/frontend e2e # must be empty bunx eslint --no-warn-ignored <touched files> bun run lint-eslint-pure # enforce-module-public-api included bun run type-check-pure bun run test-unit-keep-cljs <touched folders' specs> bun run module-boundaries 2>&1 | tail -1 # after; no violation may name the new files bun run build-release:static-viz # only if viz/static-viz/ui/dayjs touched; hard 3.5 MiB budget - Report: count before/after (state count-neutral explicitly), the edges
removed by file, flags/rules deleted, barrel exports added, anything you
skipped and why, and any consumer you found that vetoed part of the move.
Traps (each has bitten at least once)
- Late binding. A bare function reference in a
createSelectorinput
array, a component identity captured for comparison, aPLUGIN_*object
read at module scope: all capture at import time and dodge jest spies, EE
overrides, and lazy loading. Read at call time ((_state) => fn()), or
inject. - Plans go stale. Verify every recipe against current code before
executing; things get merged, renamed, or turn out never to have existed.
Skip-and-report beats force. - The linter's blind spots:
export … fromre-exports, bare-specifier
packages (custom-viz), pre-seeded caches that never register tags. A grep
for the old path is the stronger claim than a green lint run. - Path-keyed baselines reset on rename: a generated per-file ledger
silently loses a file's history when it moves. - A heavy barrel is a bundle hazard until its module is side-effect free.
One constant imported from themetabase/uibarrel pulled Mantine into the
static-viz bundle and broke its budget; the fix wasSIDE_EFFECT_FREE_PATHS,
never a deep import. - Type augmentation is program-wide.
declare module "dayjs"(and
similar) makes plugin types available to any file in the same tsconfig, so
a passing type-check never proves the setup ran; only importing the facade
does. - Environment before diagnosis: after a dependency-touching merge,
bun installbefore declaring a failure real; worktrees share staleness
through the node_modules symlink, and a staletarget/cljs_devmakestype-check-purefail onmetabase-lib/metric/core.ts(rebuild cljs in the
worktree).
PR shape
One logical change per PR; the body names the specific edges removed (files,
imports, before/after count) and gives each move's ownership reason in plain
language a reviewer outside the effort can follow, without campaign labels.
Stack only for semantic dependency or same-hot-file serialisation, and say
so. Consider codeowner surface when chunking. Open as a draft until measured
and green.