Skip to content
ExponaBLOG
NESTJS · DAY 003 / 100

Structure NestJS projects by feature: a practical guide

Folders by feature beat folders by type once you pass 20 files.

Expona Admin10 min read

Structure NestJS projects by feature

I've watched small NestJS projects turn into unmaintainable messes more times than I'd like to admit. Around the time you hit 20 files organized by type—all controllers in one folder, services scattered elsewhere, DTOs piled up—finding where business logic lives becomes a treasure hunt. Today I'm sharing how to structure NestJS projects by feature instead, a shift that keeps your codebase readable as it grows and makes onboarding new developers feel less like archaeology.

Why type-based structure fails fast

When you organize by type, you're grouping files by their technical role, not their business meaning. That looks like this:

typescript
src/
├── controllers/
│   ├── orders.controller.ts
│   ├── products.controller.ts
│   ├── users.controller.ts
├── services/
│   ├── orders.service.ts
│   ├── products.service.ts
│   ├── users.service.ts
├── dtos/
│   ├── create-order.dto.ts
│   ├── create-product.dto.ts
│   ├── create-user.dto.ts
├── entities/
│   ├── order.entity.ts
│   ├── product.entity.ts
│   ├── user.entity.ts

This works fine for a three-endpoint MVP. But once you're managing five domains with shared utilities, cross-cutting concerns, and team handoffs, this structure forces you to navigate four folders just to understand one feature. New developers ask questions like "wait, which service calls which controller?" and "where does the order validation live?" You lose the ability to see a feature as a cohesive unit.

Feature-based structure: organizing by domain

Instead, group everything related to a business capability into its own folder. One feature folder holds its controller, service, DTOs, module, and entities—everything you need to understand that domain in isolation.

typescript
src/
├── orders/
│   ├── orders.controller.ts
│   ├── orders.service.ts
│   ├── orders.module.ts
│   ├── dto/
│   │   ├── create-order.dto.ts
│   │   ├── update-order.dto.ts
│   ├── entities/
│   │   └── order.entity.ts
├── products/
│   ├── products.controller.ts
│   ├── products.service.ts
│   ├── products.module.ts
│   ├── dto/
│   │   ├── create-product.dto.ts
│   ├── entities/
│   │   └── product.entity.ts
├── users/
│   ├── users.controller.ts
│   ├── users.service.ts
│   ├── users.module.ts
│   ├── dto/
│   │   ├── create-user.dto.ts
│   ├── entities/
│   │   └── user.entity.ts
├── common/
│   ├── filters/
│   ├── guards/
│   ├── interceptors/
│   ├── pipes/
│   └── decorators/

This structure immediately answers the question: "show me everything about orders." Everything lives in one place. A new developer can open orders/ and understand the entire feature without jumping between directories.

The orders folder: a complete feature example

Let me walk through what a real orders feature looks like inside its own folder. Here's the module that ties it together:

typescript
// orders/orders.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { Order } from './entities/order.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Order])],
  controllers: [OrdersController],
  providers: [OrdersService],
  exports: [OrdersService], // Export if other features need it
})
export class OrdersModule {}

The service handles all business logic:

typescript
// orders/orders.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order } from './entities/order.entity';
import { CreateOrderDto } from './dto/create-order.dto';

@Injectable()
export class OrdersService {
  constructor(
    @InjectRepository(Order)
    private ordersRepository: Repository<Order>,
  ) {}

  async create(createOrderDto: CreateOrderDto): Promise<Order> {
    const order = this.ordersRepository.create(createOrderDto);
    return this.ordersRepository.save(order);
  }

  async findById(id: string): Promise<Order> {
    const order = await this.ordersRepository.findOne({ where: { id } });
    if (!order) {
      throw new NotFoundException(`Order ${id} not found`);
    }
    return order;
  }

  async list(): Promise<Order[]> {
    return this.ordersRepository.find();
  }
}

The controller routes requests to the service:

typescript
// orders/orders.controller.ts
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';

@Controller('orders')
export class OrdersController {
  constructor(private ordersService: OrdersService) {}

  @Post()
  create(@Body() createOrderDto: CreateOrderDto) {
    return this.ordersService.create(createOrderDto);
  }

  @Get()
  list() {
    return this.ordersService.list();
  }

  @Get(':id')
  findById(@Param('id') id: string) {
    return this.ordersService.findById(id);
  }
}

And the DTO validates incoming data:

typescript
// orders/dto/create-order.dto.ts
import { IsString, IsNumber, IsNotEmpty } from 'class-validator';

export class CreateOrderDto {
  @IsString()
  @IsNotEmpty()
  customerId: string;

  @IsNumber()
  @IsNotEmpty()
  totalAmount: number;
}

Everything the orders feature needs is here. No hunting through five directories.

Shared code lives in common/ or libs/

Not everything belongs in a feature folder. Cross-cutting concerns—guards, filters, interceptors, utilities—go in a shared space. I typically use common/ for smaller codebases and break it into libs/ subdirectories as complexity grows:

