# Use case: architecture rules
The Architecture dimension is rustqual's "I want this codebase to look like *that* in five years" enforcement layer. Where IOSP, complexity, and SRP catch *local* smells, architecture rules catch the *global* drift — the kind that turns a clean hexagonal design into a tangle of cross-imports over six months.
Architecture rules are config-driven. You write them once in `rustqual.toml`, and they apply on every analysis run. If a PR violates a rule, CI fails. There's no "I'll fix it later" — the rule is the source of truth.
## What you can enforce
- **Layers** — domain → ports → infrastructure → application. Inner layers can't import outer ones.
- **Forbidden edges** — `analyzer X` cannot import `analyzer Y`, regardless of layer. Specific cross-cuts.
- **Symbol patterns** — where specific paths/macros/methods are allowed (e.g., `syn::` only in adapters, `unwrap()` only in tests, `println!` only in CLI).
- **Trait contracts** — every port trait must be object-safe, `Send + Sync`, and not leak adapter error types.
These are independent rule families. You can use any combination. Most projects start with layers, add forbidden edges as they discover specific cross-cuts to forbid, and add symbol patterns when a particular antipattern keeps coming back.
## Layers
The defining structural rule. Inner layers know nothing of outer ones; outer layers can use inner ones freely.
```toml
[architecture.layers]
order = ["domain", "ports", "infrastructure", "analysis", "application"]
unmatched_behavior = "strict_error" # files outside any layer fail the dim
[architecture.layers.domain]
paths = ["src/domain/**"]
[architecture.layers.ports]
paths = ["src/ports/**"]
[architecture.layers.infrastructure]
paths = ["src/adapters/config/**", "src/adapters/source/**"]
[architecture.layers.analysis]
paths = ["src/adapters/analyzers/**", "src/adapters/report/**"]
[architecture.layers.application]
paths = ["src/app/**"]
```
A `domain` file importing from `application` fails. An `application` file importing from `domain` is fine. Same-layer imports are always fine.
### `unmatched_behavior`
Three options:
- `"strict_error"` — every production file must match a layer (hard finding otherwise). Recommended; flags new files dropped in arbitrary locations.
- `"composition_root"` — unmatched files act as the composition root and may import any layer.
- `"warn"` — soft warning instead of error.
### Re-export points
Some files (`lib.rs`, `main.rs`, `bin/**`, `cli/**`, `tests/**`) live at the root and re-export from every layer. Mark them explicitly:
```toml
[architecture.reexport_points]
paths = ["src/lib.rs", "src/main.rs", "src/bin/**", "src/cli/**", "tests/**"]
```
### Workspaces
For multi-crate workspaces, map external crates to layers:
```toml
[architecture.external_crates]
my_domain_types = "domain"
my_port_traits = "ports"
```
Now `infrastructure` can import `my_domain_types` (lower-rank, allowed) but not the other way around.
## Forbidden edges
Where layers are too coarse, forbidden edges name specific source/destination pairs:
```toml
[[architecture.forbidden]]
from = "src/adapters/analyzers/iosp/**"
to = "src/adapters/analyzers/**"
except = ["src/adapters/analyzers/iosp/**"]
reason = "Dimension analyzers don't know each other"
[[architecture.forbidden]]
from = "src/adapters/**"
to = "src/app/**"
reason = "Adapters know domain + ports, not application"
```
Each rule has `from`, `to`, an optional `except` list, and a human-readable `reason` that shows up in the finding.
## Symbol patterns
The most flexible family — you can forbid specific path prefixes, method calls, macro calls, or glob imports in specific directories:
```toml
# AST types only in adapters
[[architecture.pattern]]
name = "no_syn_in_domain"
forbid_path_prefix = ["syn::", "proc_macro2::", "quote::"]
forbidden_in = ["src/domain/**"]
reason = "Domain has no AST representation"
# unwrap()/expect() only in tests
[[architecture.pattern]]
name = "no_panic_helpers_in_production"
forbid_method_call = ["unwrap", "expect"]
forbidden_in = ["src/**"]
except = ["**/tests/**"]
reason = "Production propagates errors typed instead of panicking"
# println!/print!/dbg! only in CLI/binaries
[[architecture.pattern]]
name = "no_stdout_in_library_code"
forbid_macro_call = ["println", "print", "dbg"]
forbidden_in = ["src/**"]
allowed_in = ["src/main.rs", "src/bin/**", "src/cli/**"]
reason = "stdout is the CLI's channel, not library code's"
# No glob imports in domain
[[architecture.pattern]]
name = "no_glob_imports_in_domain"
forbid_glob_import = true
forbidden_in = ["src/domain/**"]
reason = "Glob imports hide layer tunneling"
```
Each rule carries `forbidden_in` (where it fires) and optional `allowed_in`/`except` (where it's exempted). The `reason` field is mandatory and shows up in the finding so reviewers know *why*.
## Trait contracts
The most prescriptive family — used to keep port traits clean:
```toml
[[architecture.trait_contract]]
name = "port_traits"
scope = "src/ports/**"
receiver_may_be = ["shared_ref"] # only &self, no &mut self / self
forbidden_return_type_contains = [
"anyhow::", "Box<dyn", # no untyped errors, no boxed dyns
]
forbidden_error_variant_contains = [
"syn::", "toml::", "serde_json::", # adapter errors don't leak
]
must_be_object_safe = true # for dyn dispatch
required_supertraits_contain = ["Send", "Sync"]
```
This catches a port that someone "almost" got right — for example, a port trait that accidentally exposes `&mut self` or returns `anyhow::Result<…>` instead of a typed error.
Plus the structural binary check `BTC` (broken trait contract) flags impls that are entirely stubs (`unimplemented!`, `todo!`, `Default::default()` only).
## What you'll see
In `--findings` (one-line) output, real findings look like:
```
src/domain/order.rs:5 ARCHITECTURE layer rank 0 ↛ rank 2 via crate::adapters::source::io: layer rule
src/adapters/analyzers/iosp/visitor.rs:3 ARCHITECTURE forbidden import crate::adapters::analyzers::dry::mod: forbidden edge
src/auth/session.rs:88 ARCHITECTURE shared method call unwrap: Production propagates errors typed instead of panicking
src/ports/storage.rs:42 ARCHITECTURE trait Storage [forbidden_return_type_contains]: anyhow::: trait contract
```
JSON / SARIF / `--format github` use machine-readable rule IDs:
`architecture/layer`, `architecture/forbidden`,
`architecture/pattern/<rule-name>` (e.g.
`architecture/pattern/no_panic_helpers_in_production`),
`architecture/trait_contract/<check>`. See
[reference-rules.md](./reference-rules.md) for the full list.
## Diagnostic mode
`rustqual --explain src/some/file.rs` prints which layer the file matches, which symbol rules apply, and what would change if you moved it. Useful when you can't tell why a file is failing.
## Configure
```toml
[architecture]
enabled = true
# Then add layers, forbidden edges, patterns, trait_contract sections
```
`--init` doesn't generate architecture rules — they require an opinion about your design that the tool can't infer. Add them manually, ratchet up over time.
## Suppression
Architecture is suppression-resistant by design. The `// qual:allow(architecture)` annotation works at the import site or item, but it counts hard against `max_suppression_ratio`, and you should leave a `reason:` rationale in the comment block:
```rust
// qual:allow(architecture) — port adapter must call into the registry directly
// here for serialization round-trip; pure domain accessor would lose ordering.
use crate::adapters::registry::lookup;
```
The right answer is usually to widen the rule (with a clear `except` clause) or move the file, not to suppress.
## Why this is unusual
Most static analyzers ship per-function rules and stop there. Architecture linters (ArchUnit, dependency-cruiser) prove what *can't* be called. rustqual's architecture dimension does both directions:
- **Negative space** (forbidden edges, layer rules, symbol patterns) — what mustn't happen.
- **Positive space** (call parity — see [adapter-parity.md](./adapter-parity.md)) — what *must* happen across multiple adapters.
The combination is what makes drift mechanically detectable rather than review-dependent.
## Related
- [adapter-parity.md](./adapter-parity.md) — call parity, the architecture rule that's unique to rustqual
- [coupling-quality.md](./coupling-quality.md) — metric-based coupling (instability, SDP)
- [reference-rules.md](./reference-rules.md) — every rule code with details
- [reference-configuration.md](./reference-configuration.md) — every config option