ADR-002: Different arch-test strategies for libraries vs. services and CLI tools¶
| Field | Value |
|---|---|
| Date | 2026-03-17 |
| Status | Accepted |
| Deciders | — |
| Supersedes | — |
| Superseded by | — |
Context and Problem Statement¶
Architecture tests (make arch-test) enforce structural rules on generated projects.
Two fundamentally different kinds of structural invariants need to be enforced depending
on the artifact type:
- Services and CLI tools implement business logic with I/O. They benefit from
Clean Architecture (inward-only dependency flow:
app → infra → usecase → domain). - Libraries are the domain. They have no application layers. The meaningful invariants are different: no circular dependencies, implementation details hidden from the public API, and a cohesive internal module structure.
Applying Clean Architecture layer checks uniformly to all artifact types would produce
false-positive failures on library projects (which do not have domain/, usecase/,
infra/, app/ directories by design) and would mislead developers.
Decision Drivers¶
- Arch tests must be meaningful — they must fail on actual violations, not on absent layers.
- Libraries must express their own structural contract (API/implementation separation).
- The tool choice per language is constrained: go-arch-lint (Go), ArchUnit (Java), custom Python script (C++ — Clang-Tidy does not enforce module-level boundaries).
- Generated projects must be immediately runnable with
make arch-testwithout requiring project-specific configuration changes.
Considered Options¶
- Apply Clean Architecture checks to all artifact types uniformly.
- Skip arch-test entirely for libraries.
- Apply Clean Architecture checks to services and CLI tools; apply library-appropriate checks (circular deps, API/impl separation) to libraries.
Decision Outcome¶
Chosen option: Option 3, because it makes arch tests meaningful for every artifact type. Libraries have a well-defined structural contract — they just express it differently from application artifacts.
Library checks (per language)¶
| Language | Tool | Rules enforced |
|---|---|---|
| Go | go-arch-lint |
pkg/ may depend on internal/; internal/ must not depend on pkg/ (no reverse leakage). Circular package deps caught by Go compiler at build time. |
| Java | ArchUnit | (1) No cyclic slice dependencies between top-level packages. (2) Classes in *.internal.* packages must not be public. |
| C++ | Custom Python script | (1) Public headers (include/) must not #include private headers (src/). (2) No circular include dependencies (DFS over include graph). |
Service and CLI tool checks (unchanged)¶
Clean Architecture inward-only dependency rule: app → infra → usecase → domain.
| Language | Tool | Config |
|---|---|---|
| Go | go-arch-lint |
.go-arch-lint.yml with 5 components (cmd, domain, usecase, infra, app) |
| Java | ArchUnit | ArchitectureTest.java with layeredArchitecture() |
| C++ | Custom Python script | scripts/arch_check.py scanning #include per layer directory |
Positive Consequences¶
- Arch tests pass on clean generated projects for all artifact types.
- Library developers get actionable feedback: circular includes, internal leakage.
- Service/tool developers get Clean Architecture enforcement.
- No false positives from absent layer directories in library projects.
Negative Consequences / Risks¶
- Two scripts/configs per language (one for libraries, one for services/tools) increases template maintenance surface.
- The Go library config does not detect intra-component circular deps — mitigated by the Go compiler, which rejects import cycles at build time.
- The C++ library script checks by filename, not by full path, which may produce false
negatives if private and public headers share the same basename. Mitigated by the
convention that public headers live under a namespace subdirectory (e.g.,
include/<ns>/).
Pros and Cons of the Options¶
Option 1 — Uniform Clean Architecture checks for all artifact types¶
- Pro: Single config/script per language; simpler template maintenance.
- Con: Fails on library projects that correctly have no
domain/,usecase/, etc. directories — makes arch-test useless or actively broken for libraries.
Option 2 — No arch-test for libraries¶
- Pro: No false positives.
- Con: Libraries have their own structural invariants that are worth enforcing. Leaving libraries without arch-test creates a quality gap.
Option 3 — Artifact-type-specific checks (chosen)¶
- Pro: Every generated project has a meaningful arch-test from day one.
- Pro: Aligns with the semantic difference between application artifacts and library artifacts.
- Con: More template files to maintain — acceptable given the clear conceptual separation.
Links¶
internal/templating/go/files/library/_go-arch-lint.yml— library config (pkg/internal separation)internal/templating/go/files/service/_go-arch-lint.yml— service config (Clean Architecture)internal/templating/go/files/cli_tool/_go-arch-lint.yml— CLI tool config (Clean Architecture)internal/templating/java/files/library/src/test/java/ArchitectureTest.java— library rules (noCycles, internalPackagesAreHidden)internal/templating/java/files/service/src/test/java/ArchitectureTest.java— service rules (layeredArchitecture)internal/templating/cpp/files/library/scripts/arch_check.py— library script (public/private header isolation, cycle detection)internal/templating/cpp/files/cli_tool/scripts/arch_check.py— CLI tool script (Clean Architecture layers)internal/templating/cpp/files/service/scripts/arch_check.py— service script (Clean Architecture layers)internal/templating/cpp/makefile_partials.go— selects Makefile partials per project typespecs/guidelines/general_coding.md— Clean Architecture section (decision driver)