T
he folder structure that felt obvious when you were the only developer becomes a liability at five. Files end up wherever made sense in the moment.utils.ts grows to 800 lines. Nobody agrees where new modules belong, so they go in the root. Six months later you have a misc/ folder that everyone's afraid to touch.
TypeScript doesn't enforce structure. That's your job. And the choices you make at month one compound good or bad for as long as the project lives.
This is the layout we use on production projects. It works for a solo developer and doesn't need a rewrite at ten.
Layer-based vs feature-based: the decision that matters most
Most teams default to layer-based structure without thinking about it:
src/
controllers/
services/
models/
utils/
types/
It feels clean because it mirrors the mental model every controller lives together, every service lives together. The problem surfaces when the codebase grows. To understand the User domain you open controllers/user.ts, services/user.ts, models/user.ts, and probably utils/userHelpers.ts. Four folders, four context switches, one feature.
Feature-based structure keeps related code together:
src/
features/
user/
user.controller.ts
user.service.ts
user.repository.ts
user.types.ts
order/
order.controller.ts
order.service.ts
order.repository.ts
order.types.ts
shared/
lib/
When a new developer asks "where does the order cancellation logic live?" the answer is features/order/. Not three folders. Not a grep. One place.
This isn't a religious argument. Layer-based works fine for small projects with one or two domains. The inflection point is when you have more than four or five distinct features at that size, feature-based pays back in navigation time every single day.
The structure in full
Here's what a production TypeScript API project looks like with this layout:
src/
features/
user/
user.controller.ts
user.service.ts
user.repository.ts
user.types.ts
user.test.ts
order/
...
shared/
middleware/
auth.middleware.ts
error.middleware.ts
errors/
AppError.ts
HttpError.ts
types/
pagination.types.ts
response.types.ts
lib/
db/
client.ts
migrations/
cache/
redis.ts
logger/
index.ts
config/
env.ts
constants.ts
index.ts
A few things to notice.
shared/ holds code used across more than one feature middleware, common error classes, shared types. The rule: nothing lives in shared/ unless two or more features need it. If only one feature uses it, it stays in that feature folder.
lib/ holds infrastructure wrappers your database client, cache connection, logger. These aren't business logic. They're tools the features reach for. Keeping them separate means you can swap a Redis client for something else without touching a single service file.
config/ is environment variables and constants. env.ts reads process.env, validates it (Zod works well here), and exports typed values. No feature file should ever import directly from process.env that spreads an untested dependency across the entire codebase.
index.ts is the entry point. It wires everything together. It's intentionally thin.
Shared code without the chaos
The shared/ folder is where structure usually breaks down. Teams either put too much in it (it becomes a second utils/) or avoid it entirely and start duplicating code across features.
Two rules keep it clean.
First: the two-consumer rule. Code doesn't move to shared/ until a second feature needs it. When you write features/order/price.utils.ts and then features/invoice/ needs the same pricing logic, that's the moment it moves to shared/. Not before.
Second: no circular dependencies. Features can import from shared/ and lib/. Features cannot import from other features. If order.service.ts needs something from user.service.ts, that logic belongs in shared/ or it's a sign the domain boundaries are wrong.
TypeScript path aliases make this readable. In tsconfig.json:
Untitled1{2 "compilerOptions": {3 "paths": {4 "@shared/*": ["./src/shared/*"],5 "@lib/*": ["./src/lib/*"],6 "@features/*": ["./src/features/*"]7 }8 }9}
Then imports read as import { AppError } from '@shared/errors/AppError' instead of ../../../../shared/errors/AppError. Legible, refactor-safe, and you can move files without updating twenty import paths.
The part most people get wrong
The flat structure mistake. Teams start with everything in src/ at the top level:
src/
user.controller.ts
user.service.ts
order.controller.ts
order.service.ts
auth.middleware.ts
db.ts
logger.ts
config.ts
types.ts
utils.ts
helpers.ts
This feels fine at ten files. At fifty, every new team member asks the same question: "where does this go?" The answer is never obvious, so it goes wherever makes the least friction, which is usually the root. Then you have eighty files in one directory and nobody wants to touch it.
The other mistake is over-nesting early. Teams anticipate scale by creating sub-folders for everything:
src/
features/
user/
controllers/
UserController.ts
services/
UserService.ts
repositories/
UserRepository.ts
At small scale this adds folders without adding clarity. One file per concern inside the feature folder is enough until you have multiple controllers or services within the same feature which is rare.
Start flat inside features. Add sub-folders when a feature folder hits more than eight files. Not before.
Real-world example: fintech API in Jakarta
A payment platform we worked with had two years of layer-based structure. Six developers, twelve domains, a services/ folder with 34 files. Onboarding a new backend engineer took four days before they felt confident making a change without breaking something adjacent.
We migrated to feature-based structure over three weeks not a rewrite, a reorganisation. The process: identify the twelve domains, move files into feature folders one domain at a time, update imports, run tests. Each domain migration was a separate PR so nothing was a big-bang change.
After migration, the same onboarding exercise took half a day. The new engineer could read features/disbursement/ and understand the entire disbursement flow without cross-referencing four directories. The shared/ folder had eighteen files genuinely shared utilities, not a dumping ground.
The structure didn't change what the code did. It changed how fast people could understand it.
FAQ
Q: Should tests live alongside source files or in a separate __tests__ directory?
A: Alongside source files, in the same feature folder. user.test.ts next to user.service.ts means you always know where the test is, and moving a feature folder keeps tests attached. A top-level __tests__ directory tends to get out of sync with the source structure.
Q: What about monorepos? Does this layout work there?
A: Yes, but each package in the monorepo gets its own src/ with this structure. The monorepo root handles shared tooling and workspace config not application code. Turborepo and Nx both work well with feature-based internals.
Q: How do I handle types that are used everywhere?
A: Distinguish between domain types and infrastructure types. Domain types (a User shape, an Order status enum) live in their feature folder. Infrastructure types (pagination wrappers, HTTP response shapes, error codes) live in shared/types/. Resist creating a single top-level types.ts it becomes a catch-all within weeks.
Q: When does a feature folder get its own sub-folders? A: When it has more than eight files. A feature with eight files is fine flat. A feature with fifteen files needs grouping probably by layer (controller, service, repository) within the feature. The goal is that any file in the project is locatable within two clicks from the feature root.
Q: We're using NestJS which enforces its own module structure. Does this apply?
A: The principle applies but the mechanics adapt. NestJS modules map naturally to feature folders one module per domain. Keep shared/ for cross-cutting concerns, lib/ for infrastructure providers, and config/ for environment config. The layer-based vs feature-based decision is the same regardless of framework.
Structure is a communication tool. The folder layout tells every developer who joins your team what the mental model of the system is. Get it right early, and adding a tenth developer costs you an afternoon of orientation. Get it wrong, and you spend months untangling conventions that compounded for two years.
For the tsconfig settings that back this up path aliases, strict options, what to ignore that's covered next.
For the full project setup from tooling to runtime, start at [→ Read: The TypeScript Project Setup That Stops Arguments and Ships Faster].
Internal Reference Logs: