DEV Community

Cover image for Clean Architecture in NestJS — Enforcing the Controller-Service-Repository Pattern with Static Analysis
Francisco López
Francisco López

Posted on

Clean Architecture in NestJS — Enforcing the Controller-Service-Repository Pattern with Static Analysis

A beginner-friendly guide to layered architecture and how to make sure your team actually follows it


When I started working with NestJS, one of the things that attracted me the most was how opinionated the framework felt. Modules, controllers, services, dependency injection — it gives you a clear structure from day one. But after working on a few projects, I slowly realized something: NestJS gives you the tools for clean architecture, but it won't stop you from breaking it.

I remember one project where a junior dev on my team was tasked with adding a "deactivate user" feature. Simple enough. But instead of going through the service layer, they injected PrismaService directly into the controller, wrote a database query, added an if/else to check permissions, and sent an email — all inside a @Patch() handler. It worked. The tests passed. The PR got merged because the reviewer was in a rush.

That's when I started thinking about enforcing architecture, not just documenting it.

[TL;DR] If you already know what layered architecture is and just want to enforce it, you can jump right to "Enforcing It" section.

Before we move forward, I wanted to set some expectations. This article won't be an academic deep dive into hexagonal architecture or clean architecture as defined by Uncle Bob. I will be focusing this writing on a practical pattern that works well for most NestJS projects — the Controller-Service-Repository pattern — and how to enforce it automatically using nestjs-doctor. Having said that, let's jump right into it.


The Three Layers

A useful way to think of this is like a restaurant.

  • The Controller is the waiter. It takes your order (HTTP request), brings it to the kitchen, and delivers the food back (HTTP response). The waiter doesn't cook. The waiter doesn't go to the market to buy ingredients. The waiter just communicates.

  • The Service is the chef. It receives the order, decides what to do, applies the recipe (business logic), and prepares the dish. The chef doesn't talk to customers directly. The chef doesn't go to the warehouse to grab ingredients — they ask someone else.

  • The Repository is the warehouse manager. It knows where everything is stored, how to fetch ingredients (data), and how to put them back. It deals with storage (the database) so nobody else has to.

In code, it looks like this:

Request  →  Controller  →  Service  →  Repository  →  Database
Response ←  Controller  ←  Service  ←  Repository  ←  Database
Enter fullscreen mode Exit fullscreen mode

Each layer only talks to the one directly below it. The controller calls the service. The service calls the repository. The repository talks to the database. Never the other way around, never skipping layers.

Let's see what each one looks like in NestJS.

