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.