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 ID | Description | Detection Method |
|---|---|---|
layer-boundary-violation | Import crosses a forbidden layer boundary | Import graph + policy rules |
circular-dependency | Two or more files form an import cycle | Tarjan's SCC algorithm |
module-boundary-violation | Direct import between modules bypassing contracts | Import graph + module patterns |
forbidden-framework-in-layer | Framework/infrastructure package imported in wrong layer | NPM 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:
| Package | Mapped Layer |
|---|---|
@prisma/client, typeorm, sequelize, mongoose, drizzle-orm, knex | infrastructure |
pg, mysql2, mongodb, ioredis, redis | infrastructure |
bullmq, bull, amqplib | infrastructure |
@nestjs/common, @nestjs/core, express, fastify, koa, @hapi/hapi | api |
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
- The import graph builder extracts all
importandrequire()statements from changed files, producing a directed graph ofImportEdge[]. - Tarjan's algorithm runs on the adjacency list, finding all SCCs in O(V + E) time.
- SCCs with a single node (self-referencing or isolated) are discarded.
- 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.