Architectural Linting: Automated Protection Against Spaghetti Code
After the third architecture review in a month, I stopped arguing and started linting. “Why is the controller calling the repository directly?” This question came up in code review every week. Senior developers knew it violated layered architecture, but nobody enforced it systematically. Result? After two years - spaghetti code where every module imported everything.
Solution? Architectural rules as tests - automatically validated in CI.
Tested on: TypeScript monorepo with 50+ packages, Java 21 microservices. Dependency Cruiser 16.x, ArchUnit 1.2.x.
Why Code Review Isn’t Enough
- Subjective - every senior has a different view
- Inconsistent - depends on who reviews
- Doesn’t cover everything - large PRs get skimmed
- Reactive - you discover problems at review, not while writing
What We Want to Achieve
┌─────────────────────────────────────────────────────┐
│ Presentation │
│ (Controllers) │
│ ↓ │
├─────────────────────────────────────────────────────┤
│ Application │
│ (Services) │
│ ↓ │
├─────────────────────────────────────────────────────┤
│ Domain │
│ (Entities) │
│ ↓ │
├─────────────────────────────────────────────────────┤
│ Infrastructure │
│ (Repositories, External APIs) │
└─────────────────────────────────────────────────────┘
Rules:
- Controllers can only call Services
- Services can call Domain and Repositories
- Domain has no dependencies (except utilities)
- No circular dependencies
Dependency Cruiser for JavaScript/TypeScript
Installation
npm install --save-dev dependency-cruiser
npx depcruise --init
Basic Configuration
// .dependency-cruiser.js
module.exports = {
forbidden: [
// Forbid circular dependencies
{
name: 'no-circular',
severity: 'error',
from: {},
to: {
circular: true
}
},
// Controllers must not call repositories directly
{
name: 'no-controller-to-repository',
severity: 'error',
comment: 'Controllers must go through services',
from: {
path: '^src/controllers/'
},
to: {
path: '^src/repositories/'
}
},
// Domain has no dependencies on infrastructure
{
name: 'domain-independence',
severity: 'error',
comment: 'Domain layer must not depend on infrastructure',
from: {
path: '^src/domain/'
},
to: {
path: '^src/(controllers|repositories|infrastructure)/'
}
},
// Forbid orphan modules (unused)
{
name: 'no-orphans',
severity: 'warn',
from: {
orphan: true,
pathNot: [
'\\.d\\.ts$',
'\\.test\\.(ts|js)$',
'index\\.(ts|js)$'
]
},
to: {}
},
// Forbid importing from node_modules subpath
{
name: 'no-subpath-imports',
severity: 'error',
from: {},
to: {
path: 'node_modules/[^/]+/.+'
}
}
],
options: {
doNotFollow: {
path: 'node_modules'
},
tsConfig: {
fileName: 'tsconfig.json'
},
reporterOptions: {
dot: {
theme: {
graph: { splines: 'ortho' },
node: { shape: 'box' }
}
}
}
}
};
Rules for Monorepo
// For Turborepo/Nx monorepo
module.exports = {
forbidden: [
// Packages must not depend on apps
{
name: 'packages-no-app-dependency',
severity: 'error',
from: {
path: '^packages/'
},
to: {
path: '^apps/'
}
},
// Feature packages are isolated
{
name: 'feature-isolation',
severity: 'error',
from: {
path: '^packages/feature-(.+)/'
},
to: {
path: '^packages/feature-(?!\\1)',
pathNot: '^packages/(shared|ui|utils)/'
}
},
// Shared package has no side effects
{
name: 'shared-pure',
severity: 'error',
from: {
path: '^packages/shared/'
},
to: {
dependencyTypes: ['npm'],
pathNot: [
'node_modules/(lodash|date-fns|zod)/'
]
}
}
]
};
CI Pipeline Integration
# .github/workflows/architecture.yml
name: Architecture Check
on: [push, pull_request]
jobs:
dependency-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run dependency cruiser
run: npx depcruise src --config .dependency-cruiser.js
- name: Generate dependency graph
if: github.event_name == 'pull_request'
run: |
npx depcruise src --config .dependency-cruiser.js \
--output-type dot \
| dot -T svg > dependency-graph.svg
- name: Upload graph
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: dependency-graph
path: dependency-graph.svg
ArchUnit for Java/Kotlin
Setup
// build.gradle.kts
dependencies {
testImplementation("com.tngtech.archunit:archunit-junit5:1.2.1")
}
Architecture Tests
// src/test/java/architecture/LayeredArchitectureTest.java
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.library.Architectures;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
class LayeredArchitectureTest {
private static JavaClasses classes;
@BeforeAll
static void setup() {
classes = new ClassFileImporter()
.importPackages("com.example.app");
}
@Test
void layerDependenciesAreRespected() {
layeredArchitecture()
.consideringAllDependencies()
.layer("Controllers").definedBy("..controller..")
.layer("Services").definedBy("..service..")
.layer("Domain").definedBy("..domain..")
.layer("Repositories").definedBy("..repository..")
.whereLayer("Controllers").mayNotBeAccessedByAnyLayer()
.whereLayer("Services").mayOnlyBeAccessedByLayers("Controllers")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Services", "Repositories")
.whereLayer("Repositories").mayOnlyBeAccessedByLayers("Services")
.check(classes);
}
@Test
void noCyclicDependencies() {
ArchRule rule = slices()
.matching("com.example.app.(*)..")
.should().beFreeOfCycles();
rule.check(classes);
}
@Test
void controllersShouldNotUseRepositoriesDirectly() {
ArchRule rule = noClasses()
.that().resideInAPackage("..controller..")
.should().dependOnClassesThat()
.resideInAPackage("..repository..");
rule.check(classes);
}
@Test
void domainShouldNotDependOnSpring() {
ArchRule rule = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("org.springframework..");
rule.check(classes);
}
}
Hexagonal Architecture
@Test
void hexagonalArchitecture() {
Architectures.onionArchitecture()
.domainModels("..domain.model..")
.domainServices("..domain.service..")
.applicationServices("..application..")
.adapter("web", "..adapter.web..")
.adapter("persistence", "..adapter.persistence..")
.adapter("messaging", "..adapter.messaging..")
.check(classes);
}
Naming Conventions
@Test
void servicesShouldHaveServiceSuffix() {
ArchRule rule = classes()
.that().resideInAPackage("..service..")
.should().haveSimpleNameEndingWith("Service");
rule.check(classes);
}
@Test
void repositoriesShouldBeInterfaces() {
ArchRule rule = classes()
.that().resideInAPackage("..repository..")
.and().haveSimpleNameEndingWith("Repository")
.should().beInterfaces();
rule.check(classes);
}
Dependency Visualization
Dependency Graph
# Generate SVG diagram
npx depcruise src \
--config .dependency-cruiser.js \
--output-type dot \
| dot -T svg > docs/dependency-graph.svg
Interactive Report
# HTML report
npx depcruise src \
--config .dependency-cruiser.js \
--output-type archi \
> docs/architecture-report.html
Production Checklist
## Architectural Linting Checklist
### Setup
- [ ] Dependency Cruiser / ArchUnit installed
- [ ] Basic rules defined
- [ ] CI pipeline configured
- [ ] Visualization in documentation
### Rules
- [ ] Circular dependencies forbidden
- [ ] Layered/Hexagonal architecture enforced
- [ ] Naming conventions
- [ ] Package/module boundaries
- [ ] No orphan modules
### CI/CD
- [ ] Fail build on violation
- [ ] Generate dependency graph on PR
- [ ] Archive reports
- [ ] Slack notifications on failure
### Maintenance
- [ ] Review rules quarterly
- [ ] Baseline for existing violations
- [ ] Onboarding documentation
How to Start with Existing Code
Baseline Approach
// .dependency-cruiser.js
module.exports = {
forbidden: [
{
name: 'no-circular',
severity: 'error',
from: {},
to: {
circular: true
}
}
],
options: {
// Ignore existing violations
exclude: {
path: [
'src/legacy/',
'src/modules/(auth|billing)/' // Known violations
]
}
}
};
Gradual Adoption
- Week 1: Warning only, no blocked builds
- Weeks 2-4: Fix most critical violations
- Month 2: Error for new violations
- Ongoing: Gradually reduce baseline
Conclusion
Architectural rules as code are a game-changer for long-term maintainability. Key benefits:
- Consistency - rules apply to everyone
- Automation - no manual checks
- Documentation - rules ARE documentation
- Prevention - catch errors before code review
- Visualization - understand architecture at a glance
FAQ
Is this too strict for small teams?
Small teams benefit the most - without a senior on every review, you catch basic problems automatically.
What if violation is intentional?
Use // depcruise-ignore-next-line or @ArchIgnore. Every exception should be documented.
Does it slow down CI?
Dependency Cruiser: ~5-30s for large projects. ArchUnit: similar. Negligible compared to build/test time.
Related Articles
- CI/CD for Monorepo - Integrating linting checks
- Architecture as Code - ADR and C4 diagrams
Related posts
API Idempotency: Designing Endpoints Resistant to Retries
Complete guide to implementing idempotent APIs. From Idempotency-Key through Redis locking to request processing state diagram.
Feature Flags Without Tech Debt: Automatic Stale Flag Detection
End-to-end solution for feature flag lifecycle management. From runtime metrics through static analysis to automatic removal PRs.
Transactional Outbox: Solving the Dual Write Problem Without 2PC
Practical Outbox pattern implementation in Node.js/TypeScript with PostgreSQL LISTEN/NOTIFY. Race-condition case study and production-ready solution.
Clean Code: Principles Every Developer Should Know
An overview of key clean code principles and why they're important for long-term software project maintainability.
Cite this article
If you reference this post, please link to the original URL and credit the author.