Exercise: Architectural Linting

Practice enforcing module boundaries and writing a custom ESLint rule that catches project-specific violations.

March 22, 20265 min read3 / 3

Two goals here: get eslint-plugin-boundaries running on a real module structure, and write a custom rule that catches something specific to the project.

Setup

Clone the workshop repo and install dependencies:

Bash
git clone https://github.com/stevekinney/enterprise-ui-workshop cd enterprise-ui-workshop pnpm install

Part 1: Module Boundary Rules

The repo has a monorepo structure with several packages. Your goal is to configure eslint-plugin-boundaries to enforce that:

  1. Application code (apps/*) can import from packages but not from other apps
  2. Packages can import from packages/shared but not from other packages (unless there's an explicit dependency)
  3. Nothing imports from */internal/* paths

Step 1: Install the plugin:

Bash
pnpm add -D eslint-plugin-boundaries

Step 2: Define your element types. Open eslint.config.js and add:

JavaScript
import boundaries from 'eslint-plugin-boundaries'; export default [ { plugins: { boundaries }, settings: { 'boundaries/elements': [ { type: 'app', pattern: 'apps/*' }, { type: 'feature', pattern: 'packages/features/*' }, { type: 'shared', pattern: 'packages/shared' }, { type: 'ui', pattern: 'packages/ui' }, ], }, rules: { 'boundaries/element-types': ['error', { default: 'disallow', rules: [ { from: 'app', allow: ['ui', 'feature', 'shared'] }, { from: 'feature', allow: ['ui', 'shared'] }, { from: 'ui', allow: ['shared'] }, { from: 'shared', allow: [] }, ], }], }, }, ];

Step 3: Run ESLint and see what it catches:

Bash
pnpm eslint .

You'll likely find some existing violations. Don't fix them all — just understand what they represent. The point is to see the rule working.

Step 4: Add the no-restricted-imports rule to ban internal paths:

JavaScript
rules: { 'no-restricted-imports': ['error', { patterns: [ { group: ['*/internal/*', '**/internal/**'], message: 'Use the public API. Internal paths are not part of the public contract.', }, ], }], }

Part 2: Writing a Custom Rule

The repo uses a custom logger utility at packages/shared/src/logger.ts. Your job is to write a rule that flags any direct use of console.warn or console.error in production code and suggests using the logger instead.

Step 1: Open AST Explorer (astexplorer.net), set the parser to @typescript-eslint/parser, and paste in:

TypeScript
console.warn('something went wrong'); console.error(new Error('oops'));

Explore the AST. Note that console.warn(...) is a CallExpression where:

  • callee is a MemberExpression
  • callee.object.name is "console"
  • callee.property.name is "warn" or "error"

Step 2: Create the rule file:

Bash
mkdir -p packages/eslint-rules/rules touch packages/eslint-rules/rules/use-logger.js
JavaScript
// packages/eslint-rules/rules/use-logger.js module.exports = { meta: { type: 'suggestion', docs: { description: 'Use the shared logger instead of console.warn/error', }, messages: { useLogger: 'Use logger.{{ method }}() from @myapp/shared instead of console.{{ method }}().', }, }, create(context) { const bannedMethods = ['warn', 'error']; return { CallExpression(node) { if ( node.callee.type === 'MemberExpression' && node.callee.object.type === 'Identifier' && node.callee.object.name === 'console' && node.callee.property.type === 'Identifier' && bannedMethods.includes(node.callee.property.name) ) { context.report({ node, messageId: 'useLogger', data: { method: node.callee.property.name, }, }); } }, }; }, };

Step 3: Create the plugin entry point:

JavaScript
// packages/eslint-rules/index.js module.exports = { rules: { 'use-logger': require('./rules/use-logger'), }, };

Step 4: Register and enable the rule:

JavaScript
// eslint.config.js import localRules from './packages/eslint-rules/index.js'; export default [ { plugins: { '@myapp': { rules: localRules.rules } }, rules: { '@myapp/use-logger': 'warn', }, }, ];

Run ESLint and see your rule in action.

Stretch Goals

Add an auto-fix: Update the use-logger rule to automatically replace console.warn(...) with logger.warn(...) and add the import if it's missing. The fixer.replaceText API is what you want.

Make it smarter: Allow console.warn in test files (files matching **/*.test.ts). Use context.getFilename() to check the current file path and skip reporting if it matches.

Write a rule for a design decision you actually have: Think about a pattern in a codebase you work on that people violate regularly — a deprecated import, a wrong way to format dates, a utility that should be used instead of a raw API. Write the rule for that.

What You're Practicing

The mechanics here are the point, but the underlying skill is translating architectural decisions into automated enforcement. The rule you write for a codebase you actually work on will do more for your team's consistency than any number of style guides or wiki pages.

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.