4layers

Architecture layer checker for Vue.js / TypeScript apps. Enforces Presentation -> Application -> Domain <- Infrastructure boundaries at dev time, build time and in CI.
417 1
Install
npm install 4layers
Latest Version:0.1.2
License:MIT
Last Updated:Oct 13, 2026
Links: GitHub  ·  npm
Maintainer: lukamilikic

4layers

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.

  • Vite plugin — a forbidden import fails the build, and shows up in the dev overlay the moment you save.
  • CLI — 4layers check for CI, with GitHub annotations and JSON output.
  • Zero runtime dependencies, .vue SFCs understood natively, works with plain JS, TS, JSX and TSX too.
  • Convention over configuration — the default preset already knows what src/components, src/stores, src/domain and src/api mean.

Install

npm install --save-dev 4layers

Quick start

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

Use it with Vue

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.

How a file gets its layer

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.

Rules

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

Package boundaries

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.

Presets

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.

Configuration

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.

Escape hatches

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.

CI

- 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

Architecture diagrams

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

CLI reference

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

Programmatic API

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.

How it works, and what it does not do

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:

  • An import written inside a template literal could produce a false positive.
  • Dynamic specifiers (import(someVariable)) cannot be judged, so they are skipped rather than guessed at.
  • It checks module boundaries, not semantics: it cannot tell you that a domain entity is secretly holding a DOM node.

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.

Roadmap

  • ESLint flat-config plugin, for editor squiggles without Vite.
  • 4layers check --changed against a git ref, for large monorepos.
  • Nuxt module.
  • Per-file dependency graph output, not just per-layer.

Contributing

See CONTRIBUTING.md. npm install && npm test is the whole setup; the package has no runtime dependencies and intends to keep it that way.

License

MIT

Related Packages

nuxt-layerscope

Linter for Nuxt layer boundaries that sees auto-imports

2,773 4
@vitejs/plugin-vue

The official plugin for Vue SFC support in Vite.

333,357,380 686
vite-plugin-checker

Vite plugin that runs TypeScript type checker on a separate process.

133,975,562 1,242
@vitejs/plugin-vue-jsx

Provides Vue 3 JSX & TSX support with HMR.

93,663,827 686
vite-plugin-ssr

Like Next.js/Nuxt but as do-one-thing-do-it-well Vite plugin.

550,634 18

Version History

Version Released License
0.1.2 MIT
0.1.1 MIT
0.1.0 MIT
Pre-releases (1)
Version Released License
0.0.0-stage