Architecture rules
10 rules that enforce clean layering, dependency injection patterns, and module boundaries.
Run every rule on this page against a project:
npx nestjs-doctor@latest . --verboseWithout --verbose the console prints one line per rule; with it, every file and line.
| Rule | Severity | What it catches |
|---|---|---|
no-business-logic-in-controllers | error | Loops, switch, a second branch, or repeated array ops in handlers |
no-repository-in-controllers | error | Repository injection in controllers |
no-orm-in-controllers | error | ORM types or ORM-binding decorators injected into controllers |
no-circular-module-deps | error | Cycles in the @Module() import graph |
no-manual-instantiation | error | new SomeService() for a class registered as a provider |
no-service-locator | warning | ModuleRef.get()/resolve() usage |
prefer-constructor-injection | warning | @Inject() property injection |
no-orm-in-services | info | Services using ORM directly (should use repositories) |
require-module-boundaries | info | Relative imports into another module's internal directories |
no-barrel-export-internals | info | Re-exporting repositories from barrel files |
no-business-logic-in-controllers
Detects loops, switch statements, a second branch, and repeated array transformations inside HTTP handler methods.
Why: A controller receives a request, calls the matching service method, and returns the response. Business logic in a controller cannot be reused, is harder to test, and violates the single responsibility principle.
Note: The thresholds are not one each. A single if is allowed, and an if whose every branch only throws never counts at all, so guard clauses are free.
A for, for...of, for...in, while, or switch reports on the first occurrence. Array operations (map, filter, reduce, sort, flatMap) report only when more than one appears in the handler.
@Controller('orders')
export class OrderController {
@Post()
create(@Body() dto: CreateOrderDto) {
const items = [];
for (const item of dto.items) { // Logic in controller
if (item.quantity > 0) {
items.push({ ...item, total: item.price * item.quantity });
}
}
return this.orderRepo.save({ items });
}
}no-repository-in-controllers
Detects direct repository injection in controller constructors.
Why: Controllers should depend on services, not repositories. Direct repository access bypasses business logic validation and creates tight coupling to the data layer.
Note: Imports are a second trigger. In a file that declares routes, any import whose specifier contains /repositories is reported, once per file rather than once per controller.
@Controller('users')
export class UserController {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
}no-orm-in-controllers
Detects data access injected straight into a controller. Two shapes count:
- ORM types:
PrismaService,PrismaClient,EntityManager,EntityRepository,DataSource,Repository,Connection,MongooseModel,MikroORM,DrizzleService. - ORM-binding decorators:
@InjectRepository,@InjectModel,@InjectEntityManager.
Why: Applies the same layering constraint as no-repository-in-controllers, extended to other ORM access patterns. Controllers should access data through a service layer.
@Controller('users')
export class UserController {
constructor(private readonly prisma: PrismaService) {}
@Get()
findAll() {
return this.prisma.user.findMany();
}
}no-circular-module-deps
Scope: Project
Detects cycles in the @Module() import graph and names the specific providers causing each edge.
Why: Circular module dependencies produce unpredictable initialization ordering, complicate the dependency graph, and make the codebase harder to reason about.
// user.module.ts
@Module({ imports: [OrderModule] })
export class UserModule {}
// order.module.ts
@Module({ imports: [UserModule] }) // circular import
export class OrderModule {}Which provider to extract
The rule traces provider-level dependencies to find what creates each module edge. It then names the dependency to extract into a shared module, choosing the edge with the fewest of them.
Treat forwardRef() as a last resort. It defers resolution at runtime and leaves the architectural coupling in place.
A cycle through three modules reports one diagnostic. The message names the cycle, and the help below it lists every edge and the extraction candidate:
Circular module dependency detected: AuthModule -> UserModule -> OrderModule
AuthModule -> UserModule: AuthService (in AuthModule) injects UserService (from UserModule)
UserModule -> OrderModule: UserNotifier (in UserModule) injects OrderService (from OrderModule)
OrderModule -> AuthModule: OrderService (in OrderModule) injects AuthService (from AuthModule)
Consider extracting UserService into a shared module — it would break the AuthModule -> UserModule edge (1 dependency).traceProviderEdges walks each module's providers and controllers and checks which constructor parameters resolve to providers from the target module. The edge with the fewest provider dependencies is the extraction candidate.
Options
ignoreForwardRefCycles is a boolean and defaults to false. It suppresses a cycle when every edge in the cycle direction is wrapped in forwardRef():
- A 2-module cycle is suppressed when both modules use
forwardRef(() => Other). - A longer cycle (
A -> B -> C -> A) needs every consecutive edge wrapped. - Any plain edge still gets flagged, including a one-sided
forwardRef(), which throws at runtime in NestJS anyway.
Set the option in the rule config:
{
"rules": {
"architecture/no-circular-module-deps": {
"options": { "ignoreForwardRefCycles": true }
}
}
}Note: Detection matches a call expression whose callee is the bare identifier forwardRef. The check is textual: it does not verify that the symbol resolves to @nestjs/common, so a locally defined forwardRef matches too.
Aliased imports (import { forwardRef as fr }) and namespace access (Nest.forwardRef(...)) are not recognized. Use the bare imported name to get the opt-out.
Dynamic module patterns are resolved during graph construction: ConfigModule.forRoot(), forwardRef(), spread syntax, and helper function calls. Cycle detection is accurate without restructuring the code into plain identifiers. See Module graph building: import resolution for the full list of supported patterns.
no-manual-instantiation
Detects new SomeService() for classes that should be injected via NestJS DI.
Why: Manually instantiating injectable classes bypasses the DI container, so constructor dependencies are not resolved. It also breaks scoping and lifecycle hooks.
Note: The class name has to end in Service, Repository, Gateway, Resolver, Guard, Interceptor, Pipe, or Filter. new OrderValidator() is not matched by any of them.
The class must also be one the scan found as a provider. A plain class, or one from node_modules that happens to end in Service, is skipped.
@Injectable()
export class CheckoutService {
process(cart: Cart) {
const orders = new OrderService(); // manual instantiation
return orders.create(cart);
}
}If your project intentionally allows manual construction for specific classes
(for example Logger wrappers), you can exclude them:
{
"rules": {
"architecture/no-manual-instantiation": {
"excludeClasses": ["Logger", "PinoLogger"]
}
}
}no-service-locator
Detects ModuleRef.get() or ModuleRef.resolve() calls that dynamically look up providers.
Why: The service locator pattern hides dependencies. The constructor signature no longer reflects the real dependency set. Use constructor injection instead.
Note: The receiver has to be written moduleRef or this.moduleRef. A parameter under another name, such as this.ref.get(SomeService), is not matched even when its type is ModuleRef.
@Injectable()
export class TaskService {
constructor(private readonly moduleRef: ModuleRef) {}
async run() {
const service = this.moduleRef.get(SomeService);
service.doWork();
}
}prefer-constructor-injection
Detects @Inject() used on class properties instead of constructor parameters.
Why: Constructor injection makes dependencies explicit and immutable. Property injection obscures dependencies and permits them to be undefined before initialization.
@Injectable()
export class UserService {
@Inject()
private configService: ConfigService;
}no-orm-in-services
Detects services that reach the ORM directly instead of going through a repository layer. Two shapes count:
- ORM types:
EntityManager,DataSource,Connection,MongooseModel,MikroORM,DrizzleService. - ORM-binding decorators:
@InjectRepository,@InjectModel,@InjectEntityManager.
Why: When services depend on ORM types directly, switching ORMs or data sources requires changing every service. A repository layer provides a clean abstraction boundary.
Note: TypeORM's Repository<T> and MikroORM's EntityRepository<T> are allowed in services. They are the repository abstraction the rule asks for. Classes named *Repository or *Repo are skipped as well, so a custom repository can inject whatever it needs.
The @mikro-orm/nestjs setup injects EntityManager or uses @InjectRepository on purpose.
@Injectable()
export class UserService {
constructor(private readonly em: EntityManager) {}
}require-module-boundaries
Detects deep imports that reach into another module's internal files.
Why: Importing from ../order/repositories/order.repository creates tight coupling to another module's internal structure. Import from the module's public API (barrel file) instead.
A relative specifier reports when it contains ../ and one of these directory segments:
| Kind | Segment |
|---|---|
| Data access | /repositories/, /entities/ |
| Transport shapes | /dto/ |
| Request pipeline | /guards/, /interceptors/, /pipes/ |
| Authentication | /strategies/ |
The list is fixed, so ../order/validators/order.validator is not matched. An import that resolves back inside its own module is skipped as well.
// In user module:
import { OrderRepository } from '../order/repositories/order.repository';no-barrel-export-internals
Detects barrel files (index.ts) that re-export internal implementation details like repositories.
Why: Barrel files define a module's public API. Re-exporting repositories or internal services allows consumers to bypass the service layer.
// user/index.ts
export { UserService } from './user.service';
export { UserRepository } from './user.repository'; // internal, not part of the public API