Guides

NestJS + Prisma + DDD

Step-by-step guide to configuring Technical Debt Radar for a NestJS backend with Prisma ORM and Domain-Driven Design architecture.

NestJS + Prisma + DDD

This guide walks through setting up Technical Debt Radar for the most common enterprise Node.js stack: NestJS as the framework, Prisma as the ORM, and Domain-Driven Design (DDD) as the architectural pattern.

By the end, you will have a fully configured radar.yml and rules.yml that enforce DDD layer boundaries, detect Prisma-specific performance anti-patterns, and block dangerous runtime code from reaching production.


Expected Folder Structure

DDD projects organize code by bounded context (module), with each module containing the same layer structure:

src/
├── billing/
│   ├── controllers/        # API layer — HTTP handlers
│   │   └── invoice.controller.ts
│   ├── use-cases/          # Application layer — orchestration
│   │   └── create-invoice.use-case.ts
│   ├── domain/             # Domain layer — business logic
│   │   ├── invoice.entity.ts
│   │   ├── invoice.service.ts
│   │   └── invoice.repository.ts   # Interface only
│   ├── infra/              # Infrastructure layer — implementations
│   │   ├── prisma-invoice.repository.ts
│   │   └── invoice.module.ts
│   └── contracts/          # Cross-module shared types
│       └── billing.contracts.ts
├── orders/
│   ├── controllers/
│   ├── use-cases/
│   ├── domain/
│   ├── infra/
│   └── contracts/
├── identity/
│   ├── controllers/
│   ├── use-cases/
│   ├── domain/
│   ├── infra/
│   └── contracts/
└── shared/                 # Shared kernel — cross-cutting types
    ├── types/
    ├── utils/
    └── constants/

The key principle: domain and application layers must never depend on infrastructure or API layers. Cross-module communication happens only through contracts/ directories.


Configuration Files

radar.yml

stack:
  language: TypeScript
  framework: NestJS
  orm: Prisma
  build_tool: Nx          # or Turborepo, or omit
  structure: modular-monolith
  runtime: node

architecture: ddd

layers:
  - name: api
    path: "src/**/controllers/**"
  - name: application
    path: "src/**/use-cases/**"
  - name: domain
    path: "src/**/domain/**"
  - name: infrastructure
    path: "src/**/infra/**"

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

data_volumes:
  users: M             # 10K–100K rows
  orders: L            # 100K–1M rows
  invoices: L
  events: XL           # 1M–50M rows
  audit_logs: XXL      # 50M+ rows

exceptions:
  - rule: "domain -> infrastructure"
    file: "src/billing/domain/legacy-adapter.ts"
    expires: "2026-06-01"
    reason: "Legacy billing integration — JIRA-1234"

rules.yml

architecture_rules:
  # DDD core: domain layer is pure
  - deny: domain -> infrastructure
  - deny: domain -> api
  - deny: domain -> __db__           # No direct ORM imports in domain

  # Application layer orchestrates, does not serve HTTP
  - deny: application -> api

  # API layer calls application, not infrastructure directly
  - deny: api -> infrastructure

  # Module isolation
  - deny: cross-module direct imports
  - allow: cross-module through "src/**/contracts/**"
  - allow: cross-module through "src/shared/**"

runtime_rules:
  block:
    - sync-fs-in-handler
    - sync-crypto
    - sync-compression
    - redos-vulnerable-regex
    - busy-wait-loop
    - unhandled-promise
  warn:
    - unbounded-json-parse
    - large-json-stringify
    - cpu-heavy-loop-in-handler

reliability_rules:
  block:
    - unhandled-promise-rejection
  warn:
    - missing-try-catch
    - external-call-no-timeout
    - empty-catch-block
    - transaction-no-timeout

gates:
  block_merge:
    - architecture_violations > 0
    - circular_dependencies_introduced > 0
    - runtime_risk_critical > 0
    - reliability_critical > 0
    - critical_performance_risk > 0
    - debt_delta_score > 15
  warn:
    - complexity_increase > 5
    - coverage_drop > 2%
    - debt_delta_score > 8

standards:
  error_handling:
    - all use-cases must have try/catch with typed errors
    - no swallowed exceptions
  naming:
    - "use-cases: <Verb><Noun>UseCase"
    - "repositories: <Entity>Repository"
    - "controllers: <Entity>.controller.ts"
  transactions:
    - Prisma transactions only in infrastructure layer
  dto_mapping:
    - no Prisma types in application or domain layer

DDD Layer Rules Explained

Domain Layer: Zero External Dependencies

The domain layer contains your business logic, entities, value objects, and repository interfaces. It must have no knowledge of how data is stored, how HTTP requests are handled, or which ORM you use.

// src/orders/domain/order.repository.ts
// This is an INTERFACE — no Prisma imports here
export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<Order>;
  findByCustomer(customerId: string, pagination: Pagination): Promise<Order[]>;
}
// src/orders/domain/order.service.ts
import { OrderRepository } from './order.repository';
import { Order } from './order.entity';

// Domain service depends only on domain types
export class OrderService {
  constructor(private readonly orderRepo: OrderRepository) {}

  async cancelOrder(orderId: string): Promise<Order> {
    const order = await this.orderRepo.findById(orderId);
    if (!order) throw new OrderNotFoundError(orderId);
    order.cancel();
    return this.orderRepo.save(order);
  }
}

