Folder Structure
There is no single correct folder structure, but the good ones share traits: they group code by feature rather than by technical type, keep a clear boundary between source and build output, and make the location of any file predictable. As a project grows, structure is what keeps it navigable — a newcomer should be able to guess where something lives. This page presents a pragmatic feature-based layout for a typical Node/TypeScript service, explains the role of each directory, and shows how TypeScript project references scale the same ideas to multi-package monorepos with fast, incremental builds.
Feature-based layout
Organize the top level around what the application does, not around technical categories like controllers/ or services/. Each feature folder owns its routes, logic, model, and tests, exposing a small public surface through an index.ts.
my-app/
├── src/
│ ├── features/
│ │ ├── user/
│ │ │ ├── user.controller.ts
│ │ │ ├── user.service.ts
│ │ │ ├── user.model.ts
│ │ │ ├── user.test.ts
│ │ │ └── index.ts # feature's public API
│ │ └── order/
│ │ ├── order.service.ts
│ │ ├── order.model.ts
│ │ └── index.ts
│ ├── shared/ # cross-feature, stable utilities
│ │ ├── http/
│ │ ├── logger.ts
│ │ └── types.ts
│ ├── config/ # env parsing, constants
│ │ └── env.ts
│ └── main.ts # composition root / entry point
├── tests/ # e2e / integration tests
├── dist/ # compiled output (gitignored)
├── package.json
├── tsconfig.json
└── tsconfig.build.json
Everything for a feature sits in one folder, so changing “user” rarely means touching unrelated directories. shared/ holds code many features reuse, and main.ts is the single composition root where the object graph is wired together.
Why feature-based over type-based
A type-based layout (controllers/, services/, models/ at the top) forces you to open three directories to understand one feature and grows worse with scale. Feature-based keeps related changes local.
| Concern | Feature-based | Type-based |
|---|---|---|
| Locating all code for a feature | One folder | Scattered across many |
| Deleting a feature | Remove one folder | Hunt across layers |
| Merge conflicts | Localized | Spread across shared dirs |
| Onboarding clarity | High | Drops as app grows |
The type-based style is fine for very small apps, but feature-based pays off the moment you have more than a handful of domains.
Separate source from output
Keep src/ (input) and dist/ (output) strictly separate via rootDir and outDir, and never commit the build. This avoids stale artifacts and keeps the repo clean.
{
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
A dedicated tsconfig.build.json can extend the base config to exclude tests from the published output, while the base tsconfig.json includes everything for editor and test tooling.
Scaling up with project references
For monorepos or large apps split into packages, TypeScript project references let each package build independently and incrementally. A root config references the leaf projects; each leaf marks itself composite.
monorepo/
├── packages/
│ ├── core/
│ │ ├── src/
│ │ └── tsconfig.json # "composite": true
│ ├── api/
│ │ ├── src/
│ │ └── tsconfig.json # references ../core
│ └── web/
│ └── tsconfig.json
└── tsconfig.json # references all packages
// monorepo/tsconfig.json
{
"files": [],
"references": [
{ "path": "packages/core" },
{ "path": "packages/api" },
{ "path": "packages/web" }
]
}
// packages/api/tsconfig.json
{
"compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" },
"references": [{ "path": "../core" }]
}
Build the whole graph in dependency order with tsc --build (or tsc -b --watch). References give you enforced boundaries between packages, faster rebuilds via .tsbuildinfo caching, and editor go-to-definition that jumps to source across packages.
Run
tsc --build, not plaintsc, once you use project references — only build mode understands the reference graph and incremental caching. Plaintscignoresreferences.
Best Practices
- Organize by feature, not by technical layer, once you have more than a few domains.
- Give each feature an
index.tsthat exposes only its intended public API. - Keep cross-cutting, stable code in
shared/and never let it import features. - Separate
src/anddist/withrootDir/outDir; gitignore the build output. - Designate one entry/composition root (
main.ts) where dependencies are wired. - Use project references with
composite: truefor monorepos and large builds. - Build reference graphs with
tsc --buildto get incremental, ordered compilation.