Writing try-catch in every single controller is exhausting and makes your code look like a repetitive mess. Even worse? Sending back different error formats for different routes.
Today, we’ll build a centralized "Air Traffic Control" system for your errors and responses.
Step 1: Create a Global Async Wrapper - The asyncHandler Utility
Instead of wrapping every controller function in a try-catch, we create a wrapper that catches errors and passes them to Express's global error handler automatically.
Create: src/utils/asyncHandler.js
export const asyncHandler = (requestHandler) => {
return (req, res, next) => {
Promise.resolve(requestHandler(req, res, next))
.catch(err => next(err));
};
};This asyncHandler takes any async function and ensures that if it throws an error, it will be caught and passed to the next middleware (our global error handler).
💡 Industry Note: We built our own
asyncHandlerso you understand how it works. In real projects, many developers use theexpress-async-handlernpm package, which does exactly the same thing. Understanding the pattern is more important than memorizing the code.
Step 2: Standardizing Responses & Errors
To make your API predictable for frontend developers, you should always return the same JSON structure.
Create: src/utils/ApiResponse.js
class ApiResponse {
constructor(statusCode, data, message = "Success") {
this.statusCode = statusCode;
this.data = data;
this.message = message;
this.success = statusCode < 400;
}
}
export { ApiResponse };This ApiResponse class standardizes the structure of your API responses, making it easier for frontend developers to handle them.
Create: src/utils/ApiError.js
class ApiError extends Error {
constructor(statusCode, message = "Something went wrong", errors = []) {
super(message);
this.statusCode = statusCode;
this.data = null;
this.success = false;
this.errors = errors;
}
}
export { ApiError };This ApiError class standardizes the structure of your error responses, ensuring that all errors are returned in a consistent format.
Step 3: Refactoring the Controller
Look how much cleaner the user.controller.js becomes when we remove the "noise" of repeated try-catches and manual status codes.
Update: src/controllers/user.controller.js
import { asyncHandler } from '../utils/asyncHandler.js';
import { ApiResponse } from '../utils/ApiResponse.js';
import { ApiError } from '../utils/ApiError.js';
import User from '../models/user.model.js';
export const getUsers = asyncHandler(async (req, res) => {
const users = await User.find();
return res.status(200).json(new ApiResponse(200, users, "Users fetched successfully"));
});
export const createUser = asyncHandler(async (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
throw new ApiError(400, "All fields are required");
}
const newUser = await User.create({ name, email });
return res.status(201).json(new ApiResponse(201, newUser, "User created successfully"));
});
export const deleteUser = asyncHandler(async (req, res) => {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) {
throw new ApiError(404, "User not found");
}
return res.status(200).json(new ApiResponse(200, null, "User deleted successfully"));
});The Comparison
Before (Spaghetti Code):
// BEFORE: Repetitive and verbose
export const getUsers = async (req, res) => {
try {
const users = await User.find();
res.status(200).json(users);
} catch (error) {
res.status(500).json({ message: error.message });
}
};After (Clean Architecture):
// AFTER: Clean and consistent
export const getUsers = asyncHandler(async (req, res) => {
const users = await User.find();
return res.status(200).json(new ApiResponse(200, users, "Users fetched"));
});Notice how the "After" version reads like plain English? We focus on the what, not the how.
Why Go Through All This Trouble?
You might be thinking, "Can't I just use console.log?"
- Consistency: Frontend developers hate parsing different error formats. This forces every error to look identical (
{ statusCode, data, message, success }). - Debugging: When a user reports a bug, you know exactly where to look because your errors are structured.
- Scalability: If you decide to add a logging service (like Sentry or Datadog) later, you only have to update it in one place (
ApiError), not in 50 different controllers.
Summary
You have successfully standardized your inputs (ApiResponse) and your outputs (ApiError). Your controllers are now focused purely on logic, not error handling.
But wait. When we write throw new ApiError(...) inside our asyncHandler, where does the error actually go?
Right now, it goes nowhere (or crashes the app). We have the tools to throw errors, but we don't have the safety net to catch them yet.
In the next post, we will build the final piece of our backend architecture: The Global Error Middleware. This will be the "Air Traffic Control" tower that catches every ApiError we throw, hides the sensitive stack traces, and sends a beautiful JSON response to the user.