Exercise: Architectural Linting
Practice enforcing module boundaries and writing a custom ESLint rule that catches project-specific violations.
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:
git clone https://github.com/stevekinney/enterprise-ui-workshop
cd enterprise-ui-workshop
pnpm installPart 1: Module Boundary Rules
The repo has a monorepo structure with several packages. Your goal is to configure eslint-plugin-boundaries to enforce that:
- Application code (
apps/*) can import from packages but not from other apps - Packages can import from
packages/sharedbut not from other packages (unless there's an explicit dependency) - Nothing imports from
*/internal/*paths
Step 1: Install the plugin:
pnpm add -D eslint-plugin-boundariesStep 2: Define your element types. Open eslint.config.js and add:
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:
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:
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:
console.warn('something went wrong');
console.error(new Error('oops'));Explore the AST. Note that console.warn(...) is a CallExpression where:
calleeis aMemberExpressioncallee.object.nameis"console"callee.property.nameis"warn"or"error"
Step 2: Create the rule file:
mkdir -p packages/eslint-rules/rules
touch packages/eslint-rules/rules/use-logger.js// 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:
// packages/eslint-rules/index.js
module.exports = {
rules: {
'use-logger': require('./rules/use-logger'),
},
};Step 4: Register and enable the rule:
// 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.
Keep reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.