analysis

Architecture

Four architecture rules that enforce layer boundaries, detect circular dependencies, prevent cross-module bypasses, and flag forbidden framework usage.

Architecture Rules

Architecture rules enforce the structural boundaries defined in your radar.yml. Every architecture violation is severity critical and blocks merge -- there is no warning-level for structural integrity violations.

The four rules are:

Rule IDDescriptionDetection Method
layer-boundary-violationImport crosses a forbidden layer boundaryImport graph + policy rules
circular-dependencyTwo or more files form an import cycleTarjan's SCC algorithm
module-boundary-violationDirect import between modules bypassing contractsImport graph + module patterns
forbidden-framework-in-layerFramework/infrastructure package imported in wrong layerNPM package layer mapping

Layer Boundary Violations

Layer violations occur when a file in one layer imports from a layer it is not allowed to depend on. Layers and their allowed dependencies are declared in radar.yml:

layers:
  - name: domain
    path: "src/**/domain/**"
  - name: application
    path: "src/**/application/**"
  - name: infrastructure
    path: "src/**/infrastructure/**"
  - name: api
    path: "src/**/api/**"

rules:
  - type: deny
    source: domain
    target: infrastructure
    description: "Domain must not depend on infrastructure"
  - type: deny
    source: domain
    target: api
    description: "Domain must not depend on API layer"
  - type: deny
    source: application
    target: api
    description: "Application must not depend on API layer"

Violation Example

// src/orders/domain/order.entity.ts
// VIOLATION: domain layer importing from infrastructure
import { PrismaClient } from '@prisma/client';

export class Order {
  constructor(private prisma: PrismaClient) {}

  async calculateTotal(): Promise<number> {
    // Domain logic coupled to database implementation
    const items = await this.prisma.orderItem.findMany({
      where: { orderId: this.id },
    });
    return items.reduce((sum, item) => sum + item.price * item.qty, 0);
  }
}

Correct Version

// src/orders/domain/order.entity.ts
// Domain layer depends only on domain abstractions
import { OrderItem } from './order-item.entity';

export class Order {
  constructor(private items: OrderItem[]) {}

  calculateTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price * item.qty, 0);
  }
}

// src/orders/domain/ports/order.repository.ts
// Port (interface) lives in domain -- infrastructure implements it
export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  findItems(orderId: string): Promise<OrderItem[]>;
}
// src/orders/infrastructure/prisma-order.repository.ts
// Infrastructure implements the domain port
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma.service';
import { OrderRepository } from '../domain/ports/order.repository';

@Injectable()
export class PrismaOrderRepository implements OrderRepository {
  constructor(private prisma: PrismaService) {}

  async findById(id: string): Promise<Order | null> {
    return this.prisma.order.findUnique({ where: { id } });
  }
}

NPM Package Layer Mapping

Radar automatically maps common NPM packages to their logical layer. Importing these packages from a forbidden layer triggers a violation even without explicit import graph edges:

PackageMapped Layer
@prisma/client, typeorm, sequelize, mongoose, drizzle-orm, knexinfrastructure
pg, mysql2, mongodb, ioredis, redisinfrastructure
bullmq, bull, amqplibinfrastructure
@nestjs/common, @nestjs/core, express, fastify, koa, @hapi/hapiapi

The domain and application layers are forbidden from importing infrastructure packages by default. This catches cases where a developer imports @prisma/client directly in a domain service even if the file path resolution would not create an import graph edge.

Tip: HTTP client libraries (axios, got, node-fetch) are intentionally not mapped to infrastructure. They are general-purpose and legitimately used in controllers for BFF/proxy patterns. Misuse of HTTP clients is caught by reliability rules instead (missing timeouts, missing error handling).

Circular Dependencies

Circular dependencies are detected using Tarjan's strongly connected components (SCC) algorithm. Any SCC with more than one node represents a cycle.

How Detection Works

  1. The import graph builder extracts all import and require() statements from changed files, producing a directed graph of ImportEdge[].
  2. Tarjan's algorithm runs on the adjacency list, finding all SCCs in O(V + E) time.
  3. SCCs with a single node (self-referencing or isolated) are discarded.
  4. Entity-only cycles are exempted -- TypeORM/Sequelize bidirectional relations (@ManyToOne/@OneToMany) between entity files are standard ORM practice.

Violation Example

// src/auth/auth.service.ts
import { UserService } from '../user/user.service';     // auth → user

@Injectable()
export class AuthService {
  constructor(private userService: UserService) {}

  async validateUser(token: string): Promise<User> {
    return this.userService.findByToken(token);
  }
}
// src/user/user.service.ts
import { AuthService } from '../auth/auth.service';     // user → auth (CYCLE!)

@Injectable()
export class UserService {
  constructor(private authService: AuthService) {}

  async findByToken(token: string): Promise<User> {
    const payload = await this.authService.decodeToken(token);
    return this.userRepo.findById(payload.userId);
  }
}

