Back to blog

Architectural Linting: Automated Protection Against Spaghetti Code

|
| architecture, ci-cd, automation, typescript, java

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

  1. Subjective - every senior has a different view
  2. Inconsistent - depends on who reviews
  3. Doesn’t cover everything - large PRs get skimmed
  4. 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

  1. Week 1: Warning only, no blocked builds
  2. Weeks 2-4: Fix most critical violations
  3. Month 2: Error for new violations
  4. Ongoing: Gradually reduce baseline

Conclusion

Architectural rules as code are a game-changer for long-term maintainability. Key benefits:

  1. Consistency - rules apply to everyone
  2. Automation - no manual checks
  3. Documentation - rules ARE documentation
  4. Prevention - catch errors before code review
  5. 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 posts

Cite this article

If you reference this post, please link to the original URL and credit the author.

Michal Drozd. "Architectural Linting: Automated Protection Against Spaghetti Code". https://www.michal-drozd.com/en/blog/architectural-linting/ (Published May 28, 2025).