Späť na blog

Architectural Linting: Automatizovaná ochrana proti spaghetti kódu

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

Po tretom archi review za mesiac som prestal debatovat a zacal lintovat. “Prečo controller volá priamo repository?” Táto otázka na code review sa opakovala každý týždeň. Senior vývojári vedeli, že to porušuje vrstvovú architektúru, ale nikto to systematicky nevynucoval. Výsledok? Po dvoch rokoch - spaghetti kód, kde každý modul importoval každý.

Riešenie? Architektonické pravidlá ako testy - automaticky validované v CI.

Testované na: TypeScript monorepo s 50+ packages, Java 21 microservices. Dependency Cruiser 16.x, ArchUnit 1.2.x.

Prečo Code Review nestačí

  1. Subjektívne - každý senior má iný pohľad
  2. Nekonzistentné - závisí od toho, kto reviewuje
  3. Nepokrýva všetko - veľké PR sa skimujú
  4. Reaktívne - problém odhalíš až pri review, nie pri písaní

Čo chceme dosiahnuť

┌─────────────────────────────────────────────────────┐
│                    Presentation                      │
│                   (Controllers)                      │
│                        ↓                             │
├─────────────────────────────────────────────────────┤
│                    Application                       │
│                    (Services)                        │
│                        ↓                             │
├─────────────────────────────────────────────────────┤
│                      Domain                          │
│                    (Entities)                        │
│                        ↓                             │
├─────────────────────────────────────────────────────┤
│                  Infrastructure                      │
│           (Repositories, External APIs)              │
└─────────────────────────────────────────────────────┘

Pravidlá:
- Controllers môžu volať len Services
- Services môžu volať Domain a Repositories
- Domain nemá žiadne závislosti (okrem utilities)
- Žiadne cyklické závislosti

Dependency Cruiser pre JavaScript/TypeScript

Inštalácia

npm install --save-dev dependency-cruiser
npx depcruise --init

Základná konfigurácia

// .dependency-cruiser.js
module.exports = {
  forbidden: [
    // Zakáž cyklické závislosti
    {
      name: 'no-circular',
      severity: 'error',
      from: {},
      to: {
        circular: true
      }
    },

    // Controllers nesmú priamo volať repositories
    {
      name: 'no-controller-to-repository',
      severity: 'error',
      comment: 'Controllers must go through services',
      from: {
        path: '^src/controllers/'
      },
      to: {
        path: '^src/repositories/'
      }
    },

    // Domain nemá závislosti na infrastructure
    {
      name: 'domain-independence',
      severity: 'error',
      comment: 'Domain layer must not depend on infrastructure',
      from: {
        path: '^src/domain/'
      },
      to: {
        path: '^src/(controllers|repositories|infrastructure)/'
      }
    },

    // Zakáž orphan moduly (nepoužité)
    {
      name: 'no-orphans',
      severity: 'warn',
      from: {
        orphan: true,
        pathNot: [
          '\\.d\\.ts$',
          '\\.test\\.(ts|js)$',
          'index\\.(ts|js)$'
        ]
      },
      to: {}
    },

    // Zakáž import z 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' }
        }
      }
    }
  }
};

Pravidlá pre Monorepo

// Pre Turborepo/Nx monorepo
module.exports = {
  forbidden: [
    // Packages nesmú závisieť na apps
    {
      name: 'packages-no-app-dependency',
      severity: 'error',
      from: {
        path: '^packages/'
      },
      to: {
        path: '^apps/'
      }
    },

    // Feature packages sú izolované
    {
      name: 'feature-isolation',
      severity: 'error',
      from: {
        path: '^packages/feature-(.+)/'
      },
      to: {
        path: '^packages/feature-(?!\\1)',
        pathNot: '^packages/(shared|ui|utils)/'
      }
    },

    // Shared package nemá 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 pre Java/Kotlin

Setup

// build.gradle.kts
dependencies {
    testImplementation("com.tngtech.archunit:archunit-junit5:1.2.1")
}

Architektonické testy

// 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);
}

Vizualizácia Závislostí

Dependency Graph

# Generuj SVG diagram
npx depcruise src \
  --config .dependency-cruiser.js \
  --output-type dot \
  | dot -T svg > docs/dependency-graph.svg

Interaktívny 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 nainštalované
- [ ] Základné pravidlá definované
- [ ] CI pipeline nakonfigurovaný
- [ ] Vizualizácia v dokumentácii

### Pravidlá
- [ ] Zakázané cyklické závislosti
- [ ] Layered/Hexagonal architecture enforced
- [ ] Naming conventions
- [ ] Package/module boundaries
- [ ] No orphan modules

### CI/CD
- [ ] Fail build na violation
- [ ] Generuj dependency graph na PR
- [ ] Archivácia reportov
- [ ] Notifikácie do Slacku pri failure

### Maintenance
- [ ] Review pravidiel každý kvartál
- [ ] Baseline pre existujúce violations
- [ ] Onboarding dokumentácia

Ako Začať s Existujúcim Kódom

Baseline Approach

// .dependency-cruiser.js
module.exports = {
  forbidden: [
    {
      name: 'no-circular',
      severity: 'error',
      from: {},
      to: {
        circular: true
      }
    }
  ],

  options: {
    // Ignoruj existujúce violations
    exclude: {
      path: [
        'src/legacy/',
        'src/modules/(auth|billing)/'  // Known violations
      ]
    }
  }
};

Postupná Adopcia

  1. Týždeň 1: Len warning, žiadne blokované builds
  2. Týždeň 2-4: Fix najkritickejšie violations
  3. Mesiac 2: Error pre nové violations
  4. Ongoing: Postupne redukuj baseline

Záver

Architektonické pravidlá ako kód sú game-changer pre dlhodobú udržateľnosť. Kľúčové prínosy:

  1. Konzistentnosť - pravidlá platia pre všetkých
  2. Automatizácia - žiadne manuálne kontroly
  3. Dokumentácia - pravidlá SÚ dokumentácia
  4. Prevencia - chyby odhalíš pred code review
  5. Vizualizácia - pochop architektúru na prvý pohľad

FAQ

Je to príliš prísne pre malé tímy?

Práve malé tímy majú najväčší benefit - bez seniora na každom review zachytíš základné problémy automaticky.

Čo ak porušenie je zámerné?

Použi // depcruise-ignore-next-line alebo @ArchIgnore. Každá výnimka by mala byť zdokumentovaná.

Spomalí to CI?

Dependency Cruiser: ~5-30s pre veľké projekty. ArchUnit: podobné. Zanedbateľné voči build/test času.


Súvisiace články

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "Architectural Linting: Automatizovaná ochrana proti spaghetti kódu". https://www.michal-drozd.com/sk/blog/architectural-linting/ (Publikované 28. mája 2025).