Layer Architecture
In blueprint: this page documents the presets'
architectureblock — the one part of the philosophy that compiles into hard gates, not just prose: the generated ESLint config and inspect's findings. Declare your own layers inblueprint.config.mjsand the same machinery enforces them.
One-way dependency flow + single responsibility per layer. The principles are framework-neutral; the unit mapping is one-to-one: composable ↔ hook, context ↔ Context, SFC ↔ function component, service ↔ api client.
pages/views → containers → components → hooks → services → assets/i18n
⇢ contexts (Provider only)
hooks ⇢ contexts (Context only, selfOnly)
contexts → servicesWhy one-way
- Each layer keeps a single responsibility (a hook that never imports a component stays reusable logic)
- Ownership is visible at a glance — no repo-wide grep
- Refactors are safe: moving a file across layers makes lint flag every illegal call site
- Adding a dependency edge means editing the blueprint — "should this layer really depend on that one?" surfaces in review
The layers
| Layer | Responsibility | Must not |
|---|---|---|
pages | Page layout + assembling containers; routes, SEO | hold business logic, stack components directly |
containers | One feature: assembly + business logic + CRUD; stateful, calls services, drives navigation | — |
components | Reusable, presentational UI; may call hooks | touch the router, call services, own app state |
hooks | inject/useContext live only here; adapts server/shared state; stores (Pinia/Zustand) are private objects of this layer | expose a raw store |
contexts | provide/createContext live only here; exposes Context/Provider | — |
services | Network primitives; the only importer of axios, the only caller of fetch/WebSocket | 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:
{ 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 (vuestays importable everywhere — onlyinjectis fenced) { global }owns a global (fetch,WebSocket) — no import statement exists, so this half is enforced by lint (no-restricted-globals), notinspect- package ownership lands twice: lint (
no-restricted-imports) and inspect'spackage-ownershipfinding
Feature folder — one module, one folder
components/
└─ Dropdown/
├─ index ← the only public entry
├─ Dropdown ← implementation, named after the module (never "Component")
├─ hooks ← private
├─ styles ← private
└─ types ← privateindexis the module's face — the outside world knows nothing else- Private sub-components live inside (a container's
ProfileTab); promotion tocomponents/happens when sharing actually arrives, not speculatively - The implementation file carries the module's name — a tab bar of ten
Component.tsxis unnavigable
components vs containers in one question: "would it survive a feature swap?" Reusable and data-blind → component. Bound to this feature's data, flow, CRUD → container. Containers wire components to data; components know nothing about containers.