The Controller

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    return this.usersService.findAll();
  }

  @Post()
  create(@Body() dto: CreateUserDto) {
    return this.usersService.create(dto);
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice how thin this is. No database queries. No if/else logic. No email sending. The controller receives the request, delegates to the service, and returns the response. That's it.

The Service — Business Logic

@Injectable()
export class UsersService {
  constructor(private readonly usersRepository: UsersRepository) {}

  async findAll() {
    return this.usersRepository.findMany();
  }

  async create(dto: CreateUserDto) {
    const existing = await this.usersRepository.findByEmail(dto.email);
    if (existing) {
      throw new ConflictException('Email already in use');
    }
    return this.usersRepository.create(dto);
  }
}
Enter fullscreen mode Exit fullscreen mode

The service contains the business rules "if the email already exists, throw an error." It doesn't know about HTTP status codes (that's ConflictException's job). It doesn't know about SQL or Prisma or Mongo — it calls the repository.

The Repository

@Injectable()
export class UsersRepository {
  constructor(private readonly prisma: PrismaService) {}

  async findMany() {
    return this.prisma.user.findMany();
  }

  async findByEmail(email: string) {
    return this.prisma.user.findFirst({ where: { email } });
  }

  async create(data: CreateUserDto) {
    return this.prisma.user.create({ data });
  }
}
Enter fullscreen mode Exit fullscreen mode

The repository is the only layer that knows about the database. If you switch from Prisma to TypeORM tomorrow, you only change this file. The service and controller don't care.


What Goes Wrong When You Break the Layers

Let me show you the common mistakes I see, especially from devs who are new to NestJS.

Mistake 1: ORM in the Controller

// BAD — The waiter is cooking!
@Controller('users')
export class UsersController {
  constructor(private readonly prisma: PrismaService) {}

  @Get()
  findAll() {
    return this.prisma.user.findMany();
  }
}
Enter fullscreen mode Exit fullscreen mode

Why is this bad? Because now your controller knows about Prisma, about your database schema, about how data is stored. If you need that same query in a background job or a WebSocket handler, you can't reuse it it's trapped in an HTTP endpoint.

Mistake 2: Business Logic in the Controller

// BAD — The waiter is deciding the recipe!
@Controller('orders')
export class OrdersController {
  @Post()
  async create(@Body() dto: CreateOrderDto) {
    const items = await this.prisma.product.findMany({
      where: { id: { in: dto.productIds } },
    });

    let total = 0;
    for (const item of items) {
      if (item.stock <= 0) {
        throw new BadRequestException(`${item.name} is out of stock`);
      }
      total += item.price * dto.quantities[item.id];
    }

    if (total > 10000) {
      // send approval email
      await this.mailer.send(dto.managerEmail, 'Approval needed', ...);
    }

    return this.prisma.order.create({ data: { total, items: ... } });
  }
}
Enter fullscreen mode Exit fullscreen mode

This handler is doing everything — fetching products, checking stock, calculating totals, sending emails, creating the order. Long story short, it's a mess. You can't test the business logic without making HTTP requests. You can't reuse the stock check from another part of your app.

Mistake 3: Repository in the Controller

// BAD — The waiter is going to the warehouse!
@Controller('users')
export class UsersController {
  constructor(private readonly usersRepository: UsersRepository) {}

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersRepository.findById(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

This one is more subtle. You have a proper repository, which is good! But the controller is calling it directly, skipping the service layer. Today it's a simple find-by-id, but tomorrow someone adds authorization logic directly in the controller because "there's no service to put it in."


Enforcing It

Knowing the pattern is one thing. Making sure your entire team follows it — especially under deadline pressure — is another. This is where nestjs-doctor comes in.

Install and Run

npm install --save-dev nestjs-doctor
npx nestjs-doctor
Enter fullscreen mode Exit fullscreen mode

You'll see something like this:

  82 / 100  Good

  ✗ Controller 'OrdersController' injects ORM service 'PrismaService' directly. (2)
    Inject a service that wraps the ORM instead of using it in controllers.

  ✗ Controller 'OrdersController' contains business logic: found loop and 3 branches. (1)
    Extract branches, loops, and complex calculations into a service method.

  ⚠ Constructor parameter 'prismaService' should be readonly. (5)
    Add the 'readonly' modifier to the constructor parameter.
Enter fullscreen mode Exit fullscreen mode

Custom Rules

A custom rule is a .ts file with two parts: meta (what the rule is) and check (what it detects). Let's create one.

Example: Services Must End With "Service"

Maybe your team requires consistent naming — all @Injectable() classes in the service layer should end with Service.

// nestjs-doctor-rules/require-service-suffix.ts

export const requireServiceSuffix = {
  meta: {
    id: "require-service-suffix",
    description: "Injectable classes that are not repositories must end with 'Service'",
    help: "Rename the class to end with 'Service' (e.g., UsersService).",
    category: "architecture",
    severity: "warning",
  },

  check(context) {
    for (const cls of context.sourceFile.getClasses()) {
      if (!cls.getDecorator("Injectable")) continue;

      const name = cls.getName() ?? "";

      // Skip repositories, guards, pipes, filters, interceptors
      if (/Repository|Repo|Guard|Pipe|Filter|Interceptor|Gateway|Resolver/.test(name)) continue;

      if (!name.endsWith("Service")) {
        context.report({
          filePath: context.filePath,
          message: `Injectable '${name}' should end with 'Service'.`,
          line: cls.getStartLineNumber(),
          column: 1,
        });
      }
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

Example: Repositories Must Not Import Other Repositories

In a clean architecture, repositories are independent data access objects. They shouldn't depend on each other — that kind of orchestration belongs in the service layer.

// nestjs-doctor-rules/no-cross-repository-imports.ts

export const noCrossRepositoryImports = {
  meta: {
    id: "no-cross-repository-imports",
    description: "Repositories must not inject other repositories",
    help: "Move the cross-repository logic to a service that orchestrates both.",
    category: "architecture",
    severity: "error",
  },

  check(context) {
    for (const cls of context.sourceFile.getClasses()) {
      const name = cls.getName() ?? "";
      if (!name.endsWith("Repository") && !name.endsWith("Repo")) continue;

      const ctor = cls.getConstructors()[0];
      if (!ctor) continue;

      for (const param of ctor.getParameters()) {
        const typeName = param.getTypeNode()?.getText() ?? "";
        if (typeName.endsWith("Repository") || typeName.endsWith("Repo")) {
          context.report({
            filePath: context.filePath,
            message: `Repository '${name}' injects '${typeName}' — repositories should not depend on each other.`,
            line: param.getStartLineNumber(),
            column: 1,
          });
        }
      }
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

Example: Flag God Services (Too Many Dependencies)

A service with 10 dependencies is probably doing too much. This rule uses project scope to access the full provider map:

// nestjs-doctor-rules/no-god-services.ts

export const noGodServices = {
  meta: {
    id: "no-god-services",
    description: "Services with too many dependencies should be split",
    help: "Extract related dependencies into smaller, focused services.",
    category: "architecture",
    severity: "warning",
    scope: "project",
  },

  check(context) {
    const MAX = 8;
    for (const [name, provider] of context.providers) {
      if (provider.dependencies.length > MAX) {
        context.report({
          filePath: provider.filePath,
          message: `'${name}' has ${provider.dependencies.length} dependencies (max ${MAX}).`,
          line: provider.classDeclaration.getStartLineNumber(),
          column: 1,
        });
      }
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

Wiring Custom Rules

Tell nestjs-doctor where to find them. In your nestjs-doctor.config.json:

{
  "customRulesDir": "./nestjs-doctor-rules"
}
Enter fullscreen mode Exit fullscreen mode

Run again and your rules show up with a custom/ prefix:

⚠ Injectable 'UserHelper' should end with 'Service'. (1)
✗ Repository 'OrdersRepository' injects 'UsersRepository'. (1)
Enter fullscreen mode Exit fullscreen mode

The Shortcut — Let AI Write the Rules For You

I know what you might be thinking — "I don't want to learn the ts-morph AST API just to write a linting rule." Fair enough. Here's the thing: you don't have to.

If you use an AI coding agent — Claude Code, Cursor, Windsurf, Codex, Gemini CLI, or others — you can install a skill that generates custom rules from plain English:

npx nestjs-doctor --init
Enter fullscreen mode Exit fullscreen mode

This installs two skills into your agent. The one we care about is /nestjs-doctor-create-rule. Now just describe what you want:

/nestjs-doctor-create-rule repositories must not inject other repositories
Enter fullscreen mode Exit fullscreen mode

The agent will assess feasibility, generate the rule file, create the directory, update your config, and verify it loads. All from a single prompt.

Here are some more prompts you can try:

/nestjs-doctor-create-rule every controller must have @ApiTags for swagger docs

/nestjs-doctor-create-rule services should not have more than 8 constructor dependencies

/nestjs-doctor-create-rule ban imports from "moment" — suggest dayjs instead

/nestjs-doctor-create-rule POST and PUT handlers must use @UsePipes(ValidationPipe)

/nestjs-doctor-create-rule flag injectable classes that don't follow the naming convention
Enter fullscreen mode Exit fullscreen mode

The skill knows the full custom rule API — what fields meta needs, how context.sourceFile works, when to use scope: "project" for cross-file analysis, and all the validation requirements. It handles the boilerplate so you focus on what to enforce, not how to traverse an AST.


Adding It to CI

Once your rules are in place — both built-in and custom — add nestjs-doctor to your CI pipeline:

npx nestjs-doctor --min-score 85
Enter fullscreen mode Exit fullscreen mode

The --min-score flag makes the command exit with a non-zero code if the health score drops below 85. PRs that break the architecture get blocked before they're merged. No more "I'll fix it later."


Sum Up

The Controller-Service-Repository pattern is simple to understand but hard to maintain across a team. Let's recap:

  • Controllers are waiters — they take orders and deliver responses. No cooking (business logic), no warehouse trips (database queries).
  • Services are chefs — they apply the recipes (business rules). They don't talk to customers (HTTP) or manage storage (database) directly.
  • Repositories are warehouse managers — they handle data storage. They don't depend on each other.
  • nestjs-doctor enforces these layers automatically with built-in rules for the common violations.
  • Custom rules let you encode your team's specific conventions — naming, dependency limits, import restrictions — in code that runs on every scan.
  • AI skills let you generate those custom rules from plain-English prompts, so you don't need to learn the AST API.

There is no silver bullet, but having automated checks that block architectural violations in CI is the closest thing. Architecture decisions that live only in documentation will be violated. Architecture decisions enforced by tooling won't.

Resources

Top comments (0)