architecture
Capture the modular (bulletproof-react) frontend architecture - module/feature layering, the Component -> Hook -> Repository -> API flow, dependency-cruiser import boundaries, and path-alias conventio
SKILL.mdplugins/react-frontend-sdlc/skills/architecture/SKILL.md
GitHubArchitecture
This skill captures the frontend architecture for a modular, bulletproof-react
codebase: which directories exist, how a feature is layered, and which boundaries are
enforced as dependency-cruiser errors. Every concrete path is resolved through the
profile (architecture.source_root, architecture.modules, architecture.component_prefix,
architecture.path_aliases) rather than hardcoded — the same rules hold whether the
source root is a feature-module SPA or a flat component library.
Profile keys consumed
architecture.source_rootarchitecture.modulesarchitecture.component_prefixarchitecture.path_aliasesframework.stateframework.dimake.lint_depsmake.formatmake.lintquality.depcruise_violations
Layered architecture (the one flow to internalize)
Every feature follows the same Component → Hook → Repository → API flow. The repository hides which backend service answered; the hook owns UI logic and state; the component only renders.
flowchart TD
api["API / BFF<br/>[Software System]"]
repository["Repository<br/>[features/<f>/repositories]"]
provider["Data provider / transformer<br/>[adapts repository output]"]
feature["Feature view<br/>[Component]"]
hook["Hook<br/>[features/<f>/hooks/use-*]"]
store["Store<br/>[framework.state]"]
child["Child components<br/>[presentation]"]
feature --> hook
feature --> child
hook --> provider
hook --> repository
hook --> store
provider --> repository
repository --> api
Read the layers top to bottom:
- Components (
features/<feature>/components/) — presentation only. They consume hooks and render children; they never import repositories, the module store, or the shared services layer directly. - Hooks (
features/<feature>/hooks/use-*.ts) — UI logic, state access (framework.state), and the only place that calls repository APIs and dispatches mutations. - Data providers / transformers — adapt repository output to the shape the UI needs. They live next to the repository they wrap.
- Repositories (
features/<feature>/repositories/) — the only layer allowed to talk to the shared HTTP client under<architecture.source_root>/services/. Exposed through theirindexfile; internal files are private. - API gateway / backend — the frontend never calls a microservice directly. It calls the project's backend (a BFF / API gateway), and the repository layer hides which service answered. Each backend owns its own storage and cache — implementation details the frontend must not encode.
Boundary rules (dependency-cruiser, severity error)
These come from the repository's dependency-cruiser config and run on every PR via the
target mapped by make.lint_deps. Internalize them before placing a file. Rule names are
the stack-generic bulletproof-react boundary set; a given repo enables the subset that
matches its layout.
Module and feature isolation:
no-cross-module-imports— one module importing another module's internals.no-cross-feature-imports— sibling features in the same module importing each other. Use the module's sharedhooks/,lib/,store/,types/, orutils/.no-components-import-modules— sharedcomponents/*depending on any module.
Repository boundary:
no-repository-internal-imports— reaching past a repository'sindexfile.no-repositories-to-ui-hooks— repositories depending on featurecomponents/,hooks/,routes/, or modulehooks//store/.no-feature-direct-http-client— anything outsiderepositories/importing the shared HTTP client in<architecture.source_root>/services/.no-store-direct-http-client— modulestore/importing the shared HTTP client.
UI layering:
no-feature-ui-to-services— featurecomponents/,hooks/,routes/importing the services layer.no-components-to-repositories— components importing repositories directly (go through a hook).no-components-to-store— components importing the module store directly. Exception: hook files (use-*.ts).no-store-to-feature-ui— module store importing featurecomponents/,hooks/,routes/.no-lib-to-features— modulelib/depending on its ownfeatures/.
DI containment (applies when framework.di is set):
no-tsyringe-outside-di-and-repositories— DI-container imports outside the composition root, repositories, services, stores, error utils, and module store mappers.no-di-config-import-outside-composition-root— importing the DI composition-root config outside the app entry points and the permitted store files.
Folder shape:
module-allowed-folders— module root may only containconfig,features,hooks,lib,store,types,utils.feature-allowed-folders— feature root may only containassets,components,hooks,i18n,repositories,routes,types,utils.feature-hooks-file-convention— files insidefeatures/*/hooks/must beindex.*oruse-<kebab>.*.
Type-only files:
type-files-imported-as-type-only—types.tsandtypes/folders may only be imported withimport type.type-files-no-runtime-imports— type-only files must not depend on runtime modules.
Naming:
no-uppercase-paths,src-module-name-kebab-case,src-feature-name-kebab-case— no uppercase letters in any source or test path.
When the target mapped by make.lint_deps reports a violation, the fix is almost always to
introduce or use the missing layer (a hook, a repository public re-export), never to
silence the rule. Treat the boundary as binding even on a repo that ships no
dependency-cruiser config.
Degrade rule (capability absent)
- If
make.lint_depsisnull, the automated boundary check is absent — note the skip and apply these rules as a manual review discipline before commit; fall back to the targets mapped bymake.formatandmake.lintinstead of inventing adependency-cruiserinvocation. - If
architecture.modulesis empty (a flat component library with no<source_root>/modules), the module/feature isolation rules do not apply — the layout reduces to the shared component layer plus the repository boundary. Place primitives under<architecture.source_root>/components/and keep any API access behind a repository.
Placement decision
digraph placement {
"New work" [shape=doublecircle];
"Used by 2+ modules without module-specific copy?" [shape=diamond];
"Belongs to one feature?" [shape=diamond];
"Shared by 2+ features in the same module?" [shape=diamond];
"Calls an external API?" [shape=diamond];
"Holds module state?" [shape=diamond];
"components/<prefix>-*" [shape=box];
"modules/<m>/features/<f>/components or hooks" [shape=box];
"modules/<m>/lib or hooks" [shape=box];
"features/<f>/repositories (new) or module-level lib" [shape=box];
"modules/<m>/store" [shape=box];
"New work" -> "Used by 2+ modules without module-specific copy?";
"Used by 2+ modules without module-specific copy?" -> "components/<prefix>-*" [label="yes"];
"Used by 2+ modules without module-specific copy?" -> "Belongs to one feature?" [label="no"];
"Belongs to one feature?" -> "Calls an external API?" [label="yes"];
"Calls an external API?" -> "features/<f>/repositories (new) or module-level lib" [label="yes"];
"Calls an external API?" -> "Holds module state?" [label="no"];
"Holds module state?" -> "modules/<m>/store" [label="yes"];
"Holds module state?" -> "modules/<m>/features/<f>/components or hooks" [label="no"];
"Belongs to one feature?" -> "Shared by 2+ features in the same module?" [label="no"];
"Shared by 2+ features in the same module?" -> "modules/<m>/lib or hooks" [label="yes"];
"Shared by 2+ features in the same module?" -> "components/<prefix>-*" [label="no"];
}
When the placement is non-obvious, stop and re-read the boundary rules — the violation is usually telling you the layer you skipped.
Frontend module catalog
Every module is a directory under <architecture.source_root>/modules/<kebab>/, and its
name appears in architecture.modules. Names are lowercase kebab-case (enforced by
src-module-name-kebab-case). Shared UI lives in <architecture.source_root>/components/
under the architecture.component_prefix (e.g. UI → a ui-button/index.tsx exporting
UIButton).
Module composition (a parent module mounting a sub-module) is the only place
sub-module imports are allowed. Everything else must go through modules/<m>/lib/,
store/, types/, or utils/, or through the public exports of another module — never a
deep import past a module or repository boundary.
A module root holds only config, features, hooks, lib, store, types, utils;
a feature root holds only assets, components, hooks, i18n, repositories, routes,
types, utils. Each feature view pairs with a typed DTO in its types/ folder
— the DTO is the shape the repository returns, and the component never sees the raw API
response.
Path aliases
Follow the bulletproof-react import convention; the configured aliases are listed in
architecture.path_aliases and wired into the TypeScript config, the bundler, and Jest.
./Xfor same-folder imports.- A project-wide alias (e.g.
@/...) for cross-folder / cross-feature imports. - An optional feature-scoped alias (e.g.
@auth/...) for a deeply nested feature subtree, so imports stay readable and within the line-length budget. Use the feature-scoped alias whenever the target lives under that feature, regardless of where the importer lives. - Avoid deep relative chains like
../../../X— reach for an alias instead.
Verification
make lint-deps # the target mapped by make.lint_deps (dependency-cruiser)
Run the target mapped by make.lint_deps. Treat any error as the architecture telling you
something is in the wrong layer: fix the import path or move the file. The
quality.depcruise_violations ceiling is fixed at 0 — never weaken the
dependency-cruiser config to make a violation pass.
For broader checks before commit, run the targets mapped by make.format then make.lint.
# Upstream reference profile values this skill reads:
architecture:
source_root: src
modules: [catalog, checkout] # >=1 module dir under <source_root>/modules; [] = flat library
component_prefix: UI # ui-button/index.tsx exports UIButton
path_aliases: ["@/", "@auth/"] # project-wide alias + feature-scoped alias
framework:
state: zustand
di: tsyringe
make:
lint_deps: lint-deps # or lint-dep-cruiser; null = no automated boundary check
format: format
lint: lint
quality:
depcruise_violations: 0 # fixed ceiling
Common mistakes
no-feature-direct-http-clientfires — a component or hook is calling the HTTP client itself. Move the call into a repository and consume it through a hook.no-components-to-storefires on a component — it imports the store directly. Expose state through ause-*.tsselector hook and import that.no-cross-feature-importsfires — one feature reaches into a sibling feature'scomponents/orhooks/. Promote the shared code tomodules/<m>/lib/orhooks/.feature-hooks-file-conventionfires — a non-hook file (helper, types) was placed insidehooks/. Move it toutils/,types/, orlib/.module-allowed-foldersfires on a stray folder — an old layout is still in use. Rename to one of the allowed module folders (lib/,utils/).- A repository imports from
hooks/orcomponents/— mapping logic crept into the wrong layer. Move the mapping into the repository or a dedicated transformer. - Need to share state across modules — wrong layer. Modules are isolated by design; lift to a shared store or expose a hook from the owning module.
Related skills
- code-organization — kebab-case naming, module / feature folder lists, file placement and naming.
- frontend-component-development — how to build the component and hook layers.
- complexity-management — when a boundary fix forces a file split to stay under the metrics gate.
- observability-instrumentation — where to add web-vitals, structured logs, and error reporting along the layered flow.
- code-review — what to flag when a PR crosses an architectural boundary.