typescript
src/
├── orders/
├── products/
├── users/
├── common/
│   ├── filters/
│   │   └── http-exception.filter.ts
│   ├── guards/
│   │   ├── auth.guard.ts
│   │   └── roles.guard.ts
│   ├── interceptors/
│   │   ├── logging.interceptor.ts
│   │   └── timeout.interceptor.ts
│   ├── pipes/
│   │   └── validation.pipe.ts
│   ├── decorators/
│   │   ├── auth.decorator.ts
│   │   └── public.decorator.ts
│   └── utils/
│       ├── logger.ts
│       └── helpers.ts

Here's an example auth guard that lives in common because multiple features use it:

typescript
// common/guards/auth.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { verify } from 'jsonwebtoken';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const token = request.headers.authorization?.split(' ')[1];

    if (!token) {
      throw new UnauthorizedException('No token provided');
    }

    try {
      const payload = verify(token, process.env.JWT_SECRET);
      request.user = payload;
      return true;
    } catch (err) {
      throw new UnauthorizedException('Invalid token');
    }
  }
}

Then import and use it across features:

typescript
// orders/orders.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../common/guards/auth.guard';

@Controller('orders')
@UseGuards(AuthGuard)
export class OrdersController {
  // ...
}

Avoid one giant shared module

There's a temptation to create a single "shared" or "common" module that exports everything to avoid repetitive imports. Resist it. I've seen projects where CommonModule exports 40 providers and becomes a bottleneck for all changes. Instead, create focused modules within common, each with a clear responsibility:

typescript
// common/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthGuard } from './guards/auth.guard';
import { AuthService } from './auth.service';

@Module({
  providers: [AuthGuard, AuthService],
  exports: [AuthGuard, AuthService],
})
export class AuthModule {}

// common/logging/logging.module.ts
import { Module } from '@nestjs/common';
import { LoggingInterceptor } from './logging.interceptor';
import { Logger } from './logger';

@Module({
  providers: [LoggingInterceptor, Logger],
  exports: [LoggingInterceptor, Logger],
})
export class LoggingModule {}

Then import only what you need in your feature modules:

typescript
// orders/orders.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '../common/auth/auth.module';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { Order } from './entities/order.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Order]), AuthModule],
  controllers: [OrdersController],
  providers: [OrdersService],
})
export class OrdersModule {}

This way, each feature is explicit about its dependencies and doesn't carry unnecessary baggage.

New developers find things by business name

This is the human benefit I care most about. When a junior dev joins and you say "we need to fix order cancellation logic," they know exactly where to look: orders/. They open one folder and find the controller, service, DTOs, and database entity. Within five minutes, they understand the feature end-to-end. No more questions like "which service is this?" or "is that business logic in the controller or the service?" The structure itself teaches the codebase.

I've onboarded people into type-based codebases where they'd spend an hour tracing through four folders just to add one field to an API response. With feature-based structure, they make the same change in 10 minutes. That compounds when you have 10 developers and 50 features.

Scaling from 20 files to 200

Feature-based structure scales beautifully. When an order feature gets complex—maybe you add event-driven logic, external service integrations, or sub-domains—you can evolve it without breaking the structure:

typescript
src/
├── orders/
│   ├── orders.controller.ts
│   ├── orders.service.ts
│   ├── orders.module.ts
│   ├── dto/
│   │   ├── create-order.dto.ts
│   │   ├── update-order.dto.ts
│   ├── entities/
│   │   └── order.entity.ts
│   ├── events/
│   │   ├── order-created.event.ts
│   │   └── order-cancelled.event.ts
│   ├── listeners/
│   │   └── order-created.listener.ts
│   ├── strategies/
│   │   ├── fulfillment.strategy.ts
│   │   └── payment.strategy.ts

The folder still represents one business concept, just with deeper internal organization. A developer working on orders still knows that everything lives under orders/.

Common mistakes

Mistake 1: Scattering shared code across features. I see teams create a "helpers.ts" in the orders folder, then duplicate it in products. Then payments needs it too. Before you know it, three versions exist. Establish the common/ folder early and enforce it. If code is used by two features, it belongs in common/.

Mistake 2: Making feature folders too granular. Some teams create an "order-creation" folder separate from "order-cancellation." That's over-engineering. One order feature folder is fine until it genuinely becomes too large (usually 15+ files with distinct concerns). At that point, consider sub-domains within the feature.

Mistake 3: Forgetting to export services that other features need. If users need to validate an order before creating a user record, the OrdersService has to export from its module. I often see developers creating duplicate services instead of importing and reusing. Always export providers that other features legitimately depend on.

Mistake 4: Treating common/ as a dumping ground. If a guard or interceptor is used by only one feature, it doesn't belong in common/. Put it in the feature folder. Common should be genuinely shared, not a grab bag of miscellaneous code.

Key takeaways

  • Feature-based structure groups a domain's controller, service, DTOs, entities, and module together, making features self-contained and easier to understand.
  • Type-based structure scales poorly once you exceed 20 files; feature-based structure remains readable as you grow to hundreds.
  • Shared code like guards, interceptors, and utilities lives in common/ or dedicated shared modules, not scattered across features.
  • Create focused modules within common/ instead of one monolithic shared module that exports everything.
  • New developers navigate by business concept, not technical type, cutting onboarding time dramatically.
  • Export services from feature modules only when other features genuinely need them; avoid unnecessary coupling.
  • Avoid premature micro-organization; keep features at the folder level until they truly require sub-structure.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles