Guides

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 -> B means "A cannot import from B" (deny) or "A can import from B" (allow)
  • A <- B means "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
FieldTypeRequiredDescription
typedeny or allowYesWhether to forbid or permit the import
sourcestringYesSource layer/module name, or cross-module
targetstringYesTarget layer/module name, cross-module, or __db__
throughstringNoGlob pattern for allowed import path
descriptionstringNoHuman-readable explanation
severitystringNocritical, 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:

  1. Is the import a cross-module import? (Yes --- orders importing from billing)
  2. Is there a deny: cross-module direct imports rule? (If yes, check for exceptions)
  3. Does the import path match any allow: cross-module through pattern? (If yes, permit it)
  4. 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:

LevelGate BehaviorPR Comment
criticalBlocks merge (if category gate is active)Red badge
warningAdds to score, does not independently blockYellow badge
infoDoes not add to scoreGray badge
ignoreNot reportedNot 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 expires field causes radar validate to fail. This is intentional.
  • Missing reason field causes radar validate to 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 source and target values reference defined layers or modules (or reserved keywords like cross-module, __db__)
  • Exception dates are valid and in the future
  • No conflicting rules (deny + allow for the exact same path without a through qualifier)
  • 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
Technical Debt Radar Documentation