Layered Node.js API Design: From Fat Controllers to Clean Architecture
Learn how to refactor a Node.js API into controller, service, and data-access layers to fix tangled business logic, inconsistent errors, and scaling pain.
This article is published in English.
Sources: I Built a Node.js API. Then I Realized I Was Doing It Wrong.
Practical lessons about structuring, scaling and maintaining a Node.js backend properly, drawn from building production APIs.
When you first start writing Node.js APIs, backend work can feel deceptively simple.
Define a route.
Wire it up to the database.
Send back some JSON.
That's it.
And for a while, that approach genuinely works.
Then the application starts to grow.
All of a sudden you're facing:
- Business rules embedded directly in controllers
- Database calls spread across every route file
- Repeated validation logic
- Error handling that varies from endpoint to endpoint
- Bloated files
- Code that resists automated testing
- Auth logic tangled up with domain logic
- Small changes that break features that seemed unrelated
The API isn't technically failing.
Something more subtle is going wrong:
Making changes to it has become genuinely painful.
That's the moment you realize there's a real difference between an API that simply runs and one that can hold up under continuous, real-world development.
1. My Early Node.js Architecture
A typical early-stage API might look like this:
const express = require("express");
const app = express();
app.get("/users", async (req, res) => {
const users = await db.query("SELECT * FROM users"); res.json(users);
});app.post("/users", async (req, res) => {
const { name, email } = req.body; const user = await db.query(
"INSERT INTO users (name, email) VALUES ($1, $2)",
[name, email]
); res.json(user);
});app.listen(3000);
None of this code is wrong on its own.
For a quick prototype, it's a completely reasonable starting point.
Trouble begins once the project starts to scale.
Picture layering in features like:
- Authentication
- Authorization
- Input validation
- Email verification
- Payment processing
- Coupon handling
- Order management
- Inventory tracking
- Notifications
- Background job processing
- Redis-based caching
Now picture cramming every one of those concerns directly into your route handlers.
At that point you no longer have a real architecture.
What you have is a mass of code that just happens to execute.
2. The Problem Wasn't Node.js
This is a mistake that's easy to make early on.
The instinct is to think:
"My codebase is getting messy simply because the app is getting bigger."
That's only part of the story.
The actual root cause was a lack of separated responsibilities.
A single controller was handling far too much.
It was responsible for:
- Parsing the incoming request
- Validating the input
- Verifying authentication
- Running the business logic
- Talking to the database
- Shaping the response
- Catching and handling errors
That's six or seven distinct jobs crammed into one place.
Any time a requirement shifted, you'd end up editing that same file all over again.
Repeatedly.
3. The Architecture I Use Now
A cleaner strategy is to split the application into distinct layers.
For instance:
src/
│
├── controllers/
│ └── user.controller.ts
│
├── services/
│ └── user.service.ts
│
├── repositories/
│ └── user.repository.ts
│
├── dto/
│ └── create-user.dto.ts
│
├── middleware/
│ └── auth.middleware.ts
│
├── routes/
│ └── user.routes.ts
│
├── utils/
│ └── error-handler.ts
│
└── app.ts
The underlying principle is straightforward:
Every layer should own one clear responsibility.
4. Controllers Shouldn't Contain Business Logic
A controller's job should be limited to HTTP concerns.
Something along these lines:
export class UserController {
constructor(
private readonly userService: UserService
) {}
async createUser(req: Request, res: Response) {
try {
const user = await this.userService.createUser(req.body); return res.status(201).json({
success: true,
data: user,
});
} catch (error) {
return res.status(500).json({
success: false,
message: error.message,
});
}
}
}
The controller has no need to know:
- Where or how the user record is stored
- Which database technology is in use
- How passwords get hashed
- Whether a confirmation email needs sending
- Whether Redis is involved anywhere
None of that is its concern.
Its only responsibility is handling the incoming request and shaping the outgoing response.
5. Put Business Logic in Services
The service layer is where the actual application logic lives.
export class UserService {
constructor(
private readonly userRepository: UserRepository
) {}
async createUser(data: CreateUserDto) {
const existingUser =
await this.userRepository.findByEmail(data.email); if (existingUser) {
throw new Error("User already exists");
} const hashedPassword = await hashPassword(
data.password
); return this.userRepository.create({
...data,
password: hashedPassword,
});
}
}
This makes the logic reusable.
Some other part of the system, for example, could simply call:
userService.createUser(data);
without needing to simulate an HTTP request at all.
That reusability matters a lot for both testing and long-term maintainability.
6. Database Access Deserves Its Own Layer
Business logic shouldn't be cluttered with raw SQL statements or ORM calls scattered everywhere.
This is exactly the problem repositories solve.
export class UserRepository {
async findByEmail(email: string) {
return prisma.user.findUnique({
where: {
email,
},
});
} async create(data: CreateUserInput) {
return prisma.user.create({
data,
});
}
}
With this in place, the separation of concerns becomes obvious.
Request
↓
Controller
↓
Service
↓
Repository
↓
Database
Every layer only knows what it strictly needs to know, and nothing more.
7. Why DTOs Matter More Than You'd Expect
A common early mistake is passing raw request bodies straight into the application.
Something like this:
const user = await userService.createUser(req.body);
That's risky.
What actually lives inside req.body?
Can you be sure?
A DTO establishes a clear, defined contract for what's expected.
class CreateUserDto {
name: string;
email: string;
password: string;
}
From there, incoming data gets validated before it's allowed anywhere near the application logic.
For instance, a payload like:
{
"name": "Mit",
"email": "mit@example.com",
"password": "secret123"
}
can be checked against rules such as:
- Required fields
- Email format
- Password length
- Data types
- Allowed values
The core principle here is simple:
Never trust input coming from the client.
The frontend does not function as a security boundary.
8. Consistency in Error Handling Matters
A related issue in early-stage APIs is inconsistent response shapes.
One route might send back:
{
"error": "User not found"
}
Another responds with:
{
"message": "Not found"
}
While yet another looks like:
{
"success": false,
"data": null
}
For anyone building a frontend against this API, that inconsistency is exhausting.
Adopting one centralized error-handling approach makes the API dramatically easier to work with.
For example:
{
"success": false,
"message": "User not found",
"code": "USER_NOT_FOUND"
}
The specific shape chosen doesn't matter much.
What matters is that it's always the same.
9. Authentication Is Not Authorization
This is a distinction that trips up many developers early on.
Authentication answers one question:
Who are you?
Authorization answers a different one:
What are you permitted to do?
As a flow, it looks something like:
Login
↓
Authentication
↓
JWT / Session
↓
User identified
↓
Authorization
↓
Can this user delete the product?
Just because a user is authenticated doesn't mean they're cleared to perform any action they want.
That's precisely why role and permission checks need to live separately from basic login/authentication logic.
10. Eventually You Have to Think About Scale
Once the code is reasonably well organized, a new question shows up:
What happens as traffic grows?
This is the point where things like Redis, job queues, and database indexes start earning their place.
Imagine an endpoint that keeps running a costly database query on every request.
Rather than:
Request
↓
Database
↓
Response
the system might eventually move to something like:
Request
↓
Redis Cache
↓
Cache Hit → Response
│
└── Cache Miss
↓
Database
↓
Redis
↓
Response
And for anything that shouldn't block the response:
API
↓
Queue
↓
Worker
↓
Email / Notification / Processing
This keeps slow, non-critical work from delaying the actual request.
But here's the caveat worth repeating:
Don't reach for Redis, queues, Kafka, microservices, or Kubernetes simply because they look impressive.
Architecture exists to solve a real problem.
It shouldn't manufacture new ones.
11. The Most Important Lesson Here
The real shift in mindset comes down to this:
Stop optimizing for how fast code gets written.
Optimize instead for how safely it can be changed later.
When starting a first project, cramming everything into a single file feels efficient.
And at first, it genuinely is.
But writing the initial version is a small fraction of the total work.
Most of what happens to a codebase comes afterward.
Over time a project will need to:
- Add features
- Fix bugs
- Adjust to changing requirements
- Swap out libraries
- Update database schemas
- Bolt on authentication
- Introduce permission systems
- Speed up slow queries
- Hook into third-party services
If the architecture turns each of those into a struggle, the early speed gained wasn't really a win.
12. A Better Starting Point
A fresh Node.js backend today could reasonably be laid out something like this:
src/
│
├── modules/
│ ├── users/
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ ├── user.repository.ts
│ │ ├── user.dto.ts
│ │ └── user.routes.ts
│ │
│ ├── products/
│ │ ├── product.controller.ts
│ │ ├── product.service.ts
│ │ ├── product.repository.ts
│ │ └── product.dto.ts
│
├── middleware/
├── config/
├── database/
├── utils/
└── app.ts
For larger applications, organizing code around modules or features tends to work better than lumping everything into giant shared folders full of unrelated controllers and services.
The exact folder layout isn't the point.
What matters is that each piece's responsibility stays obvious.
13. Seven Rules Worth Following
Boiled down, this all comes to seven rules:
1. Keep controllers thin
Controllers exist to handle HTTP, nothing more.
2. Put business logic in services
Services are where an application's actual behavior should live.
3. Isolate database operations
Avoid letting database queries leak into every corner of the codebase.
4. Validate external input
Don't assume req.body contains what's expected.
5. Standardize errors
Make failure responses predictable and uniform.
6. Add infrastructure only when needed
Redis, queues, and microservices are tools reached for when justified, not signals of seniority.
7. Design for change
Whoever maintains this code will be modifying it far more often than writing it fresh.
Conclusion
Getting a Node.js API up and running isn't hard.
Keeping it understandable months into ongoing feature work is a much bigger challenge.
It's easy to equate good backend development with knowing how to wire up:
GET
POST
PUT
DELETE
That's really just the starting point.
The real skill lies in knowing where each piece of logic should live, how the different parts of the system talk to each other, and how to evolve it without breaking everything else in the process.
Not every project needs an elaborate architecture.
What it needs is an architecture proportional to the complexity of the actual problem.
Because the goal was never:
"Get it working."
The real goal is:
"Make it easy to adapt once the requirements inevitably shift."