Vibe Engineering
Tooling
6 min read
May 2025

Architecture you can't break

How we use ESLint to enforce module boundaries — so the architecture stays intact whether you're moving slowly or flying.

The problem with self-review

As a solo developer, code review is your weakest gate. The same mental model that produced the code is the one checking it. For structural decisions that compound over time — which layer can import which, which modules can reach where — you want a rule enforced by the linter, not by goodwill and attention.

The same is true in small teams where reviews happen fast. Architectural violations feel harmless in isolation. A shared component importing from a feature, just this once. A server action pulling from the app layer for convenience. Each one is a small thing. The accumulation is not.

The solution: linting import boundaries

eslint-plugin-project-structure is an ESLint plugin that enforces file structure and import boundaries at the linter level. Its independentModules feature lets you define named layers and specify exactly which other layers each one can import from. Violations are lint errors, not suggestions — red underlines in your editor, blocks in CI.

Installation is one line. Configuration lives in a dedicated independentModules.mjs file that gets imported by your flat ESLint config. The separation keeps the architecture definition readable and editable without touching the rest of your ESLint setup.

npm install -D eslint-plugin-project-structure

The three-layer architecture

Our codebase is divided into three layers with a strict import hierarchy. Lower layers can't import from higher ones. Features can't import from each other. Actions live inside the shared layer — they follow the same import rules and are importable by both features and the app layer.

┌──────────────────────────────────────────────────┐
│  APP LAYER  ·  src/app/**                        │
│  Can import: shared + all features               │
└──────────────────────────────────────────────────┘
                          ▲
┌──────────────────────────────────────────────────┐
│  FEATURES LAYER  ·  src/features/{name}/**       │
│  Can import: shared + own feature only           │
│  Cannot import: other features                   │
└──────────────────────────────────────────────────┘
                          ▲
┌──────────────────────────────────────────────────┐
│  SHARED LAYER                                    │
│  src/lib  ·  components  ·  hooks  ·  db         │
│  contexts  ·  types  ·  actions                  │
│  Can import: other shared modules only           │
└──────────────────────────────────────────────────┘

The critical constraint is feature isolation. Every feature module — src/features/auth, src/features/booking, and so on — is a closed unit. It can use shared utilities, but it cannot reach into another feature. This keeps each feature independently understandable: to know what a feature does, you only read that feature.

The configuration

Here's the actual independentModules.mjs from this project, slightly simplified:

import { createIndependentModules } from 'eslint-plugin-project-structure';

export const independentModulesConfig = createIndependentModules({
  pathAliases: {
    baseUrl: '.',
    paths: { '@/*': ['src/*'] }
  },
  debugMode: false,
  modules: [
    // Shared layer — can only import from other shared modules
    {
      name: 'shared-components',
      pattern: 'src/components/**',
      allowImportsFrom: ['{sharedLayer}'],
      errorMessage: 'Shared components can only import from other shared modules.'
    },
    // Actions — can only import from shared
    {
      name: 'actions',
      pattern: 'src/actions/**',
      allowImportsFrom: ['{sharedLayer}'],
      errorMessage: 'Actions can only import from shared modules.'
    },
    // Features — two rules because {dirname} vs {family} behave
    // differently at root vs nested depth
    {
      name: 'feature-root-file',
      pattern: 'src/features/*/*',
      allowImportsFrom: ['{sharedLayer}', '{dirname}/**', 'src/actions/**'],
      errorMessage: 'Features can only import from shared and their own folder.'
    },
    {
      name: 'feature-nested-file',
      pattern: 'src/features/*/**',
      allowImportsFrom: ['{sharedLayer}', '{family}/**', 'src/actions/**'],
      errorMessage: 'Features can only import from shared and their own folder.'
    },
    // App layer — can import everything
    {
      name: 'app-layer',
      pattern: 'src/app/**',
      allowImportsFrom: ['{sharedLayer}', 'src/features/**', 'src/actions/**', 'src/app/**']
    }
  ],
  reusableImportPatterns: {
    sharedLayer: [
      'src/lib/**',
      'src/components/**',
      'src/hooks/**',
      'src/contexts/**',
      'src/db/**',
      'src/types/**',
      'src/actions/**'
    ]
  }
});

A few things worth noting:

  • reusableImportPatterns lets you define named groups (like {sharedLayer}) referenced across all module rules.
  • Features need two separate rules: feature-root-file uses {dirname}, while feature-nested-file uses {family}. These resolve differently at different folder depths — using the wrong one causes false positives.
  • The pathAliases key must match your tsconfig.json paths exactly, or the plugin won't resolve aliased imports correctly.

Wiring it into ESLint

The plugin connects to your flat ESLint config in a single rule entry. We also run Prettier as an ESLint rule (via eslint-plugin-prettier), so one eslint --fix handles both formatting and import violations:

import { projectStructurePlugin } from 'eslint-plugin-project-structure';
import { independentModulesConfig } from './independentModules.mjs';
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals';
import prettierPlugin from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';

export default [
  ...nextCoreWebVitals,
  prettierConfig,
  {
    files: ['**/*.ts', '**/*.tsx', '**/*.mjs'],
    plugins: {
      'project-structure': projectStructurePlugin,
      prettier: prettierPlugin
    },
    rules: {
      'prettier/prettier': 'error',
      'project-structure/independent-modules': ['error', independentModulesConfig]
    }
  }
];

What a violation looks like

If a shared component tries to import from a feature folder — something that happens naturally when you're moving fast and just need the thing to work — you get this:

// src/components/ui/my-button.tsx
import { useGuestChat } from '@/features/text-ai-chat/hooks/use-guest-chat';
//                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// ESLint: Shared components can only import from other shared modules.
//
// Fix: move the logic to a shared hook, or move this component into the feature.

The error surfaces immediately in the editor (with ESLint integration) and on every npm run lint run. The custom errorMessage per module tells you exactly what the constraint is, not just that something is wrong.

Why this matters for AI-assisted development

AI models don't know your layer boundaries. When generating code, they'll import whatever looks right for the task — pulling a feature hook into a shared component, reaching across feature boundaries for something similar. Without enforcement, these violations accumulate silently. With it, they're caught before they leave your editor.

This is what vibe engineering looks like in practice: not slowing down AI assistance, but adding guardrails that preserve the architectural properties you care about, even when neither you nor the model is paying close attention to them in the moment.

The last gate: lint in CI

The editor catches violations the moment they appear. But there's still a gap: if you push without running npm run lint first, the violation reaches the repository. CI seals that gap.

The GitHub Actions workflow we run on every push and pull request includes a lint step that executes before the build. Any import boundary violation — any architectural rule broken, no matter how small — fails the pipeline and blocks the merge. Broken architecture cannot reach main, and it cannot reach production.

This is the full loop: the linter flags it immediately in the editor, the local habit catches it before the push, and CI is the unconditional backstop. No violation slips through because someone was moving fast or skipped a step.

Watch: the concept in depth

This approach was directly inspired by Web Dev Simplified's breakdown of feature-based architecture. His video is what made the pattern click for me, and it shaped how I structured this codebase. If the concepts above resonated, the video goes even deeper. Credit where it's due.

Have a project in mind?

Book a free 30-minute call. No commitment, just a conversation.

Book a free 30-min call

Prefer to write? Send us a message — we respond within 24 hours.