This creates the cycle: auth.service.ts -> user.service.ts -> auth.service.ts

The violation message includes the full cycle path and file count:

Circular dependency detected (2 files): auth.service.ts → user.service.ts → auth.service.ts

Correct Version

Break the cycle by extracting the shared concern into a separate module or using an event:

// src/auth/token.service.ts  (extracted -- no circular dependency)
@Injectable()
export class TokenService {
  decodeToken(token: string): TokenPayload { /* ... */ }
  validateToken(token: string): boolean { /* ... */ }
}
// src/user/user.service.ts -- depends on TokenService, not AuthService
import { TokenService } from '../auth/token.service';

@Injectable()
export class UserService {
  constructor(
    private tokenService: TokenService,
    private userRepo: UserRepository,
  ) {}

  async findByToken(token: string): Promise<User> {
    const payload = this.tokenService.decodeToken(token);
    return this.userRepo.findById(payload.userId);
  }
}

Entity Cycle Exemption

Bidirectional ORM relations are automatically exempted. The following cycle is not flagged:

// src/user/entities/user.entity.ts
import { Order } from '../../order/entities/order.entity';

@Entity()
export class User {
  @OneToMany(() => Order, order => order.user)
  orders: Order[];
}
// src/order/entities/order.entity.ts
import { User } from '../../user/entities/user.entity';

@Entity()
export class Order {
  @ManyToOne(() => User, user => user.orders)
  user: User;
}

Cross-Module Boundary Violations

When modules are declared in radar.yml, Radar enforces that modules communicate only through their public contracts (barrel exports, interfaces, events) -- not through direct internal imports.

modules:
  - name: orders
    path: "src/orders/**"
  - name: billing
    path: "src/billing/**"
  - name: users
    path: "src/users/**"

rules:
  - type: deny
    source: "*"
    target: "*"
    description: "No direct cross-module imports"
  - type: allow
    source: "*"
    target: "*"
    through: "**/contracts/**"
    description: "Cross-module allowed through contracts"

Violation Example

// src/billing/services/invoice.service.ts
// VIOLATION: billing directly imports from orders internals
import { OrderRepository } from '../../orders/infrastructure/order.repository';

@Injectable()
export class InvoiceService {
  constructor(private orderRepo: OrderRepository) {}

  async generateInvoice(orderId: string): Promise<Invoice> {
    const order = await this.orderRepo.findById(orderId);
    // ...
  }
}

Correct Version

// src/orders/contracts/order.contract.ts  (public contract)
export interface OrderContract {
  getOrder(id: string): Promise<OrderDTO>;
}
// src/billing/services/invoice.service.ts
// Allowed: imports through the contracts path
import { OrderContract } from '../../orders/contracts/order.contract';

@Injectable()
export class InvoiceService {
  constructor(
    @Inject('OrderContract') private orderContract: OrderContract,
  ) {}

  async generateInvoice(orderId: string): Promise<Invoice> {
    const order = await this.orderContract.getOrder(orderId);
    // ...
  }
}

Feature-Module Exemptions

When the architecture: feature-module preset is active, standard NestJS cross-module patterns are automatically exempted:

  • Module files (*.module.ts) importing from other modules (DI wiring)
  • Entity files (*.entity.ts) cross-referencing for ORM relations
  • DTO files (*.dto.ts) shared across modules
  • Interface/decorator files (*.interface.ts, *.decorator.ts)
  • Shared directory (/shared/) imports

Forbidden Framework in Layer

This rule detects when framework-specific packages are imported in layers where they do not belong. It works through the NPM package layer mapping described above.

Violation Example

// src/orders/domain/services/pricing.service.ts
// VIOLATION: domain service importing NestJS framework decorator
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../shared/prisma.service';

@Injectable()
export class PricingService {
  constructor(private prisma: PrismaService) {}
  // Domain logic coupled to both framework and database
}

Correct Version

// src/orders/domain/services/pricing.service.ts
// Pure domain service -- no framework dependencies
export class PricingService {
  calculateDiscount(order: Order, customer: Customer): number {
    if (customer.tier === 'gold' && order.total > 500) {
      return order.total * 0.15;
    }
    return 0;
  }
}
// src/orders/infrastructure/nest-pricing.provider.ts
// Framework binding lives in infrastructure
import { Injectable } from '@nestjs/common';
import { PricingService } from '../domain/services/pricing.service';

@Injectable()
export class NestPricingProvider extends PricingService {}

Configuration Reference

All architecture rules can be fine-tuned through exceptions in radar.yml:

exceptions:
  - rule: layer-boundary-violation
    file: "src/legacy/**"
    expires: "2026-06-01"
    reason: "Legacy module pending migration to clean architecture"

  - rule: circular-dependency
    file: "src/auth/**"
    expires: "2026-04-15"
    reason: "Auth/User cycle will be resolved in Q2 refactor"

Exceptions have mandatory expiration dates. Once expired, the violations surface again and block merge.

Technical Debt Radar Documentation