How to organize modules, services, and shared code so the codebase scales

The best NestJS project structure is feature-based: one module folder per domain (users, orders) with its controller, service, DTOs, entities, and tests, plus one SharedModule for cross-cutting code and a CoreModule for one-time setup. Keep DTOs separate from entities and end-to-end tests in a top-level test/ folder. For Postgres, cron jobs, and secrets next to that NestJS app, Encore works alongside your framework and provisions that infrastructure in your own AWS or GCP account. You can also start a new backend on Encore alone, where each service is a folder with an encore.service.ts file and no modules or providers to register.
NestJS's biggest strength is that it hands you an architecture. Its biggest weakness is that the architecture is open-ended: you still have to decide how modules relate, where shared code lives, and how to keep things consistent as the app grows. A 5-controller hello-world is fine. A 50-controller production backend with three teams contributing? Structure starts to matter a lot.
This guide covers the structural decisions that actually affect maintainability, the patterns that work, the ones that become regretful technical debt, and when it makes sense to step back and reconsider whether NestJS's structure is still the right fit.
Encore works alongside NestJS (or as the backend itself): declare Postgres databases, buckets, and cron jobs in code, and Encore provisions them in your own AWS or GCP account. The quickstart builds a small multi-service app you can compare against the NestJS layouts below.
nest new gives you this:
src/
├── app.controller.ts
├── app.module.ts
├── app.service.ts
└── main.ts
That is fine for a demo. You will outgrow it at 5-10 endpoints. What replaces it is the first decision worth getting right.
With Encore for TypeScript, each service is a folder with an encore.service.ts file in it. Encore finds your services and wires them together for you, so there are no modules or providers to register. See A lighter approach below.
Group by feature, not by technical layer. Each feature is a module with everything it needs (controllers, services, DTOs, entities, tests) in one folder.
src/
├── users/
│ ├── dto/
│ │ ├── create-user.dto.ts
│ │ └── update-user.dto.ts
│ ├── entities/
│ │ └── user.entity.ts
│ ├── users.controller.ts
│ ├── users.service.ts
│ ├── users.module.ts
│ └── users.service.spec.ts
├── orders/
│ ├── dto/
│ ├── entities/
│ ├── orders.controller.ts
│ ├── orders.service.ts
│ └── orders.module.ts
├── shared/
│ ├── decorators/
│ ├── filters/
│ ├── guards/
│ └── interceptors/
├── app.module.ts
└── main.ts
Why this works:
src/
├── controllers/
├── services/
├── dtos/
├── entities/
└── modules/
This mirrors an MVC frame, but breaks at scale. A change to "users" touches four folders. Pull requests span unrelated files. Dependencies across layers become invisible.
Use feature-based unless you are writing a tutorial. Every NestJS codebase that grew past 20 controllers with a layered structure ends up regretting it.
NestJS modules are both an organizational and a runtime concept: they define what is injectable where. Getting this right matters more than file layout.
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // so other modules can inject it
})
export class UsersModule {}
Feature modules should export only what other modules need to inject. Do not export everything by default.
Create a SharedModule for filters, interceptors, pipes, and utility providers that many features use:
@Global()
@Module({
providers: [LoggerService, HttpExceptionFilter],
exports: [LoggerService, HttpExceptionFilter],
})
export class SharedModule {}
@Global() makes these available everywhere without re-importing. Use it sparingly, only for genuinely cross-cutting concerns. Overusing @Global() recreates the "everything is available everywhere" problem that modules were meant to solve.
Some concerns (database connection, logger configuration, global interceptors) should only initialize once. Put those in a CoreModule, imported only in AppModule:
@Module({
imports: [TypeOrmModule.forRoot({ /* ... */ })],
exports: [TypeOrmModule],
})
export class CoreModule {}
For large apps, consider loading some modules lazily:
@Module({ /* ... */ })
export class AdminModule {}
// In app.module.ts, use dynamic import in a route or feature flag
Lazy loading matters for serverless cold starts and very large apps. For most deployments, eager loading is fine and simpler.
In Encore for TypeScript, the unit of structure is the service, and it is defined in one file:
// users/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("users");
Each service gets its own APIs, database and tests, and calls to other services are type-checked function calls.
Keep DTOs (shape of data in/out of the API) and entities (database models) separate. They look similar at first; they diverge over time.
<feature>/dto/: validated with class-validator, used by controllers.<feature>/entities/: annotated with TypeORM/Prisma/Drizzle decorators, used by services.Never expose an entity directly from a controller. It leaks internal fields, locks you into your DB schema, and makes API changes expensive.
// users/dto/user-response.dto.ts
export class UserResponseDto {
id: number;
email: string;
name: string;
// no password, no internal flags
}
// users/users.service.ts
async findOne(id: number): Promise<UserResponseDto> {
const user = await this.repo.findOne(id);
return { id: user.id, email: user.email, name: user.name };
}
A library like class-transformer with @Exclude() decorators can automate this, but be explicit about which fields leave the service.
Use @nestjs/config with Joi or Zod schema validation, and keep anything sensitive out of it in favor of a secrets store:
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
DATABASE_URL: Joi.string().required(),
JWT_SECRET: Joi.string().required(),
PORT: Joi.number().default(3000),
}),
}),
],
})
export class AppModule {}
Fail fast if config is invalid. A NestJS app that starts with missing env vars and crashes an hour later under load is worse than one that will not start at all.
Co-locate unit tests with the code they test:
users/
├── users.service.ts
└── users.service.spec.ts
Put e2e tests in a separate top-level folder:
test/
├── users.e2e-spec.ts
└── orders.e2e-spec.ts
NestJS's testing module lets you mock any provider in a single line, which is the real DX win of the DI architecture:
const module = await Test.createTestingModule({
providers: [
UsersService,
{ provide: UsersRepository, useValue: mockRepository },
],
}).compile();
Use this for unit tests. For e2e, spin up the real AppModule with a test database and use supertest.
Centralize error handling with a filter:
// shared/filters/http-exception.filter.ts
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const status =
exception instanceof HttpException ? exception.getStatus() : 500;
const message =
exception instanceof HttpException
? exception.getResponse()
: "Internal server error";
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
});
}
}
Register globally in main.ts:
app.useGlobalFilters(new GlobalExceptionFilter());
This keeps error format consistent across every endpoint. Do not let individual controllers format errors their own way.
Use a structured logger (Pino, Winston) wrapped as a NestJS provider. Do not use console.log: you will regret it when you add observability.
@Injectable()
export class AppLogger extends Logger {
log(message: string, context?: string) {
// structured JSON output
}
}
Register in main.ts before anything else:
const app = await NestFactory.create(AppModule, {
logger: new AppLogger(),
});
If you end up splitting into microservices, use nest new --monorepo (or set up a Turborepo / Nx workspace). Shared DTOs go in a libs/ package imported by every service:
apps/
├── users-service/
├── orders-service/
└── gateway/
libs/
├── shared-dto/
├── shared-auth/
└── shared-logging/
This gets painful fast, versioning shared libs across services, keeping them in sync, managing deploys, but there are not great alternatives if you are committed to NestJS microservices. See our NestJS Microservices Guide for the full picture.
forwardRef() warning. Fix the structure, do not paper over it.new UsersService() instead of injecting it, you lose testability.AppModule. If app.module.ts imports 40 feature modules, it is telling you something. Break it up with domain modules.This is worth asking honestly: once you have set up modules, providers, DTOs, entities, guards, interceptors, pipes, filters, and decorators, have you built much that delivers business value?
NestJS's structure is a solution to a problem: keeping a large TypeScript backend coherent across many developers. If you are a team of 2-5, the structure often costs more than it earns. You are writing framework wiring instead of features.
Encore takes a different position: Encore handles the structure decisions so you do not have to. An Encore service is just a folder with an encore.service.ts file. An endpoint is a function with typed parameters. There is no DI container, no module system, no providers.
src/
├── users/
│ ├── encore.service.ts
│ ├── users.ts // endpoints + business logic
│ └── migrations/ // database schema
├── orders/
│ ├── encore.service.ts
│ ├── orders.ts
│ └── migrations/
└── encore.app
Compare that to the NestJS equivalent. Same functionality, less scaffolding. No modules to register, no providers to inject, no DTOs to duplicate against entities.
// users/users.ts
import { api, APIError } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
// Provisions managed Postgres, Docker locally, RDS or Cloud SQL in production.
const db = new SQLDatabase("users", { migrations: "./migrations" });
interface User {
id: number;
email: string;
name: string;
}
export const get = api(
{ method: "GET", path: "/users/:id", expose: true },
async ({ id }: { id: number }): Promise<User> => {
const user = await db.queryRow<User>`
SELECT id, email, name FROM users WHERE id = ${id}`;
if (!user) throw APIError.notFound("user not found");
return user;
},
);
What you do not write:
What you get automatically:
For a new backend, this is usually a better starting point than NestJS. For an existing NestJS codebase, sticking with what you have is normally right, the structural decisions are already made.
To see the same app in NestJS and Encore side by side, start from the NestJS example.
Run in your terminal to get started locally.
For a new NestJS project with good structure from day one:
npm install -g @nestjs/cli
nest new my-app
cd my-app
nest generate module users
nest generate controller users
nest generate service users
For a new backend that skips most of the structure decisions, install Encore with brew install encoredev/tap/encore && encore app create, then:
encore app create my-app --example=ts/empty
cd my-app && encore run
Windows: iwr https://encore.dev/install.ps1 | iex
Linux: curl -L https://encore.dev/install.sh | bash
For most NestJS projects, a feature-based structure works best: group each feature into its own module folder containing its controllers, services, DTOs, entities, and tests. This keeps related code in one place, so moving or deleting a feature is a single-folder operation. Avoid a layered structure (separate controllers/, services/, dtos/ folders) for non-trivial apps, since a single change ends up touching many folders.
Structure by feature for anything beyond a tutorial. A feature-based layout groups all code for a domain (users, orders) in one module folder, which keeps pull requests focused and dependencies visible. A layered layout, splitting code into controllers, services, and DTOs folders, tends to break down as a project grows, since one change forces edits across several folders.
A DTO defines the shape of data going in and out of your API and is validated with class-validator, while an entity models a database table (a TypeORM class with column decorators, or a model in a Prisma schema). Keep them separate and never return an entity directly from a controller, since doing so leaks internal fields and locks your API to your database schema.
Use a global module (marked with @Global()) only for cross-cutting concerns such as a logger, configuration, or shared filters and interceptors that most features need. Marking it global makes its exported providers available everywhere without re-importing. Overusing it recreates the everywhere-is-available problem that the module system was designed to prevent, so keep it to a single SharedModule.
Co-locate unit tests next to the code they test using a .spec.ts suffix (for example users.service.spec.ts beside users.service.ts), and keep end-to-end tests in a separate top-level test/ folder with a .e2e-spec.ts suffix. For unit tests, use the NestJS testing module to mock providers; for e2e tests, boot the real AppModule against a test database and drive it with supertest.
For a team of two to five, much of NestJS's structure (modules, providers, DTOs versus entities, guards, interceptors) can cost more than it returns, since you spend time on framework wiring instead of features. NestJS earns its structure on large codebases with many contributors. If you want less scaffolding for a new backend, Encore, a backend framework that declares infrastructure in code, takes a lighter approach where a service is a folder and an endpoint is a typed function, with no DI container or module system to wire up.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.