Radar blocks any import that violates this:

// VIOLATION: domain importing from infrastructure
// src/orders/domain/order.service.ts
import { PrismaClient } from '@prisma/client';   // blocked: domain -> __db__
import { OrderMapper } from '../infra/mappers';   // blocked: domain -> infrastructure

Infrastructure Layer: Implements Domain Interfaces

The infrastructure layer contains the concrete implementations of domain interfaces. This is where Prisma lives.

// src/orders/infra/prisma-order.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma.service';
import { OrderRepository } from '../domain/order.repository';
import { Order } from '../domain/order.entity';

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

  async findById(id: string): Promise<Order | null> {
    const record = await this.prisma.order.findUnique({ where: { id } });
    return record ? OrderMapper.toDomain(record) : null;
  }

  async findByCustomer(customerId: string, pagination: Pagination): Promise<Order[]> {
    const records = await this.prisma.order.findMany({
      where: { customerId },
      take: pagination.limit,
      skip: pagination.offset,
      orderBy: { createdAt: 'desc' },
    });
    return records.map(OrderMapper.toDomain);
  }
}

Cross-Module Communication

Modules cannot import from each other directly. Cross-module communication goes through contracts/:

// src/billing/contracts/billing.contracts.ts
export interface BillingService {
  createInvoiceForOrder(orderId: string, amount: number): Promise<string>;
}
// src/orders/use-cases/complete-order.use-case.ts
import { BillingService } from '../../billing/contracts/billing.contracts'; // allowed

export class CompleteOrderUseCase {
  constructor(
    private readonly orderRepo: OrderRepository,
    private readonly billing: BillingService,
  ) {}
}

Common Violations and Fixes

1. Prisma Types Leaking into Domain

Violation: Returning Prisma-generated types from a use case instead of domain entities.

// VIOLATION: Prisma types in application layer
import { Order as PrismaOrder } from '@prisma/client';

export class GetOrderUseCase {
  async execute(id: string): Promise<PrismaOrder> { // Prisma type leaked
    return this.prisma.order.findUnique({ where: { id } });
  }
}

Fix: Map Prisma types to domain entities in the infrastructure layer.

export class GetOrderUseCase {
  async execute(id: string): Promise<Order> {       // Domain type
    return this.orderRepo.findById(id);              // Uses repository interface
  }
}

2. Unbounded Query on Large Table

Violation: findMany() without pagination on an XL table.

// VIOLATION: unbounded query on events (XL — 1M+ rows)
const events = await this.prisma.event.findMany({
  where: { userId },
});

Fix: Add pagination and ordering.

const events = await this.prisma.event.findMany({
  where: { userId },
  take: 50,
  skip: page * 50,
  orderBy: { createdAt: 'desc' },
});

3. N+1 Query in Loop

Violation: Querying inside a loop instead of eager loading.

// VIOLATION: N+1 — one query per order
const orders = await this.prisma.order.findMany({ where: { customerId } });
for (const order of orders) {
  order.items = await this.prisma.orderItem.findMany({
    where: { orderId: order.id },
  });
}

Fix: Use Prisma include for eager loading.

const orders = await this.prisma.order.findMany({
  where: { customerId },
  include: { items: true },
  take: 50,
});

4. Sync File Read in Controller

Violation: readFileSync inside a NestJS handler.

// VIOLATION: blocks event loop
@Get('report')
getReport() {
  const template = fs.readFileSync('./templates/report.html', 'utf-8');
  return this.reportService.render(template);
}

Fix: Use async file operations.

@Get('report')
async getReport() {
  const template = await fs.promises.readFile('./templates/report.html', 'utf-8');
  return this.reportService.render(template);
}

5. Missing Error Handling in Use Case

Violation: No try/catch in an async use case.

// VIOLATION: unhandled errors
export class CreateOrderUseCase {
  async execute(dto: CreateOrderDto): Promise<Order> {
    const order = Order.create(dto);
    await this.orderRepo.save(order);
    await this.billing.createInvoice(order.id, order.total);
    return order;
  }
}

Fix: Wrap in try/catch with typed error handling.

export class CreateOrderUseCase {
  async execute(dto: CreateOrderDto): Promise<Order> {
    try {
      const order = Order.create(dto);
      await this.orderRepo.save(order);
      await this.billing.createInvoice(order.id, order.total);
      return order;
    } catch (error) {
      if (error instanceof DomainError) throw error;
      this.logger.error('Failed to create order', { dto, error });
      throw new OrderCreationError('Unexpected error during order creation', { cause: error });
    }
  }
}

Quick Start

# 1. Install the CLI
npm i -g @radar/cli

# 2. Initialize config (auto-detects NestJS + Prisma + DDD)
radar init

# 3. Install the DDD rule pack for stricter enforcement
radar pack install nestjs-prisma-ddd

# 4. Validate configuration
radar validate

# 5. Run your first scan
radar scan .

# 6. Fix violations with AI assistance
radar fix .

The nestjs-prisma-ddd pack includes all the rules shown in this guide, plus additional patterns for NestJS guards, interceptors, pipes, and middleware scope detection.

Technical Debt Radar Documentation