Every NestJS app starts tidy. nest new gives you an AppModule, a controller and a service, and the first ten features slot in nicely. Somewhere around feature thirty, things change. UsersService is 1,800 lines long. Half the modules import each other through forwardRef(). There's a common/ folder that everything depends on and nobody owns, and a change to invoices breaks notifications in a way nobody can explain.
None of that is NestJS's fault. The framework gives you modules and dependency injection, but it doesn't decide where the boundaries go. This post is the NestJS project structure I use for large SaaS backends: what goes where, how modules talk to each other, and the few rules that keep a codebase with dozens of modules and several developers from turning into a ball of mud.
Part of my guide to building a scalable SaaS backend with NestJS and PostgreSQL.
TL;DR#
- Organise by feature, not by file type. One folder per business capability (
billing,invoices,customers), each with its own module, controllers, services, entities and tests. - Give every feature module a small public API. Export one or two services, keep repositories and entities private, and import other features only through their
index.ts. - Keep three kinds of shared code apart:
core/for app-wide wiring (auth, logging, errors),database/for the data source and migrations, andshared/for small, stable helpers with no business rules. - Treat
forwardRef()as a bug report. Fix cycles with events, a new module that owns the shared concept, or orchestration one level up. - Enforce the boundaries with a tool, not a wiki page. A dependency rule in CI catches what code review misses.
- Stay a modular monolith until a team genuinely needs to deploy separately.
The folder structure#
Here is the shape I start from. Every folder has a reason to exist, and I'll go through them below.
src/
├── main.ts # HTTP entry point
├── worker.ts # queue workers: same code, no HTTP server
├── app.module.ts # imports core, database and the feature modules. Nothing else.
├── config/
│ ├── env.schema.ts # every environment variable, validated at startup
│ └── app.config.ts # typed config namespaces
├── core/ # wiring every request needs, imported once
│ ├── core.module.ts
│ ├── auth/ # guards, JWT strategy, @CurrentUser(), @Roles()
│ ├── context/ # request id, user and tenant (nestjs-cls)
│ ├── http/ # exception filter, interceptors, global pipes
│ ├── logging/
│ └── health/
├── database/
│ ├── database.module.ts
│ ├── data-source.ts # shared by the app and the migration CLI
│ ├── tenant-db.service.ts
│ └── migrations/
├── shared/ # small, stable, no business rules
│ ├── pagination/
│ └── money.ts
└── modules/
├── customers/
├── invoices/
├── billing/
└── notifications/
test/
└── e2e/
The idea behind it: someone new should be able to answer "where does the code for X live?" by looking at modules/, and "what happens to every request?" by looking at core/.
Organise by feature, not by type#
The most common structure I see in NestJS projects that are struggling looks like this:
src/
├── controllers/
├── services/
├── entities/
├── dtos/
└── repositories/
It feels organised, and it falls apart as soon as the app grows. Changing how invoices work means touching five folders. Nothing tells you which services belong together, so anything can import anything. And the module system, which is NestJS's best tool for keeping code apart, ends up as one giant AppModule or a handful of modules that all import each other.
The NestJS docs put it simply: a feature module organises the code for one feature, which keeps boundaries clear. So each business capability gets a folder and a module, and the folder holds everything that capability needs.
What counts as "a feature"? Think in nouns your customers or your finance team would use: customers, invoices, subscriptions, payments, notifications, reports. Avoid technical names like utils, helpers or managers. If a module's name doesn't tell a product manager what it does, it probably isn't a module.
Inside a feature module#
A small or medium module stays flat:
modules/customers/
├── customers.module.ts
├── index.ts # the public API
├── customers.controller.ts
├── customers.service.ts
├── customers.repository.ts
├── customer.entity.ts
├── dto/
│ ├── create-customer.dto.ts
│ └── customer.response.ts
└── customers.service.spec.ts
When a module grows past roughly a dozen files, or starts to hold real business rules (pricing, proration, state machines), I split it into layers:
modules/billing/
├── billing.module.ts
├── index.ts
├── api/ # controllers, request and response DTOs
├── application/ # one service per use case, transactions live here
├── domain/ # entities, value objects, rules, domain events
└── infrastructure/ # repositories, the Stripe client, queue processors
The rule between layers is simple: dependencies point inwards. Controllers call application services. Application services use the domain and call repositories. The domain imports nothing from NestJS, TypeORM or Stripe, so its rules can be unit tested without a database or a mock in sight.
This is a lighter version of hexagonal or clean architecture. I don't apply the full version with ports and adapters to every module. A CRUD module with three endpoints doesn't need four layers and six interfaces; it needs to be easy to read. Save the layering for the modules where the business logic is the hard part, and let the simple ones stay simple.
Keep controllers thin. A controller should read the request, call one service method, and shape the response. If a controller has an if that isn't about HTTP, that logic belongs in a service, where a queue worker or a CLI command can reuse it.
Don't return entities from controllers. Return a response DTO. Entities change when the database changes, and you don't want a new column (say, password_hash or internal_notes) to appear in your public API because someone added it to the entity.
Module boundaries: a small public API#
This is the rule that matters most, and the one most codebases skip.
// modules/customers/customers.module.ts
@Module({
imports: [TypeOrmModule.forFeature([Customer])],
controllers: [CustomersController],
providers: [CustomersService, CustomersRepository],
exports: [CustomersService], // only the service, never the repository
})
export class CustomersModule {}
// modules/customers/index.ts
export { CustomersModule } from './customers.module';
export { CustomersService } from './customers.service';
export type { CustomerSummary } from './dto/customer.response';
Other modules import CustomersModule and inject CustomersService. They don't reach into customers.repository.ts or query the customers table with their own Repository<Customer>. When invoices needs a customer's name, it asks the customers module.
Why bother? Because the alternative is that six modules query the customers table directly, each in a slightly different way, and nobody can change that table without reading the entire codebase. A narrow public API turns "who uses this table?" into "who calls these four methods?", and your editor can answer that.
Each module also owns its tables. Billing writes to subscriptions and payments; invoices writes to invoices and invoice_lines. Joins across modules in a read query are fine for reports. Writes to another module's tables are not.
Cross-cutting code: core, database and shared#
Three folders hold code that isn't a feature. Keeping them separate stops them from merging into one dumping ground.
core/ is the wiring every request goes through: authentication guards, the request context, the global exception filter, logging, health checks. CoreModule is imported once, in AppModule. The docs say to register global modules only once, typically in the root or core module, and that making everything global isn't a good design. I make exactly three things global: config, the logger and the request context.
// main.ts
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // strip properties without decorators
forbidNonWhitelisted: true, // and reject requests that send them
transform: true, // turn payloads into DTO instances
}),
);
app.enableShutdownHooks(); // close DB pools and finish jobs on SIGTERM
database/ holds the data source, the transaction helpers and the migrations. The migration CLI and the app read the same data-source.ts, so the two can't drift apart. Never set synchronize: true outside a throwaway local database; schema changes go through migrations, reviewed like code. Running database migrations safely in CI/CD covers how to ship them.
shared/ is for small, stable code with no business rules and no NestJS dependencies: a Money type, pagination helpers, date utilities. My test for whether something belongs there: would it make sense in a different product? If it mentions an invoice, a plan or a tenant, it belongs to a feature module.
The common/ folder that most projects grow is usually all three of these mixed together, plus some business logic someone couldn't find a home for. Splitting it this way gives each piece an owner.
Request context without request-scoped providers#
Large SaaS apps need a few values everywhere: the request ID for logs, the current user, the tenant. The obvious NestJS tool is Scope.REQUEST, and it's a trap at scale. A request-scoped provider makes everything that injects it request-scoped too, so a single one deep in the tree can mean NestJS builds dozens of service instances on every request.
nestjs-cls solves this with Node's AsyncLocalStorage. Services stay singletons and read the current request's values when they need them:
// core/context/context.module.ts
ClsModule.forRoot({
global: true,
middleware: {
mount: true,
generateId: true, // a request id for every log line
setup: (cls, req) => {
cls.set('userId', req.auth?.userId);
cls.set('tenantId', req.auth?.tenantId);
},
},
}),
Its transactional plugin also lets a service method run several repository calls in one transaction with a @Transactional() decorator, without passing an EntityManager through every function. For a multi-tenant app, the tenant from this context is what you hand to PostgreSQL's row-level security. Multi-tenant SaaS in PostgreSQL shows that wiring, including the connection pool detail that matters.
Configuration: validate everything at startup#
An app that starts with a missing STRIPE_WEBHOOK_SECRET and crashes on the first webhook is worse than one that refuses to start. Validate every environment variable when the app boots:
// config/env.schema.ts
import { z } from 'zod';
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
DATABASE_POOL_MAX: z.coerce.number().default(10),
REDIS_URL: z.string().url(),
JWT_PUBLIC_KEY: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
});
export type Env = z.infer<typeof envSchema>;
// app.module.ts
ConfigModule.forRoot({
isGlobal: true,
validate: (env) => envSchema.parse(env),
}),
The validate function works on every recent NestJS version. In NestJS 12, validationSchema also accepts any Standard Schema object, so you can pass the Zod schema directly. Then group settings with registerAs() (database, stripe, queue) and inject only the slice a module needs, rather than reading process.env in random files.
Background jobs and a second entry point#
Sending emails, generating PDFs, syncing with Stripe, and anything else that doesn't need to finish before the response goes to a queue. BullMQ on Redis is my default; for small apps that already have PostgreSQL and nothing else, a Postgres-backed queue built on SELECT … FOR UPDATE SKIP LOCKED works well too. Incoming webhooks take the same path: verify the signature, record the event, queue the work and answer at once. Stripe webhooks that never double-charge shows the pattern.
Two structural rules:
- Jobs live in the module that owns them. The PDF processor sits in
invoices/infrastructure/, next to the code it calls, not in a top-leveljobs/folder. - Workers get their own entry point.
worker.tsboots the same modules without an HTTP server:
// worker.ts
const app = await NestFactory.createApplicationContext(WorkerModule);
app.enableShutdownHooks();
WorkerModule imports the same feature modules as AppModule, minus the controllers' concerns. In production you run the same Docker image twice with a different command, so the API and the workers scale separately and a slow PDF job can never take an API request's CPU.
Fixing circular dependencies properly#
NestJS reports a cycle when two providers or modules depend on each other, and the documented escape hatch is forwardRef(). It works. It's also the codebase telling you two modules have an unclear relationship. One forwardRef() becomes ten, and the module graph stops meaning anything.
The three fixes I reach for, in order:
- Use an event. Invoices needs to tell notifications that an invoice was paid; notifications needs nothing back. Invoices emits
invoice.paidand notifications listens. The dependency now points one way.@nestjs/event-emitteris enough inside one process. When the event must survive a crash, write it to an outbox table in the same transaction and publish it from there. - Extract the shared concept. If customers and billing both need "the customer's billing address", that might be its own small module both depend on.
- Move the orchestration up. If a use case needs customers and billing and invoices, write it as a service in a module that imports all three, rather than having the three call each other.
To find the cycles you already have, run madge against the source:
npx madge --circular --extensions ts src/
Enforce the boundaries in CI#
Folder conventions erode. Someone in a hurry imports ../billing/infrastructure/stripe.client from the invoices module, the review misses it, and the boundary is gone. Make the rule executable. With dependency-cruiser:
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: 'no-circular',
severity: 'error',
from: {},
to: { circular: true },
},
{
name: 'only-through-public-api',
comment: 'Import another feature module through its index.ts only',
severity: 'error',
from: { path: '^src/modules/([^/]+)/' },
to: {
path: '^src/modules/[^/]+/',
pathNot: ['^src/modules/$1/', '^src/modules/[^/]+/index\\.ts$'],
},
},
{
name: 'shared-stays-generic',
severity: 'error',
from: { path: '^src/shared/' },
to: { path: '^src/(modules|core)/' },
},
],
};
npx depcruise src --config .dependency-cruiser.cjs
Run it in the same CI job as the linter. The first run on an existing codebase will be long and depressing; fix the worst offenders, record the rest as known violations, and stop new ones from appearing. A CODEOWNERS file per module helps too: the team that owns billing reviews every change to billing.
Tests that follow the structure#
- Unit tests sit next to the code (
*.spec.ts). The domain layer is where they pay off most, because it has no dependencies to mock. - Integration tests for repositories run against a real PostgreSQL in a container (Testcontainers, or a
postgresservice in GitHub Actions). Mocking the database tests your mock, not your SQL. - End-to-end tests in
test/e2e/boot the app withTest.createTestingModule(), call the HTTP API with Supertest, and cover the flows that make money: sign-up, checkout, the main workflow.
NestJS 12 projects created as ESM default to Vitest instead of Jest, and to oxlint instead of ESLint. CommonJS projects keep Jest. Either is fine; the structure of the tests doesn't change.
When to split: modules, monorepo or microservices#
A well-structured modular monolith gets you a long way. One deployable, one database, in-process calls, transactions that just work. The modules give you the boundaries you'd want from microservices without the network in between.
I'd split something out when there's a concrete reason, not before:
- A part with very different scaling needs, like a websocket gateway holding thousands of connections, or a CPU-heavy export service.
- A team that needs to deploy on its own schedule, and is big enough to own a service end to end, on-call included.
- A hard isolation requirement, such as card data that must stay in its own audited service.
When that day comes, a module with a small public API and its own tables is easy to lift out: the public service becomes an HTTP or message interface, and the tables move with it. A module that six others query directly is not.
If you end up with several deployables sharing code, NestJS's monorepo mode (apps/ and libs/) or Nx keeps them in one repository with shared libraries. In NestJS 12, monorepo mode builds with Rspack by default instead of webpack.
A note on NestJS 12#
NestJS 12 shipped in August 2026. The changes that touch structure are small: core packages are now ES modules (CommonJS apps keep working), Node.js 20.19+ or 22.12+ is required, nest new asks whether you want an ESM or CommonJS project, and route decorators accept Standard Schema validators such as Zod alongside class-validator. nest upgrade updates dependencies without converting your module format. Nothing in this post depends on the version; it applies equally to NestJS 10 and 11.
The checklist#
- One folder and one NestJS module per business capability
- Each module exports a small public API through
index.ts; repositories stay private - Each module owns its tables; no writes to another module's tables
-
core/,database/andshared/are separate, andshared/has no business rules - Only config, logging and request context are global
- Controllers are thin and return response DTOs, never entities
- Environment variables are validated at startup
- Request context uses
AsyncLocalStorage(nestjs-cls), not request-scoped providers - Jobs live in their owning module; workers have their own entry point
- No
forwardRef()without a ticket to remove it - Dependency rules and cycle checks run in CI
Need a hand?#
Untangling a NestJS codebase that has outgrown its structure, without stopping feature work, is something I do for SaaS teams: module boundaries, the request context, the database layer and the CI rules that keep it clean. See my full-stack and backend engineering services, or email mhuzaifaa.dev@gmail.com.
FAQ#
What is the best folder structure for a NestJS project?#
Organise by feature: one folder and one NestJS module per business capability, such as customers, invoices or billing, each holding its own controllers, services, entities, DTOs and tests. Keep app-wide wiring (auth, logging, error handling) in a core module, database setup and migrations in a database folder, and small generic helpers in shared.
How do I organise modules in a large NestJS application?#
Give each feature module a narrow public API: export one or two services and keep repositories and entities private. Let each module own its tables, import other modules only through their public exports, and make only config, logging and request context global. Check the rules automatically in CI with a tool such as dependency-cruiser.
How do I fix circular dependencies in NestJS?#
forwardRef() resolves the error but hides the design problem. Prefer one of three fixes: emit an event so the dependency points one way, extract the shared concept into its own module, or move the orchestration into a higher-level service that imports both modules. Run madge --circular to find existing cycles.
Should I use a monorepo or microservices with NestJS?#
Start with a modular monolith: one deployable with well-separated modules. Split a module into its own service only for a concrete reason, such as very different scaling needs, a team that must deploy independently, or a strict isolation requirement. NestJS's monorepo mode or Nx help once you have several deployables sharing code.
Should I use request-scoped providers in NestJS?#
Rarely. A request-scoped provider makes every provider that injects it request-scoped too, which multiplies object creation on every request. For values such as the request ID, current user or tenant, use AsyncLocalStorage, for example through the nestjs-cls package, and keep services as singletons.
Is clean architecture worth it in NestJS?#
For modules with real business rules, a light version is: controllers, application services, a domain layer with no framework imports, and repositories. For simple CRUD modules, it adds files without adding clarity. Apply the layering where the business logic is the hard part, and keep the rest flat.
