> ## Documentation Index
> Fetch the complete documentation index at: https://aspect.build/llms.txt
> Use this file to discover all available pages before exploring further.

# A rule you can actually enforce

> Declare architecture layering rules as data, evaluate them with typed query results, and exit non-zero when one breaks.

```shell theme={null}
git checkout step-4
git diff step-3 step-4
```

Everyone has a rule like this. *The domain model mustn't import the HTTP layer. The scheduler mustn't link the database. The foundation libraries stay leaves.* Usually it lives in a wiki page, in review comments, or in a `grep` somebody added to CI in 2022.

`.aspect/policy.axl` is 222 lines, and three places in it carry the lesson: the `RULES` list, which is the data; the one-line `_OFFENDERS` query template below it; and `_check`, which evaluates one rule and attributes the offending edge. The two `record()` declarations and the two reporting functions are worth a skim and nothing more.

## Rules as data

```python theme={null}
LayeringRule = record(
    name = field(str),
    rationale = field(str),
    targets = field(str),
    must_not_depend_on = field(str),
)
```

`record()` and `field()` are AXL, not Bazel Starlark — one of the places the dialects differ. The rules themselves are a list at the top of the file, so adding one is a list entry and no code:

```python theme={null}
LayeringRule(
    name = "cron-stays-offline",
    rationale = "cmd/cron is a scheduler: it must start without a database, " +
                "a queue broker or an HTTP listener, so it may not link them in.",
    targets = "//cmd/cron/...",
    must_not_depend_on = "//lib/store/... + //lib/queue/... + //lib/api/...",
)
```

The `rationale` is printed when the rule fires, so the person who tripped it learns the intent rather than just the verdict.

## Run it

```shell theme={null}
aspect policy
```

```text theme={null}
→ 🎬 Running policy task
3 rule(s) checked, no violations.

→ ✅ Passed policy task in 246ms
```

Three rules, one `bazel query` each, under a second.

## Break it

```shell theme={null}
git apply .course/exercises/policy-violation.patch
```

Two lines of Go and one BUILD dep: `lib/validate` starts logging. **It compiles** — `bazel build //lib/validate:validate` is perfectly happy. The compiler has no opinion about your architecture.

```shell theme={null}
aspect policy; echo "exit=$?"
```

```text theme={null}
→ 🎬 Running policy task
VIOLATION lib/validate/BUILD.bazel:3:11: //lib/validate:validate (go_library) depends on //lib/log:log [foundation-libs-stay-leaf]
VIOLATION lib/validate/BUILD.bazel:15:8: //lib/validate:validate_test (go_test) depends on //lib/log:log (via //lib/validate:validate) [foundation-libs-stay-leaf]
  rule foundation-libs-stay-leaf: clock/codec/schema/validate are the bottom of the stack; if they can log or emit metrics, every other library inherits a dependency on the observability stack.
3 rule(s) checked, 2 violation(s).

→ ❌ Failed policy task (exit code 1) in 461ms
exit=1
```

One edit, two violations — the direct one on the library and a transitive one on its test. **Exit 1**, which is what makes this a gate you can put in a pre-push hook.

```shell theme={null}
git checkout -- lib/validate/
```

## The query

```text theme={null}
rdeps(//..., (deny)) intersect (targets) except (deny)
```

Three parts: find everything that reaches the denied targets, keep only the ones inside the layer, and drop the denied targets themselves.

**Why `//...` and not `targets` as the universe.** It reads like it matters and it doesn't — the two are equivalent here, which is worth knowing because the reasoning is a trap people fall into. `rdeps(u, x)` searches the reverse dependencies of `x` within the **transitive closure** of `u`, not within the literal patterns you wrote. So a universe of just `targets` still walks everything the layer depends on, including an intermediate *outside* the layer. Try it: give `lib/validate` a dependency on `lib/retry`, which reaches `lib/log`, and both spellings return the same two targets even though `lib/retry` is in no rule's `targets`. The `intersect` is what narrows the answer, so `//...` is the plainest universe to write.

**`except (deny)`** drops the seed set, which `rdeps` always returns. For every rule in this file it changes nothing, because no rule's `targets` overlaps its `deny` — `targets intersect deny` is empty, so the `intersect` has already removed them. It earns its place the moment you write a rule where the two patterns do overlap, which is easy to do by accident:

```text theme={null}
targets = "//lib/..."           # includes lib/log
must_not_depend_on = "//lib/log/..."
```

Without `except (deny)`, `//lib/log:log` reports itself as its own violation.

## Typed results, again

Naming the offending edge needs no text parsing:

| | |
| - | - |
| `.name` | `"//lib/validate:validate"` |
| `.rule_class` | `"go_library"` — note it's not `.kind` |
| `.rule_input` | the target's direct inputs, as labels |
| `.location` | the BUILD file line |

Intersecting `.rule_input` with the denied set identifies the edge directly. Only an *indirect* violation needs a second `somepath` query.

The queries run with `--noimplicit_deps`, which matters twice: a toolchain edge isn't an architecture decision anybody made, and without it `.rule_input` comes back with ten labels instead of four — most of them `@rules_go` and `@bazel_tools` noise.

## Keeping the top level tidy

`changed`, `impact` and `policy` have all landed at the top level, beside `build`, `test` and everything the CLI already ships. That does not scale: a repository with twenty commands of its own buries the ones people use daily.

`group` nests a task under a parent. Had `policy.axl` declared the task this way —

```python theme={null}
check = task(
    group = ["arch"],
    summary = "Enforce the layering rules",
    implementation = _impl,
)
```

— the verb would be `aspect arch check` rather than `aspect check`. The variable name is still the last word; the group is the path to it.

The CLI already does this with its own commands, which is what `aspect --help` shows under **Task Groups**:

```text theme={null}
  auth      configure, login, logout, remove, status, use
  axl       add
  cache     diff
  worktree  add, inspect, list, path, prune, release
```

`aspect worktree` on its own lists what is in that group. Seven of the CLI's own groups are built this way, so the top level stays a page of categories rather than a wall of thirty-three verbs. Groups nest up to five levels — `group = ["arch", "deps"]` gives `aspect arch deps check`.

<Note>
  This one is a read, not an exercise. The task in `.aspect/policy.axl` is named `policy` with no group, and the next section still asks you to run `aspect policy` — so leave it alone for now. Renaming it to `check` under `arch` would work, and would also make `aspect policy` stop existing.
</Note>

## Your turn

Open `.aspect/policy.axl` and append a fourth rule:

```python theme={null}
LayeringRule(
    name = "libs-never-import-domain",
    rationale = "lib/ is generic; internal/domain/ is not.",
    targets = "//lib/...",
    must_not_depend_on = "//internal/domain/...",
)
```

Run `aspect policy` again. It fails — and the output tells you why, which is a genuine fact about this repository you have not been told yet.

Next: stop asking Bazel questions, and start watching it work.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.