When I first started building APIs with Express.js, I made the same mistake many beginners make:
"If it works, just put it in the controller."
So that's exactly what I did.
Database queries? Controller.
Validation? Controller.
Business logic? Controller.
Error handling? Controller. Everything lived in the same file.
At first, this wasn't a problem. The project was small, the controllers were short, and finding bugs was easy. But as the application grew, so did the controllers. A controller that started with 20 lines slowly became 100 lines. Then 300. Then 600+ lines.
At that point, reading the code became difficult. Finding bugs took longer, testing became harder, and making changes felt risky because every responsibility was mixed together in the same file. That's when I discovered the Service Layer pattern.
What Is a Service Layer?
A service layer is responsible for handling your application's business logic.
Think of it this way:
Routes decide which endpoint should be called.
Controllers handle HTTP requests and responses.
Services handle the actual business logic.
Business logic includes things such as:
Database queries
Validation rules
Permission checks
User-related operations
Payment processing
Business rules specific to your application
Instead of putting all of this inside a controller, you move it into dedicated service files. This keeps your controllers small and focused while making your business logic easier to reuse, test, and maintain.
A Simple Analogy
A controller is like a waiter in a restaurant. The waiter takes your order and brings your food to the table. However, the waiter doesn't cook the food. The kitchen does.
In this analogy:
The controller is the waiter.
The service layer is the kitchen.
The controller receives the request, passes it to the service layer, and returns the result to the client. The actual work happens inside the service.
Without a Service Layer
A typical controller often starts looking like this:
export const getUser = async (req, res) => {
const id = req.params.id;
const user = await User.findById(id);
if (!user) {
return res.status(404).json({
message: "User not found",
});
}
return res.json(user);
};
At first glance, this seems fine.
The problem begins when more logic gets added:
Validation
Authorization checks
Database operations
Error handling
Business rules
Eventually, the controller becomes responsible for everything.
With a Service Layer
Controller:
export const getUser = async (req, res) => {
try {
const user = await getUserById(req.params.id);
return res.json(user);
} catch (error) {
return res.status(404).json({
message: error.message,
});
}
};
Service:
export const getUserById = async (id) => {
const user = await User.findById(id);
if (!user) {
throw new Error("User not found");
}
return user;
};
Notice the difference.
The controller no longer knows how users are fetched. Its only responsibility is handling the request and returning a response. The service layer contains the business logic required to retrieve a user. This separation becomes increasingly valuable as your application grows.
Common Mistakes to Avoid
1. Putting all business logic inside controllers
Controllers should not become the place where everything happens. Their primary responsibility is handling the request-response cycle.
2. Turning services into another controller
Moving code to a service file is not enough. Services should contain business logic, not HTTP-specific logic such as req, res, or status codes.
3. Ignoring error handling
Service functions can throw errors. Controllers should properly handle those errors and return appropriate responses.
4. Creating services too early for tiny projects
Not every project needs a large architecture from day one. For small applications, simple controllers may be enough. As complexity grows, introducing services becomes more valuable.
Conclusion
The biggest lesson is simple:
Controllers handle requests and responses. Services handle business logic.
Keeping those responsibilities separate makes your code easier to read, test, maintain, and scale as your application grows.
The goal isn't to follow architecture patterns for the sake of it. The goal is to prevent your controllers from becoming 600-line files that nobody enjoys maintaining.
Top comments (0)