Catalog npm
PACKAGE EXPLORERView in registry

4layers

npm

Architecture layer checker for Vue.js / TypeScript apps. Enforces Presentation -> Application -> Domain <- Infrastructure boundaries at dev time, build time and in CI.

architectureboundariesclean-architectureddddependency-ruleshexagonallayerslintstatic-analysisvitevite-pluginvue
Versions collected4
Direct dependencies5From release 0.1.2
Last observed20 Oct, 20:12 UTC

README

Current package documentation

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:

LayerDefault globs
presentationsrc/components/**, src/views/**, src/pages/**, src/layouts/**, src/ui/**, src/presentation/**, **/*.vue
applicationsrc/application/**, src/stores/**, src/store/**, src/composables/**, src/queries/**, src/mutations/**, src/app/**
domainsrc/domain/**, src/entities/**, src/models/**, src/use-cases/**, src/usecases/**
infrastructuresrc/infrastructure/**, src/infra/**, src/api/**, src/http/**, src/services/**, src/repositories/**, src/adapters/**, src/storage/**, src/gateways/**
compositionsrc/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

RuleChecksclassic
layer-dependencyAn import crosses a layer boundary that canImport does not allow.error
package-boundaryA layer imports an external package its policy forbids.error
unassigned-fileA scanned file belongs to no layer.off
unresolved-importA relative or aliased specifier resolves to nothing.off
self-importA layer imports itself while allowSelfImports: false.off
unknown-layerA 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' });
PresetDependency edgesNotes
strictpresentation→application, application→domain, infrastructure→domainInfrastructure is injected, never imported. Domain imports no packages. Every file must have a layer.
classicpresentation→application+domain, application→domain+infrastructure, infrastructure→domainThe pragmatic default.
relaxedsame as classicEverything 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

Downloads & popularity

Awaiting observations

No metric observations yet

Download counts and popularity will appear here as the collector receives them.

Versions 4

Collected releases
VersionPublishedDirect dependenciesOpen version
0.1.2Registry latest13 Oct 20265
0.1.112 Oct 20265
0.1.012 Oct 20265
0.0.0-stage12 Oct 20260

Package advisories

0
No matching advisories collected

This describes our collected data; it is not a security assessment.

Repository

PENDING
lukamilikic/4layers

Shared repository data · GitHub

License detected in repository
Not collected

Metadata history

Meaningful changes
Current collected state Current

Observed since 20 Oct, 20:12 UTC

No superseded metadata states collected. Unchanged observations do not create new revisions.

Record identity

package:6c7083c84e913685bec62b1fce252203e8ab31dcea5426523f7c7b2a335fd731