Custom Rules
How to write custom architecture rules in rules.yml, including deny/allow syntax, severity overrides, exception rules with expiry, and testing custom rules.
Custom Rules
Technical Debt Radar ships with built-in rule packs for common architectures, but every codebase has unique constraints. The rules.yml file lets you define custom dependency rules, severity overrides, and exception policies that match your specific architecture.
Rule Syntax
Custom rules are defined in the architecture_rules section of rules.yml. Two syntaxes are supported: shorthand and structured.
Shorthand Syntax
The shorthand syntax is concise and readable. It covers the most common use cases:
architecture_rules:
# Deny: source cannot import from target
- deny: domain -> infrastructure
- deny: domain -> api
# Reverse deny: target cannot import from source
- deny: application <- domain
# Allow: explicitly permit an import path (overrides deny)
- allow: infrastructure -> domain
# Cross-module: deny all direct imports between modules
- deny: cross-module direct imports
# Cross-module: allow imports through specific paths
- allow: cross-module through "src/**/contracts/**"
Arrow direction:
A -> Bmeans "A cannot import from B" (deny) or "A can import from B" (allow)A <- Bmeans "B cannot import from A" --- reverse direction for readability
Structured Syntax
The structured syntax provides more control, including descriptions and custom severity:
architecture_rules:
- type: deny
source: domain
target: infrastructure
description: "Domain layer must not depend on infrastructure details"
severity: critical
- type: allow
source: cross-module
target: cross-module
through: "src/**/contracts/**"
description: "Cross-module imports permitted through contracts"
- type: deny
source: controllers
target: __db__
description: "Controllers must not access the database directly"
severity: critical
| Field | Type | Required | Description |
|---|---|---|---|
type | deny or allow | Yes | Whether to forbid or permit the import |
source | string | Yes | Source layer/module name, or cross-module |
target | string | Yes | Target layer/module name, cross-module, or __db__ |
through | string | No | Glob pattern for allowed import path |
description | string | No | Human-readable explanation |
severity | string | No | critical, warning, or info. Default: critical |
The __db__ Target
The special target __db__ represents direct database/ORM package imports. This lets you control which layers can access the ORM:
architecture_rules:
# Only infrastructure layer can import Prisma/Sequelize/TypeORM directly
- deny: domain -> __db__
- deny: application -> __db__
- deny: controllers -> __db__
# Infrastructure is the only layer allowed to use the ORM
- allow: infrastructure -> __db__
Radar recognizes imports from @prisma/client, sequelize, typeorm, mongoose, drizzle-orm, knex, and mikro-orm as database imports.
Cross-Module Rules
Cross-module rules govern imports between bounded contexts or feature modules:
architecture_rules:
# Block all direct cross-module imports
- deny: cross-module direct imports
# Allow through contracts directories
- allow: cross-module through "src/**/contracts/**"
# Allow through a shared kernel
- allow: cross-module through "src/shared/**"
# Allow specific module pairs (when one module depends on another)
- type: allow
source: orders
target: billing
through: "src/billing/contracts/**"
description: "Orders module can use billing contracts"
How Cross-Module Detection Works
Radar determines module membership by matching file paths against the modules section in radar.yml. When a file in src/orders/ imports from src/billing/, Radar checks:
- Is the import a cross-module import? (Yes ---
ordersimporting frombilling) - Is there a
deny: cross-module direct importsrule? (If yes, check for exceptions) - Does the import path match any
allow: cross-module throughpattern? (If yes, permit it) - If no allow pattern matches, flag as a violation.
Custom Severity Overrides
Override the default severity for specific violation types:
severity_overrides:
# Treat unbounded queries on M-sized tables as critical (default: warning)
performance_risk_M: critical
# Treat missing try/catch as critical (default: warning)
missing-try-catch: critical
# Treat code duplication as info instead of warning
code-duplication: info
# Treat dead code as ignored (not reported at all)
dead-code: ignore
Severity levels:
| Level | Gate Behavior | PR Comment |
|---|---|---|
critical | Blocks merge (if category gate is active) | Red badge |
warning | Adds to score, does not independently block | Yellow badge |
info | Does not add to score | Gray badge |
ignore | Not reported | Not shown |
Exception Rules with Expiry
Exceptions temporarily suppress a rule for a specific file. Every exception requires an expiration date --- there are no permanent exceptions.
exceptions:
- rule: "domain -> infrastructure"
file: "src/billing/domain/legacy-adapter.ts"
expires: "2026-06-01"
reason: "Legacy billing migration — JIRA-1234"
- rule: "cross-module direct imports"
file: "src/orders/use-cases/sync-billing.use-case.ts"
expires: "2026-04-15"
reason: "Temporary cross-module call until events are implemented — JIRA-5678"
- rule: "controllers -> __db__"
file: "src/controllers/admin-report.controller.ts"
expires: "2026-05-01"
reason: "Admin endpoint accesses DB directly — needs service layer — JIRA-9012"
Exception Behavior
- Active exceptions suppress the rule for the specified file. The violation is tracked internally (visible in the dashboard as "excepted") but does not count toward the gate score.
- Expired exceptions are ignored. The violation starts counting again the day after expiration.
- Missing
expiresfield causesradar validateto fail. This is intentional. - Missing
reasonfield causesradar validateto warn. Reasons are strongly recommended for auditability.
Wildcard Exceptions
Use glob patterns in the file field to except multiple files:
exceptions:
- rule: "domain -> infrastructure"
file: "src/billing/domain/legacy-*.ts"
expires: "2026-06-01"
reason: "All legacy adapters in billing domain — JIRA-1234"
Combining Custom Rules with Built-In Packs
Rule packs provide a starting point. Custom rules in your rules.yml are merged with pack rules:
# Install a pack
radar pack install nestjs-prisma-ddd
# The pack creates/updates rules.yml with its rules
# You can then add custom rules on top
When merging:
- Deny rules are additive. A deny from the pack and a deny from your custom rules both apply.
- Allow rules are additive. An allow from your custom rules can open a path that the pack denies.
- If the same path has both deny and allow, deny wins (security-first).
- Custom severity overrides take precedence over pack defaults.
Example: the nestjs-prisma-ddd pack denies domain -> infrastructure. You can add an allow for a specific path:
architecture_rules:
# Pack rules (auto-generated, do not edit this section)
- deny: domain -> infrastructure
- deny: domain -> api
- deny: domain -> __db__
# Custom rules (add your rules below this line)
- type: allow
source: domain
target: infrastructure
through: "src/**/domain/adapters/**"
description: "Domain adapters can reference infra interfaces"
Testing Custom Rules
Validate your rules before committing:
# Validate syntax and cross-references
radar validate
The validator checks:
- YAML syntax is valid
- All
sourceandtargetvalues reference defined layers or modules (or reserved keywords likecross-module,__db__) - Exception dates are valid and in the future
- No conflicting rules (deny + allow for the exact same path without a
throughqualifier) - Severity overrides reference valid rule names
Test Against Your Codebase
Run a scan to see how your rules apply:
# Full scan with rule details
radar scan . --verbose
# Check a single file
radar check src/orders/domain/order.service.ts
# Dry run — show what would be flagged without posting results
radar scan . --dry-run
Test a Specific Rule
Use radar check to test individual files against your rules:
$ radar check src/billing/domain/billing.service.ts
File: src/billing/domain/billing.service.ts
Layer: domain (matched "src/**/domain/**")
Module: billing (matched "src/billing/**")
Imports:
✓ ../domain/invoice.entity.ts → domain → domain (allowed)
✗ ../../shared/prisma.service.ts → domain → __db__ (DENIED)
✓ ../../orders/contracts/order.types → cross-module through contracts (allowed)
Rule Examples by Architecture
DDD (Domain-Driven Design)
architecture_rules:
- deny: domain -> infrastructure
- deny: domain -> api
- deny: domain -> __db__
- deny: application -> api
- deny: api -> infrastructure
- deny: cross-module direct imports
- allow: cross-module through "src/**/contracts/**"
Hexagonal (Ports and Adapters)
architecture_rules:
- deny: domain -> adapters
- deny: domain -> __db__
- deny: ports -> adapters
- deny: adapters -> domain # Adapters use ports, not domain directly
- allow: adapters -> ports
Clean Architecture
architecture_rules:
- deny: domain -> use-cases
- deny: domain -> adapters
- deny: domain -> infrastructure
- deny: domain -> __db__
- deny: use-cases -> adapters
- deny: use-cases -> infrastructure
Feature-Module
architecture_rules:
- deny: cross-module direct imports
- allow: cross-module through "src/shared/**"
# Each feature module is self-contained
# No layer rules within modules — teams decide internally