Code review manual sering gagal menangkap pelanggaran struktural yang halus. Pengembang baru dapat memanggil repository langsung dari controller, membuat siklus dependensi (circular dependency) antar-package, atau melewatkan konvensi penamaan standar. Seiring waktu, batas arsitektur menipis dan sistem terdegradasi menjadi big ball of mud.

ArchUnit menyelesaikan masalah ini dengan memperlakukan arsitektur sebagai kode. ArchUnit adalah pustaka pengujian Java yang menganalisis bytecode file .class untuk memastikan aturan arsitektur dipatuhi melalui unit test standar. Artikel ini menguraikan konfigurasi ArchUnit pada Spring Boot, pembuatan rules penting, strategi mitigasi legacy code, dan integrasi pipeline CI.

Setup Dependency

ArchUnit berjalan di atas engine JUnit 5 tanpa memerlukan runtime Spring context aktif, sehingga eksekusi rule berlangsung dalam hitungan detik.

Untuk Maven, tambahkan dependency ke pom.xml:

<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit-junit5</artifactId>
    <version>1.3.0</version>
    <scope>test</scope>
</dependency>

Untuk Gradle (Kotlin DSL), tambahkan ke build.gradle.kts:

testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")

Menulis Rule Arsitektur

ArchUnit menyediakan Fluent API untuk mendefinisikan batasan kode. Buat class pengujian baru di dalam direktori src/test/java.

1. Penegakan Layer Arsitektur

Pola baku Spring Boot umumnya memisahkan Controller, Service, dan Repository. Controller tidak boleh mengakses Repository secara langsung, dan Repository tidak boleh memanggil Service.

package com.example.app.architecture;

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.core.importer.ImportOption;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

@AnalyzeClasses(packages = "com.example.app", importOptions = ImportOption.DoNotIncludeTests.class)
public class LayerArchitectureTest {

    @ArchTest
    static final ArchRule layer_dependencies_are_respected = layeredArchitecture()
            .consideringAllDependencies()
            .layer("Controller").definedBy("..controller..")
            .layer("Service").definedBy("..service..")
            .layer("Repository").definedBy("..repository..")
            .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller", "Service")
            .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");
}

2. Larangan Circular Dependency Antar-Paket

Circular dependency menyebabkan kopling ketat yang menyulitkan modularisasi dan testing. Library Slices pada ArchUnit mendeteksi dependensi melingkar di tingkat sub-package.

package com.example.app.architecture;

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.core.importer.ImportOption;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;

@AnalyzeClasses(packages = "com.example.app", importOptions = ImportOption.DoNotIncludeTests.class)
public class CircularDependencyTest {

    @ArchTest
    static final ArchRule no_cycles_between_feature_slices = slices()
            .matching("com.example.app.(*)..")
            .should().beFreeOfCycles();
}

3. Konvensi Penamaan dan Anotasi Spring

Untuk menjaga konsistensi, pastikan class yang diberi anotasi Spring mengikuti standar penamaan yang seragam.

package com.example.app.architecture;

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.core.importer.ImportOption;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RestController;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;

@AnalyzeClasses(packages = "com.example.app", importOptions = ImportOption.DoNotIncludeTests.class)
public class NamingConventionTest {

    @ArchTest
    static final ArchRule controllers_must_be_annotated_and_named = classes()
            .that().resideInAPackage("..controller..")
            .should().beAnnotatedWith(RestController.class)
            .andShould().haveSimpleNameEndingWith("Controller");

    @ArchTest
    static final ArchRule services_must_be_annotated_with_service = classes()
            .that().haveSimpleNameEndingWith("Service")
            .should().beAnnotatedWith(Service.class);
}

Strategi Baseline untuk Legacy Code

Menerapkan ArchUnit pada sistem yang sudah berjalan sering menghasilkan ratusan kegagalan pengujian sekaligus. Menghentikan pengembangan fitur demi memperbaiki seluruh pelanggaran lama bukan langkah realistis. Gunakan fitur FreezingArchRule untuk membuat baseline pelanggaran.

Aktifkan baseline storage dengan membuat file src/test/resources/archunit.properties:

freeze.store.default.allowStoreUpdate=true
freeze.store.default.path=src/test/resources/archunit_store

Bungkus rule yang memiliki utang teknis dengan FreezingArchRule.freeze():

import static com.tngtech.archunit.library.freeze.FreezingArchRule.freeze;

@ArchTest
static final ArchRule layer_dependencies_are_respected = freeze(
        layeredArchitecture()
                .consideringAllDependencies()
                .layer("Controller").definedBy("..controller..")
                .layer("Service").definedBy("..service..")
                .layer("Repository").definedBy("..repository..")
                .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
                .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller", "Service")
                .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
);

Mekanisme kerja Freezing:

  • Saat dijalankan pertama kali, ArchUnit merekam seluruh pelanggaran yang ada ke dalam folder archunit_store dan meluluskan build.
  • Ubah properti freeze.store.default.allowStoreUpdate=false pada environment CI.
  • Jika ada developer yang menambahkan pelanggaran baru, test akan gagal dan memblokir pipeline.
  • Jika pelanggaran lama diperbaiki secara bertahap, perbarui store secara berkala.

Integrasi ke Pipeline CI (GitHub Actions)

Karena ArchUnit berjalan di atas JUnit 5, eksekusi dilakukan bersamaan dengan unit test tanpa konfigurasi plugin tambahan. Buat workflow GitHub Actions di .github/workflows/ci.yml:

name: CI Pipeline

on:
  pull_request:
    branches: [ main, master ]

jobs:
  architecture-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: maven

      - name: Run ArchUnit Tests
        run: mvn test -Dtest=*ArchitectureTest,*Test

Jika ada rule yang dilanggar, ArchUnit mencetak pesan error spesifik beserta nama class, method, dan nomor baris penyebab error, lalu mengembalikan status exit code non-zero yang membatalkan merge pull request.

Troubleshooting dan Optimasi

  • Waktu Scan Lambat: Scope scan yang terlalu luas (misal menyertakan class pihak ketiga) memperlambat evaluasi bytecode. Batasi target package secara eksplisit pada anotasi @AnalyzeClasses(packages = "com.example.app").
  • Spring Context Overhead: Jangan gunakan @SpringBootTest pada test suite ArchUnit. ArchUnit hanya membutuhkan Java reflection dan ASM bytecode analyzer. Memuat ApplicationContext Spring akan memperlambat build tanpa memberikan nilai tambah.
  • Sub-package Naming: Penggunaan ..package.. mencakup package tersebut beserta seluruh turunannya, sedangkan .package. hanya mencakup layer tepat di bawahnya. Ketelitian penulisan syntax regex krusial agar pengujian tidak meloloskan pelanggaran.