Skip to content
Development
Skill

/fe-modularization

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.

From plugin
metabase
49k31 skills11 agents23 commands
Install
$ npx -y skills add metabase/metabase --skill fe-modularization --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/fe-modularization

Context preview

The summary Claude sees to decide when to auto-load this skill.

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.

SKILL.md

fe-modularization.SKILL.md
name: fe-modularization
description: 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.

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 … from` re-export at an old path, and NEVER `export *`

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.ts` when that module has

`enforcePublicApi: true`, and never past `metabase/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 with `enforcePublicApi: true` and an explicit `index.ts`.

  • NEVER move code without the consumer-tier check (procedure step 2).
  • ALWAYS delete a module's `enforceSharedTiers: false` line in the PR that

takes it to zero violations.

  • ALWAYS measure `bun run module-boundaries` before and after, and report the

numbers.

  • ALWAYS import `dayjs` from `metabase/dayjs`, routing from `metabase/router`,

Mantine from `metabase/ui`, react-redux hooks from `metabase/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)

1. **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. 2. **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. 3. **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). 4. **A bridge lives above the lower endpoint.** Code that needs both sides of a boundary belongs in or above the higher module. 5. **"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 in `whitelabel`), 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,
Read more
Ships withmetabase

Metabase is the easy, open-source way for everyone in your company to ask questions and learn from data.

Get the whole plugin

Other skills on metabase.