5 Node.js Patterns That Saved Me Hours of Debugging
After years of writing Node.js applications, I have learned that the difference between maintainable code and spaghetti code often comes down to a few key patterns. Today, I want to share five patterns that have completely transformed how I write and debug Node.js applications.
1. The Repository Pattern: Decoupling Data Access
One of the biggest mistakes I see in Node.js applications is directly using database queries throughout controllers and services. This creates tight coupling and makes testing a nightmare.
Instead, I use the Repository pattern to abstract data access:
// repositories/UserRepository.js
class UserRepository {
constructor(database) {
this.db = database;
}
async findById(id) {
return this.db.users.findOne({ id });
}
async findByEmail(email) {
return this.db.users.findOne({ email });
}
async create(userData) {
return this.db.users.create(userData);
}
}
module.exports = UserRepository;
Now your controller does not care if you are using MongoDB, PostgreSQL, or a mock database. This single change made our tests run 10x faster because we could easily swap in an in-memory database.
2. Error Handling Middleware: One Place for All Errors
Forget try-catch blocks scattered throughout your code. Express has a powerful middleware system that handles errors beautifully:
// Middleware error handler (must be LAST middleware)
const errorHandler = (err, req, res, next) => {
console.error("Error:", err);
if (err.name === "ValidationError") {
return res.status(400).json({
error: "Validation Failed",
details: err.message
});
}
if (err.name === "UnauthorizedError") {
return res.status(401).json({
error: "Authentication Required"
});
}
// Do not leak error details in production
res.status(500).json({
error: process.env.NODE_ENV === "production"
? "Internal Server Error"
: err.message
});
};
app.use(errorHandler);
The key insight: always put your error handler LAST in the middleware stack. This ensures it catches all errors from all routes.
3. The Builder Pattern for Complex Queries
When building APIs, we often need to construct complex database queries based on optional filters. The Builder pattern makes this clean and readable:
class UserQueryBuilder {
constructor() {
this.filters = {};
this.sort = {};
this.limit = 50;
this.offset = 0;
}
withRole(role) {
this.filters.role = role;
return this;
}
withStatus(status) {
this.filters.status = status;
return this;
}
createdAfter(date) {
this.filters.createdAt = { $gte: date };
return this;
}
sortedBy(field, order = "desc") {
this.sort[field] = order;
return this;
}
paginated(page, perPage) {
this.limit = perPage;
this.offset = (page - 1) * perPage;
return this;
}
build() {
return {
query: this.filters,
sort: this.sort,
limit: this.limit,
offset: this.offset
};
}
}
// Usage becomes beautifully readable
const query = new UserQueryBuilder()
.withRole("admin")
.withStatus("active")
.createdAfter(new Date("2025-01-01"))
.sortedBy("createdAt")
.paginated(1, 20)
.build();
No more messy conditional logic scattered throughout your route handlers.
4. Dependency Injection: Making Code Testable
This is the pattern that had the biggest impact on my Node.js applications. Instead of importing modules directly, we inject dependencies:
// Before: Tightly coupled, hard to test
class UserService {
async getUser(id) {
const user = await db.users.findOne({ id });
const emailService = new EmailService();
await emailService.sendWelcome(user.email);
return user;
}
}
// After: Dependency Injection
class UserService {
constructor(userRepository, emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
async getUser(id) {
const user = await this.userRepository.findById(id);
if (user) {
await this.emailService.sendWelcome(user.email);
}
return user;
}
}
// Easy testing with mocks
const mockRepo = {
findById: async () => ({ id: 1, email: "test@example.com" })
};
const mockEmail = {
sendWelcome: async () => {}
};
const service = new UserService(mockRepo, mockEmail);
Now you can test UserService without touching the database or sending real emails. Game changer.
5. Event-Driven Architecture for Loose Coupling
Instead of directly calling services within your business logic, emit events and let listeners handle side effects:
const EventEmitter = require("events");
// Create a dedicated event bus
const eventBus = new EventEmitter();
// In your service, emit events instead of calling other services
class OrderService {
async createOrder(orderData) {
const order = await this.orderRepository.create(orderData);
// Emit event - do not call other services directly
eventBus.emit("order.created", {
orderId: order.id,
customerEmail: order.customerEmail,
total: order.total
});
return order;
}
}
// Listeners handle the rest (in a separate file or module)
eventBus.on("order.created", async (data) => {
await sendConfirmationEmail(data.customerEmail, data.orderId);
await updateInventory(data.orderId);
await analytics.track("order_created", data);
});
eventBus.on("order.created", async (data) => {
if (data.total > 1000) {
await sendHighValueAlert(data);
}
});
This pattern makes your code remarkably flexible. Want to add a new side effect? Just add another listener. Want to disable something? Comment out or remove the listener. No code changes to your core business logic.
The Bigger Picture
These patterns share a common theme: they reduce complexity by creating clear boundaries between different parts of your application. When something breaks, you know exactly where to look. When you need to add features, you know exactly where to add them.
Start implementing one pattern at a time. Your future self will thank you when debugging takes minutes instead of hours.
What patterns have saved your Node.js development? I would love to hear about your experiences in the comments.
Top comments (0)