Fitness Functions skill for Claude Code
Architecture rules that fail the build, not reviews. Claude turns architecture decisions into executable CI checks: dependency direction, module boundaries, cycle bans, complexity and size limits, bundle and latency budgets, and API breaking-change diffs. Existing violations are baselined instead of blocking the team, a bundled ratchet script fails on any regression and tightens the baseline on improvement, and every rule's message links the decision it protects.
- name
fitness-functions - size172 lines · ≈1,950 tokens
- files3
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Turns architecture decisions into automated fitness functions that run in CI - dependency direction and layering rules, cycle bans, module and package boundaries, complexity and size limits, bundle and latency budgets, and API breaking-change checks - using dependency-cruiser, eslint-plugin-boundaries, ArchUnit, NetArchTest, import-linter, depguard and Packwerk, with baselines for existing violations and a ratchet script so metrics only improve. Use when architecture rules live only in docs or reviews, layers keep leaking, a codebase needs guardrails before a refactor, or an ADR should be enforced automatically. Try asking
- Enforce that our domain folder never imports infrastructure, in CI
- Add architecture tests to this Spring Boot service with ArchUnit
- We have 200 layering violations. Stop new ones without fixing all of them today
Use it when
- Architecture rules exist only in docs or review comments
- Layers or modules keep leaking into each other
- You want guardrails before a large refactor
- An ADR should be enforced automatically
Skip it when
- You are designing module boundaries for a monolith in the first place — use modular-monolith
- You need to record the decision itself — use adr-records
How to check the result
A deliberate violation on a scratch branch fails CI with the rule's explanatory message, reverting it passes, and fixing a baselined violation shrinks the baseline through the ratchet.
The common mistake it prevents
Turning on a strict rule across a legacy codebase with no baseline, so the build goes red on day one and the team disables the check permanently.
Done well means: every important architecture rule is an executable check with a clear failure message, the build fails on new violations while existing ones are baselined and shrinking, checks run in seconds on every PR, and each check traces back to the decision (ADR or design doc) it protects.
Workflow
- [ ] 1. List the architecture characteristics and decisions worth protecting
- [ ] 2. Turn each into a measurable rule with a threshold
- [ ] 3. Pick the tool per ecosystem; write the rule
- [ ] 4. Measure the current state; baseline existing violations
- [ ] 5. Gate CI on new violations; ratchet metrics
- [ ] 6. Make failures self-explanatory; link rules to decisions
- [ ] 7. Review the rule set periodically; delete rules nobody needs
1. What to protect
Start from decisions that already exist (ADRs, design docs, review comments that repeat) rather than inventing ideals. Typical candidates:
| Characteristic | Decision | Fitness function |
|---|---|---|
| Maintainability | Domain layer has no framework or IO dependencies | Dependency rule: domain must not import infra, ORM, HTTP |
| Modularity | Features interact only through public entry points | Boundary rule: no imports of another feature’s internals |
| Changeability | No dependency cycles between packages | Cycle check |
| Simplicity | Functions stay small and readable | Complexity per function at or below threshold for new code |
| Performance | Web bundle stays fast on mobile | Bundle size budget per entry point |
| Performance | Checkout API p95 under 300 ms | Load test threshold in a scheduled pipeline |
| Compatibility | Public API never breaks silently | oasdiff / buf breaking-change check |
| Security | No high-severity vulnerable dependencies | Audit gate |
Pick 3 to 7 rules to start. A wall of rules nobody understands gets disabled.
2. Make each rule measurable
Write each as: scope, rule, threshold, severity, owner, linked decision.
Rule: domain-is-pure
Scope: src/domain/**
Rule: must not import src/infra/**, src/web/**, or packages pg, express, prisma
Threshold: 0 new violations (baseline: 14 existing)
Severity: error (blocks merge)
Owner: platform team
Decision: ADR-0007 hexagonal core
3. Tools per ecosystem
| Ecosystem | Structural rules | Cycles | Metrics |
|---|---|---|---|
| TypeScript/JS | dependency-cruiser (default), eslint-plugin-boundaries, Nx module boundaries | dependency-cruiser, madge | ESLint complexity, size-limit |
| Java/Kotlin | ArchUnit (default), Spring Modulith, Konsist | ArchUnit slices().beFreeOfCycles() | PMD, Checkstyle |
| .NET | NetArchTest or ArchUnitNET; project references | ArchUnitNET | Roslyn analyzers (CA1502 complexity) |
| Python | import-linter (default) | import-linter independence contracts | radon + xenon |
| Go | depguard (golangci-lint), go-arch-lint, internal/ packages | compiler | gocyclo, gocognit |
| Ruby | Packwerk | Packwerk | RuboCop metrics cops |
Prefer rules enforced by the compiler or build graph (separate projects/modules with
restricted references, Go internal/) over lint rules: they cannot be bypassed with an
ignore comment.
Ready-to-adapt configurations for dependency-cruiser, ArchUnit with frozen baselines, NetArchTest, import-linter, depguard and Packwerk, plus non-structural gates (bundle size, Lighthouse, k6, complexity) are in references/tool-configs.md. Read it when writing the first rule for an ecosystem.
4. Baseline existing violations
Never start by fixing everything, and never start with the rule disabled.
| Tool | Baseline mechanism |
|---|---|
| dependency-cruiser | --output-type baseline to a known-violations file, then --ignore-known |
| ArchUnit | FreezingArchRule.freeze(rule) with a committed violation store |
| import-linter | ignore_imports per contract, listing each known violation |
| Packwerk | package_todo.yml |
| Any numeric metric | scripts/ratchet.py with a committed baseline JSON |
The baseline is committed, reviewed like code, and only allowed to shrink.
5. Gate and ratchet
For numeric metrics (violation counts, cycle counts, files over a size limit, coverage), collect them into one JSON file in CI and compare with the committed baseline:
npx depcruise src -c .dependency-cruiser.cjs -T json | jq '{dep_violations: .summary.error}' > arch-metrics.json
python3 scripts/ratchet.py arch-metrics.json arch-baseline.json # CI: fail on regression
python3 scripts/ratchet.py arch-metrics.json arch-baseline.json --update # main branch: tighten
The script fails when any metric is worse than baseline (lower is better by default;
mark metrics like coverage with "better": "higher"), fails when a baselined metric
disappears, and with --update writes improvements back so the bar only moves forward.
Run --update on the main branch after merge (or locally, committing the new baseline),
never on PR branches.
CI placement:
| Check | When | Budget |
|---|---|---|
| Dependency, boundary, cycle rules | Every PR | Under 60 s |
| Complexity and size ratchets | Every PR | Under 60 s |
| Bundle size | Every PR touching frontend | Under 2 min |
| Breaking API diff | Every PR touching the spec or API code | Under 1 min |
| Load-test thresholds | Nightly or pre-release | Minutes |
6. Failure messages people can act on
A failing rule must say what was violated, why the rule exists, and what to do instead:
{
name: "domain-is-pure",
comment: "Domain must not import infrastructure (ADR-0007). Define a port interface in "
+ "src/domain/ports and implement it in src/infra.",
severity: "error",
from: { path: "^src/domain" },
to: { path: "^src/infra" },
}
Link the ADR from the rule and the rule from the ADR. When a rule is wrong, change the rule (and the ADR) in a reviewed PR; do not sprinkle ignore comments.
7. Keep the rule set alive
- Review quarterly: rules with zero violations for a year that guard nothing current can go; rules that are frequently suppressed signal a wrong decision.
- Track the baseline size over time; a flat baseline means nobody is paying down debt, which may be fine if the team decided so explicitly.
Gotchas
- Rules that only run locally or in a weekly job are documentation, not fitness functions.
- Path-regex rules silently stop matching after a folder rename; add a test or a rule that asserts the target paths exist (dependency-cruiser warns on unmatched rules with the right options; otherwise check counts are non-zero for “should exist” rules).
- Type-only imports in TypeScript count as dependencies for some rules and not others;
set
tsPreCompilationDepsdeliberately. - Freezing rules without allowing the store to shrink keeps fixed violations in the baseline, so they can silently come back.
- Gating on absolute coverage or complexity across a legacy codebase blocks everyone on day one; gate on “no worse than baseline” and on new code.
- Updating or deleting a baseline to make CI green is a policy decision; require explicit approval in the PR.
Verification
- Introduce a deliberate violation on a scratch branch (import infra from domain, add a cycle, grow a bundle past budget); CI fails with the rule’s message.
- Revert it; CI passes. Fix one baselined violation; the ratchet reports an improvement
and
--updateshrinks the baseline. - Total runtime of PR-level fitness checks stays under the agreed budget.
Output contract
| Rule | Protects (ADR/decision) | Tool | Threshold | Baseline | Severity | Runs |
Plus the config files added, the baseline files committed, and the CI job definition.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/tool-configs.md
# Fitness function tool configurations
Working starting points per ecosystem. Pin tool versions in the project's lockfile and
check option names against the installed version's docs.
## TypeScript / JavaScript: dependency-cruiser
```bash
npm i -D dependency-cruiser
npx depcruise --init # generates .dependency-cruiser.cjs
```
```js
// .dependency-cruiser.cjs
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{ name: "no-circular", severity: "error", from: {}, to: { circular: true } },
{
name: "domain-is-pure",
comment: "Domain code must not depend on infrastructure or delivery layers",
severity: "error",
from: { path: "^src/domain" },
to: { path: "^src/(infra|web|jobs)" },
},
{
name: "no-cross-feature-internals",
comment: "Features talk through their index.ts only",
severity: "error",
from: { path: "^src/features/([^/]+)/" },
to: { path: "^src/features/([^/]+)/(?!index\\.ts$)", pathNot: "^src/features/$1/" },
},
{ name: "no-orphans", severity: "warn", from: { orphan: true, pathNot: "\\.(d\\.ts|test\\.ts)$" }, to: {} },
{ name: "no-dev-deps-in-src", severity: "error", from: { path: "^src" }, to: { dependencyTypes: ["npm-dev"] } },
],
options: {
doNotFollow: { path: "node_modules" },
tsConfig: { fileName: "tsconfig.json" },
tsPreCompilationDeps: true,
},
};
```
```bash
npx depcruise src --config .dependency-cruiser.cjs # gate
npx depcruise src --config .dependency-cruiser.cjs --output-type baseline \
> .dependency-cruiser-known-violations.json # freeze existing
npx depcruise src --config .dependency-cruiser.cjs --ignore-known # fail only on new
npx depcruise src --config .dependency-cruiser.cjs --output-type json \
| jq '{dep_violations: .summary.error}' > arch-metrics.json # feed ratchet.py
npx depcruise src --include-only "^src" --output-type dot | dot -T svg > deps.svg
```
Alternatives: `eslint-plugin-boundaries` (element types and allowed relations as lint
rules, good editor feedback), ESLint core `no-restricted-imports` for a handful of bans,
Nx `@nx/enforce-module-boundaries` with project tags in Nx workspaces, `madge --circular src`
for a quick cycle check.
## Java / Kotlin: ArchUnit
```xml
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.4.1</version>
<scope>test</scope>
</dependency>
```
```java
@AnalyzeClasses(packages = "com.acme.shop", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
@ArchTest
static final ArchRule layers = layeredArchitecture().consideringAllDependencies()
.layer("Web").definedBy("..web..")
.layer("Application").definedBy("..application..")
.layer("Domain").definedBy("..domain..")
.layer("Infrastructure").definedBy("..infrastructure..")
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers("Web")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure")
.whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer();
@ArchTest
static final ArchRule noCycles = slices().matching("com.acme.shop.(*)..").should().beFreeOfCycles();
@ArchTest
static final ArchRule domainHasNoSpring = noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage("org.springframework..", "jakarta.persistence..");
// Baseline existing violations; new ones fail. Store lives in archunit_store/ (commit it).
@ArchTest
static final ArchRule frozenLayers = FreezingArchRule.freeze(layers);
}
```
Enable the store to shrink automatically by setting `freeze.store.default.allowStoreUpdate=true`
locally and `false` in CI (in `archunit.properties`), so CI never writes new violations
into the baseline. Spring Modulith's `ApplicationModules.of(App.class).verify()` covers
module boundaries for Spring Boot apps. Kotlin projects can use Konsist for
declaration-level rules.
## .NET: NetArchTest or ArchUnitNET
```csharp
[Fact]
public void Domain_does_not_depend_on_infrastructure()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.That().ResideInNamespace("Shop.Domain")
.ShouldNot().HaveDependencyOnAny("Shop.Infrastructure", "Microsoft.EntityFrameworkCore")
.GetResult();
Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames ?? []));
}
```
Separate projects per layer with restricted `ProjectReference`s are the cheapest .NET
fitness function: the compiler enforces direction.
## Python: import-linter
```ini
# .importlinter
[importlinter]
root_package = shop
include_external_packages = True
[importlinter:contract:layers]
name = Layered architecture
type = layers
layers =
shop.api
shop.services
shop.domain
[importlinter:contract:domain-pure]
name = Domain has no framework imports
type = forbidden
source_modules =
shop.domain
forbidden_modules =
sqlalchemy
fastapi
shop.infra
[importlinter:contract:features-independent]
name = Feature packages are independent
type = independence
modules =
shop.billing
shop.catalog
shop.shipping
```
```bash
pip install import-linter
lint-imports # exits non-zero on broken contracts
```
Use `ignore_imports` in a contract to list known violations explicitly, and shrink the
list over time. Complexity: `radon cc -s -a shop` to measure, `xenon --max-absolute B
--max-modules A --max-average A shop` to gate.
## Go
- Package direction: `depguard` via golangci-lint with rules per package path, or
`go-arch-lint` for component-level rules.
- Cycles are compile errors in Go; the remaining risks are layering and `internal/` misuse.
Put private packages under `internal/` so the toolchain blocks outside imports.
```yaml
# .golangci.yml (excerpt; v2 config format)
linters:
enable: [depguard]
settings:
depguard:
rules:
domain:
files: ["**/internal/domain/**"]
deny:
- pkg: "database/sql"
desc: "domain must not touch the database"
- pkg: "net/http"
desc: "domain must not know about HTTP"
```
## Ruby
Packwerk (`bin/packwerk check`, `package.yml` per pack with `enforce_dependencies: true`)
with `package_todo.yml` as the baseline of existing violations.
## Non-structural fitness functions
| Characteristic | Tool | Gate example |
|---|---|---|
| Bundle size | size-limit | `"limit": "180 kB"` per entry, CI fails above |
| Page performance | Lighthouse CI | `assertions: { "categories:performance": ["error", {"minScore": 0.9}] }` |
| API latency | k6 thresholds | `http_req_duration: ["p(95)<300"]` in a nightly run |
| Function complexity | ESLint `complexity`, radon/xenon, gocyclo | max 15 per function for new code |
| File size | custom script (wc) + ratchet | no file over 800 lines unless baselined |
| Dependency policy | license checkers, `npm audit --audit-level=high`, osv-scanner | zero high or critical |
| Breaking API changes | oasdiff, buf breaking | no breaking changes without a version bump |
scripts/ratchet.py
#!/usr/bin/env python3
"""Ratchet architecture metrics: fail when any metric gets worse than its baseline.
Usage:
python3 scripts/ratchet.py current.json baseline.json [--update] [--tolerance 0]
Both files map metric names to numbers, or to objects with "value" and optional
"better" ("lower" is the default, or "higher"):
{"dep_violations": 42, "circular_deps": 3,
"coverage_pct": {"value": 81.5, "better": "higher"}}
The "better" direction is read from the baseline first, then from current.
Behavior:
- A metric worse than baseline (beyond --tolerance) fails the run (exit 1).
- A metric in the baseline but missing from current fails (exit 1).
- New metrics in current are reported and, with --update, added.
- With --update and no regressions, improved values are written back to the
baseline so the bar only moves in the good direction.
Exit codes: 0 = no regressions, 1 = regression or missing metric, 2 = bad input.
"""
import json
import sys
def load(path):
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"error: cannot read {path}: {exc}", file=sys.stderr)
sys.exit(2)
if not isinstance(data, dict):
print(f"error: {path} must be a JSON object", file=sys.stderr)
sys.exit(2)
out = {}
for name, raw in data.items():
if isinstance(raw, dict):
value, better = raw.get("value"), raw.get("better", "lower")
else:
value, better = raw, None
if not isinstance(value, (int, float)) or isinstance(value, bool):
print(f"error: {path}: metric '{name}' is not a number", file=sys.stderr)
sys.exit(2)
if better not in (None, "lower", "higher"):
print(f"error: {path}: metric '{name}' has better='{better}' (use lower|higher)", file=sys.stderr)
sys.exit(2)
out[name] = (float(value), better)
return out
def main(argv):
args = [a for a in argv[1:] if not a.startswith("--")]
update = "--update" in argv
tolerance = 0.0
if "--tolerance" in argv:
try:
tolerance = float(argv[argv.index("--tolerance") + 1])
args = [a for a in args if a != argv[argv.index("--tolerance") + 1]]
except (IndexError, ValueError):
print("error: --tolerance needs a number", file=sys.stderr)
return 2
if len(args) != 2:
print(__doc__)
return 2
current_path, baseline_path = args
current, baseline = load(current_path), load(baseline_path)
regressions, improved, rows = [], {}, []
for name, (base_val, base_better) in sorted(baseline.items()):
if name not in current:
regressions.append(f"{name}: missing from current metrics")
rows.append((name, base_val, None, "MISSING"))
continue
cur_val, cur_better = current[name]
better = base_better or cur_better or "lower"
delta = cur_val - base_val
worse = delta > tolerance if better == "lower" else -delta > tolerance
gained = delta < 0 if better == "lower" else delta > 0
if worse:
regressions.append(f"{name}: {base_val:g} -> {cur_val:g} ({better} is better)")
status = "WORSE"
elif gained:
improved[name] = (cur_val, better)
status = "better"
else:
status = "same"
rows.append((name, base_val, cur_val, status))
new = {k: v for k, v in current.items() if k not in baseline}
for name, (val, _) in sorted(new.items()):
rows.append((name, None, val, "new"))
width = max([len(r[0]) for r in rows] + [6])
print(f"{'metric':<{width}} {'baseline':>10} {'current':>10} status")
for name, b, c, s in rows:
bs = "-" if b is None else f"{b:g}"
cs = "-" if c is None else f"{c:g}"
print(f"{name:<{width}} {bs:>10} {cs:>10} {s}")
if regressions:
print("\nFAIL: architecture metrics regressed:")
for r in regressions:
print(f" - {r}")
print("Fix the regression, or get explicit approval and edit the baseline in a reviewed commit.")
return 1
if update and (improved or new):
merged = {}
for name, (val, better) in {**baseline, **new, **improved}.items():
b = better or baseline.get(name, (None, None))[1] or "lower"
merged[name] = {"value": val, "better": b}
with open(baseline_path, "w", encoding="utf-8") as fh:
json.dump(merged, fh, indent=2, sort_keys=True)
fh.write("\n")
print(f"\nBaseline tightened: {len(improved)} improved, {len(new)} added -> {baseline_path}")
elif improved:
print(f"\nOK: {len(improved)} metric(s) improved; run with --update to tighten the baseline.")
else:
print("\nOK: no regressions.")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.