Architectural Linting: Automatizovaná ochrana proti spaghetti kódu
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čí
- Subjektívne - každý senior má iný pohľad
- Nekonzistentné - závisí od toho, kto reviewuje
- Nepokrýva všetko - veľké PR sa skimujú
- 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
- Týždeň 1: Len warning, žiadne blokované builds
- Týždeň 2-4: Fix najkritickejšie violations
- Mesiac 2: Error pre nové violations
- Ongoing: Postupne redukuj baseline
Záver
Architektonické pravidlá ako kód sú game-changer pre dlhodobú udržateľnosť. Kľúčové prínosy:
- Konzistentnosť - pravidlá platia pre všetkých
- Automatizácia - žiadne manuálne kontroly
- Dokumentácia - pravidlá SÚ dokumentácia
- Prevencia - chyby odhalíš pred code review
- 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
- CI/CD pre monorepo - Integrácia linting checks
- Architektúra ako kód - ADR a C4 diagramy
Súvisiace články
Idempotencia API: Ako navrhnúť endpointy odolné voči retry
Kompletný návod na implementáciu idempotentných API. Od Idempotency-Key cez Redis locking až po stavový diagram spracovania.
Feature flags bez technického dlhu: Automatická detekcia stale flags
End-to-end riešenie pre lifecycle management feature flags. Od runtime metrík cez statickú analýzu až po automatické removal PR.
Transactional Outbox: Ako vyriešiť Dual Write problém bez 2PC
Praktická implementácia Outbox patternu v Node.js/TypeScript s PostgreSQL LISTEN/NOTIFY. Race-condition case study a production-ready riešenie.
Prestaňte mockovať databázu: Integračné testy v ére Testcontainers
Prečo mocky klamú a ako Testcontainers zmení váš prístup k testovaniu. Praktické príklady, CI setup a stratégie izolácie dát.
Citujte tento článok
Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.