Observed since 20 Oct, 20:12 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
Architecture layer checker for Vue.js / TypeScript apps. Enforces Presentation -> Application -> Domain <- Infrastructure boundaries at dev time, build time and in CI.
Keep your frontend layers honest — at dev time, at build time, and in CI.
A layered architecture only works while everyone respects the arrows. One
import axios from 'axios' in a component, one import { useCart } from '@/stores/cart'
in a repository, and the boundaries quietly stop existing.
4layers checks the boundaries for you:
┌─────────────────────────────────────────────────────────────────┐
│ presentation components, views, layouts, user interaction │
│ │ │
│ ▼ │
│ application stores, composables, routing, queries │
│ │ │
│ ▼ │
│ domain ◄── entities, use cases, pure business rules │
│ ▲ │
│ │ │
│ infrastructure HTTP, storage, SDKs, edge functions │
└─────────────────────────────────────────────────────────────────┘
Around them sits a fifth, small composition layer: the router and the app
entry point (src/router/**, src/main.ts). That is where the layers get
wired together, so it may import all of them, and nothing may import it back.
A router has to reference its views; this is where that is legal.
4layers check for CI, with GitHub annotations and JSON output..vue SFCs understood natively, works with
plain JS, TS, JSX and TSX too.src/components, src/stores, src/domain and src/api mean.npm install --save-dev 4layers
npx 4layers check
That is already useful with no config at all: the classic preset plus the
conventional Vue directory layout. By default only files under src/ are
scanned.
4layers 0.1.1 · classic preset
✖ infrastructure → application layer-dependency
src/api/cart.ts:4:1 '@/stores/cart'
fix infrastructure may import: domain. Infrastructure should expose data and let application call it, not the other way round.
✖ presentation → infrastructure layer-dependency
src/components/CartView.vue:13:1 '@/api/cart'
fix presentation may import: application, domain. Put the call in an application-layer store or composable and let the component consume that.
✖ presentation → axios package-boundary
src/components/CartView.vue:15:1 'axios'
fix Move "axios" behind the application layer so the component only talks to state and use cases.
✖ 3 errors · 0 warnings · 7 files · 13 imports · 24ms
Violations are grouped by layer edge, so ten components calling the same API
module show up as one block with ten locations. In a color terminal each layer
has its own color and every path:line:col is underlined, so most editors and
terminals make it clickable. With NO_COLOR, outside a TTY, or with a non
UTF-8 locale, the same report uses plain ASCII symbols (x, ->, #).
Add --stats for a per-layer overview, printed before the violations:
LAYER FILES IMPORTS SHARE VIOLATIONS
────────────────────────────────────────────────────────────
presentation 2 6 ████████████████ 2
application 1 2 ██████░░░░░░░░░░ 0
domain 1 0 ░░░░░░░░░░░░░░░░ 0
infrastructure 1 2 ██████░░░░░░░░░░ 1
composition 2 3 ████████░░░░░░░░ 0
SHARE compares the imports each layer makes, scaled to the busiest one.
Write a config when your layout differs:
npx 4layers init
Add the plugin to vite.config.ts. It runs with enforce: 'pre', so .vue
files are checked as real SFCs before @vitejs/plugin-vue takes them apart.
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import fourLayers from '4layers/vite';
export default defineConfig({
plugins: [
vue(),
fourLayers({
// dev: 'overlay' (default) | 'warn' | 'off'
// build: 'error' (default) | 'warn' | 'off'
}),
],
});
During vite dev a violation is logged and raised through Vite's error
overlay, pointing at the exact line. During vite build the whole project is
checked once and the build fails. Set dev: 'warn' while you are migrating.
A file belongs to the layer whose most specific matching glob claims it —
not the first one declared. That is what lets the default preset give every
**/*.vue file to the presentation layer while an explicit src/domain/**
still wins for a .vue file that happens to live in the domain.
Defaults from the built-in presets:
| Layer | Default globs |
|---|---|
presentation | src/components/**, src/views/**, src/pages/**, src/layouts/**, src/ui/**, src/presentation/**, **/*.vue |
application | src/application/**, src/stores/**, src/store/**, src/composables/**, src/queries/**, src/mutations/**, src/app/** |
domain | src/domain/**, src/entities/**, src/models/**, src/use-cases/**, src/usecases/** |
infrastructure | src/infrastructure/**, src/infra/**, src/api/**, src/http/**, src/services/**, src/repositories/**, src/adapters/**, src/storage/**, src/gateways/** |
composition | src/router/**, src/routes/**, src/main.{ts,js,mts,mjs}, src/bootstrap/**, src/composition/** — may import every layer |
Not sure where a file landed? Ask:
npx 4layers explain src/stores/cart.ts
src/stores/cart.ts
layer: application (matched src/stores/**)
may import: domain, infrastructure
role: Application-specific orchestration: state, routing, workflows, data fetching.
status: clean
Aliases are picked up automatically from tsconfig.json/jsconfig.json
paths and from Vite's own resolve.alias, so @/stores/cart resolves the
same way your bundler resolves it.
| Rule | Checks | classic |
|---|---|---|
layer-dependency | An import crosses a layer boundary that canImport does not allow. | error |
package-boundary | A layer imports an external package its policy forbids. | error |
unassigned-file | A scanned file belongs to no layer. | off |
unresolved-import | A relative or aliased specifier resolves to nothing. | off |
self-import | A layer imports itself while allowSelfImports: false. | off |
unknown-layer | A canImport entry names a layer that does not exist. | warn |
Layer rules stop internal imports from crossing boundaries. Package policies stop the outside world from leaking in — which is how you actually keep a domain layer framework-agnostic:
layers: {
domain: {
// Nothing from node_modules at all.
packages: { allow: [] },
},
presentation: {
// No HTTP clients in components.
packages: { deny: ['axios', 'ky', '@supabase/*', 'firebase'] },
},
}
allow is a whitelist (set it and everything else is rejected), deny is a
blacklist, and deny wins. Patterns match both the package name and the full
specifier, so @firebase/* and firebase/firestore both work.
export default defineConfig({ extends: 'classic' });
| Preset | Dependency edges | Notes |
|---|---|---|
strict | presentation→application, application→domain, infrastructure→domain | Infrastructure is injected, never imported. Domain imports no packages. Every file must have a layer. |
classic | presentation→application+domain, application→domain+infrastructure, infrastructure→domain | The pragmatic default. |
relaxed | same as classic | Everything reported as warnings, type-only imports ignored. Good for adoption. |
4layers.config.ts, .mts, .mjs, .js, .cjs, .json, .4layersrc.json
or a "4layers" key in package.json. Everything is optional and merged over
the preset.
import { defineConfig } from '4layers';
export default defineConfig({
extends: 'classic',
include: ['src/**/*.{js,ts,jsx,tsx,vue}'],
exclude: ['**/*.spec.*', '**/__tests__/**'],
// Merged over the preset: list only what differs.
layers: {
presentation: {
files: ['src/components/**', 'src/views/**', '**/*.vue'],
exclude: ['src/components/__fixtures__/**'],
canImport: ['application', 'domain'],
packages: { deny: ['axios', '@supabase/*'] },
description: 'Dumb components. Props in, events out.',
},
application: {
files: ['src/stores/**', 'src/composables/**'],
canImport: ['domain', 'infrastructure'],
},
domain: {
files: ['src/domain/**'],
canImport: [],
packages: { allow: ['zod', 'date-fns'] },
},
infrastructure: {
files: ['src/api/**', 'src/storage/**'],
canImport: ['domain'],
},
// Extra layers are fine — the four are just the default.
shared: {
files: ['src/shared/**'],
canImport: [],
},
},
// Usually inferred from tsconfig paths; override when needed.
aliases: { '@': 'src' },
// 'check' (default) holds `import type` to the same rules; 'ignore' exempts it.
typeOnlyImports: 'check',
allowSelfImports: true,
severity: {
'layer-dependency': 'error',
'package-boundary': 'error',
'unassigned-file': 'warn',
},
});
canImport: ['*'] allows every layer. canImport: [] allows none — that is
the domain layer's whole point.
For the import you have to live with today:
// 4layers-disable-next-line layer-dependency
import { legacyFetch } from '@/api/legacy';
// 4layers-disable-next-line
import { other } from '@/api/other';
And for a file you will deal with later:
// 4layers-disable-file
4layers-ignore-next-line and 4layers-ignore-file work the same way.
- run: npx 4layers check --format github
--format github emits workflow commands, so every violation annotates the
line in the pull request diff. The command exits 1 when there are errors
(or when warnings exceed --max-warnings), 2 on a usage or config error.
Adopting on an existing codebase? Start permissive and tighten:
npx 4layers check --preset relaxed --max-warnings 120
npx 4layers graph > docs/layers.mmd # mermaid
npx 4layers graph --format dot | dot -Tsvg -o docs/layers.svg
The graph shows the declared edges with their real usage counts, and dashes the illegal edges it actually found:
flowchart TD
presentation["presentation"]
application["application"]
domain["domain"]
infrastructure["infrastructure"]
presentation --> |34| application
presentation --> |12| domain
application --> |51| domain
application --> |20| infrastructure
infrastructure --> |18| domain
presentation -. "1 illegal" .-> infrastructure
4layers check [files...] Check the project (default command)
4layers graph Print the layer dependency graph
4layers explain <file> Show which layer owns a file, and why
4layers init Write a starter 4layers.config.mjs
--root <dir> Project root (default: cwd)
--config <file> Path to a config file
--preset <name> strict | classic | relaxed (overrides config)
--format <name> pretty | compact | json | github | mermaid | dot
--max-warnings <n> Fail when warnings exceed n
--stats Print the per-layer table before the report
--quiet Only print the summary
--no-hints Hide the "how to fix" hints
import { check, createChecker, resolveConfig } from '4layers';
const result = await check({ root: process.cwd() });
console.log(result.errorCount, result.edges);
// Or check a single buffer, e.g. from an editor extension.
const checker = createChecker(resolveConfig({ extends: 'strict' }, { root: process.cwd() }));
const violations = checker.checkFile('src/components/Cart.vue', source);
Also exported: createResolver, createLayerIndex, scanImports,
walkProject, createMatcher, every formatter, and all the types.
Imports are found by scanning source text with comments masked out and, for
.vue files, only inside <script> blocks — no AST, no parser dependency,
fast enough to run on every keystroke-save. That is a deliberate trade:
import(someVariable)) cannot be judged, so they are
skipped rather than guessed at.Type-only imports are detected (import type, and named clauses where every
specifier is type-prefixed) and, by default, held to the same rules — a
presentation component importing an infrastructure DTO type is still coupling.
Set typeOnlyImports: 'ignore' if you disagree.
4layers check --changed against a git ref, for large monorepos.See CONTRIBUTING.md. npm install && npm test is the whole
setup; the package has no runtime dependencies and intends to keep it that way.
Download counts and popularity will appear here as the collector receives them.
| Version | Published | Direct dependencies | Open version |
|---|---|---|---|
| 0.1.2Registry latest | 13 Oct 2026 | 5 | |
| 0.1.1 | 12 Oct 2026 | 5 | |
| 0.1.0 | 12 Oct 2026 | 5 | |
| 0.0.0-stage | 12 Oct 2026 | 0 |
This describes our collected data; it is not a security assessment.
Observed since 20 Oct, 20:12 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:6c7083c84e913685bec62b1fce252203e8ab31dcea5426523f7c7b2a335fd731