Skip to content

Layer Architecture ​

In blueprint: this page documents the presets' architecture block — the one part of the philosophy that compiles into hard gates, not just prose: the generated ESLint config and inspect findings. Declare your own layers in blueprint.config.mjs and the same machinery enforces them.

One-way dependency flow + single responsibility per layer. That inner discipline is shared by both supported topologies:

  • layer-first: Layer → Unit;
  • module-first: Module → Layer → Unit for ordinary modules.

The canonical Vue and React presets are layer-first by default; given modules, the same presets project this doctrine onto module-first. Module-first adds an outer domain dependency graph; it does not replace the layer doctrine inside each ordinary module.

Why one-way ​

  1. Each layer keeps a single responsibility (a hook that never imports a component stays reusable logic)
  2. Ownership is visible at a glance — no repo-wide grep
  3. Refactors are safe: moving a file across layers makes lint flag every illegal call site
  4. Adding a dependency edge means editing the blueprint — "should this layer really depend on that one?" surfaces in review

The outer module boundary ​

In module-first, every ordinary module repeats the shared layer contract below. A module's dependsOn edges determine which other modules it can reach, including transitive dependencies. An import must satisfy both that outer graph and the inner one-way layer flow.

The optional reserved app module is different: it represents recursive router composition at the container position. It is not an ordinary domain module and does not repeat the shared layers. This exception keeps router ownership explicit without weakening the module/layer model for application features.

In a module-first preset, pages and containers below are positions rather than repeated layers: route composition belongs to the reserved app module, and each module's root files take the container position. The remaining layers repeat inside every ordinary module.

The layers ​

pages

  • Does — page layout, assembling containers; routes, SEO
  • Must not — hold business logic, stack components directly

containers

  • Does — one feature: assembly, business logic, CRUD; stateful, calls services, drives navigation

components

  • Does — presentation UI, including feature-private UI with a single consumer; may call hooks and own ephemeral presentation state
  • Must not — touch the router, call services, own application or use-case state, orchestrate a use case

hooks

  • Does — inject/useContext live only here; adapts server/shared state; stores (Pinia/Zustand) are private objects of this layer
  • Must not — expose a raw store

contexts

  • Does — provide/createContext live only here; exposes Context/Provider

services

  • Does — network primitives; the only importer of axios, the only caller of fetch/WebSocket
  • Must not — contain UI or business logic

There is no stores layer and no utils layer. A store has a single owner hook — its public face; other features read through that hook. And utils/ is a cohesion-free junk drawer that grows without bound until everything imports it — pure functions get homes by ownership instead: module-private files, or a named, domain-scoped module.

Ownership — owns ​

The "only importer of axios" cells above are not prose — they compile. A layer declares the primitives it exclusively owns, and every other layer is barred from them:

js
{ name: 'services', owns: ['axios', { global: 'fetch' }, { global: 'WebSocket' }] },
{ name: 'hooks',    owns: [{ package: 'vue', imports: ['inject'] }, 'pinia'] },
  • a bare string owns a whole package; { package, imports } narrows it to specific named imports (vue stays importable everywhere — only inject is fenced)
  • { global } owns a global (fetch, WebSocket) — no import statement exists, so this half is enforced by lint (no-restricted-globals), not inspect
  • package ownership lands twice: lint (no-restricted-imports) and inspect's package-ownership finding

Feature folder — one unit, one folder ​

components/
└─ Dropdown/
   ├─ index        ← the only public entry
   ├─ Dropdown     ← implementation, named after the unit (never "Component")
   ├─ hooks        ← private
   ├─ styles       ← private
   └─ types        ← private
  • index is the unit's face — the outside world knows nothing else
  • Private sub-components may stay inside their owning unit (a container's ProfileTab). A presentation unit may also live in components/ with a single consumer. Reuse determines whether to promote it to a shared/public location, not its layer role
  • The implementation file carries the unit's name — a tab bar of ten Component.tsx is unnavigable

components vs containers is a responsibility decision: "does it present UI, or own the use case?" A feature-specific display, such as a MatchScoreCard, remains a component even with one consumer. Its vocabulary and consumer count do not make it a container. A component may own ephemeral presentation state, such as an expanded section, hover state, or an open menu. Application state, workflow transitions, CRUD, and navigation belong to the container position. Containers wire components to data; components do not own the container's use case.

In module-first, a feature-private presenter lives under its owner's components layer, not at the module root, and needs no cross-module public export. In layer-first, components may retain feature ownership without a shared barrel export.