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 . --verbose

Without --verbose the console prints one line per rule; with it, every file and line.

RuleSeverityWhat it catches
no-business-logic-in-controllerserrorLoops, switch, a second branch, or repeated array ops in handlers
no-repository-in-controllerserrorRepository injection in controllers
no-orm-in-controllerserrorORM types or ORM-binding decorators injected into controllers
no-circular-module-depserrorCycles in the @Module() import graph
no-manual-instantiationerrornew SomeService() for a class registered as a provider
no-service-locatorwarningModuleRef.get()/resolve() usage
prefer-constructor-injectionwarning@Inject() property injection
no-orm-in-servicesinfoServices using ORM directly (should use repositories)
require-module-boundariesinfoRelative imports into another module's internal directories
no-barrel-export-internalsinfoRe-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:

KindSegment
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