Project Structure: Feature vs Layer Folders
Why Project Structure Matters
In backend projects, the biggest cost is not writing code, it is maintaining and changing it safely. A good structure makes the next 6 months easier. A bad structure makes every change risky.
Common Failure Mode
The typical failure pattern is “everything in one folder” or “random folders” that grow without boundaries. You end up with circular dependencies, duplicated logic, and untestable code.
Principle: Separate Concerns
- App wiring: server startup, dependency wiring, routes registration
- Modules/features: business capabilities (users, orders, billing)
- Shared: cross-cutting utilities (logger, config, errors)
- Infrastructure: DB clients, external services, adapters
Recommended Baseline Structure
src/
index.ts
app/
createApp.ts
routes.ts
modules/
users/
users.controller.ts
users.service.ts
users.repo.ts
users.dto.ts
orders/
orders.controller.ts
orders.service.ts
orders.repo.ts
orders.dto.ts
shared/
config.ts
logger.ts
errors.ts
http.ts
infra/
db.ts
redis.ts
tests/
Feature-Based vs Layer-Based
Two popular styles exist:
- Layer-based: controllers/, services/, repositories/ across the whole app
- Feature-based: each feature owns its controller/service/repo
For growing backends, feature-based structure usually scales better because ownership and boundaries are clearer.
Rule: No Business Logic in Controllers
Controllers should translate HTTP to application calls. Business logic belongs in services/use-cases. This makes testing easier and avoids “HTTP coupled” business rules.
Rule: Keep Dependencies Pointing Inward
Higher-level modules should not depend on lower-level infrastructure details directly. Instead, define interfaces at the boundary and inject implementations.
Example: Minimal Wiring
// src/index.ts
import { createApp } from './app/createApp.js';
const app = createApp();
app.listen(3000, () => console.log('Listening on 3000'));
Example: Module Boundary
// src/modules/users/users.service.ts
export class UsersService {
constructor(private repo: UsersRepo) {}
async getUser(id: string) {
return this.repo.findById(id);
}
}
Testing Benefit
With clear boundaries, you can unit test services without running the HTTP server or database. Integration tests then validate the wiring.
Production Insight
- Structure is an API for your team.
- Good boundaries reduce merge conflicts and regressions.
- Clear layering makes it easier to add caching, auth, logging later.
JS Note
Structure rules apply equally to JS and TS. TypeScript simply makes boundaries more explicit via types and interfaces.
Next Step
Next we will cover linting and formatting: how to keep code style consistent and prevent common mistakes automatically.