How I Fixed Messy Error Handling in Node.js Microservices with a Layered Approach
My Node.js microservice was crashing in production, and I couldn’t figure out why. The logs showed “unhandled promise rejection” but I had try/catch blocks everywhere. Or so I thought.
UnhandledPromiseRejectionWarning: Error: Connection refused at UserService.transferFunds (user-service.ts:45:15) at processTicksAndRejections (internal/process/task_queues.js:95:5)I had wrapped every database call in try/catch. I had error handling in every controller. But errors were still slipping through, and my logs were a mess of inconsistent error messages. Some returned { error: "User not found" }, others returned { message: "Database error" }, and some just returned the raw Postgres error with connection details.
The real problem? I was handling errors at the wrong layer. Let me show you how I fixed it with a layered error handling strategy.
The Problem: Scattered Error Handling
My original code looked like this:
async function getUserById(userId: string) { try { const result = await db.query('SELECT * FROM users WHERE id = $1', [userId]); if (!result.rows[0]) { return { error: 'User not found' }; } return { data: result.rows[0] }; } catch (err) { console.error('Database error:', err); return { error: 'Something went wrong' }; }}
async function transferFunds(fromId: string, toId: string, amount: number) { try { const fromUser = await getUserById(fromId); if (fromUser.error) { return { error: fromUser.error }; }
if (fromUser.data.balance < amount) { return { error: 'Insufficient funds' }; }
// More try/catch blocks for each database operation... await db.query('BEGIN'); try { await db.query('UPDATE users SET balance = balance - $1 WHERE id = $2', [amount, fromId]); await db.query('UPDATE users SET balance = balance + $1 WHERE id = $2', [amount, toId]); await db.query('COMMIT'); } catch (err) { await db.query('ROLLBACK'); throw err; }
return { success: true }; } catch (err) { console.error('Transfer failed:', err); return { error: 'Transfer failed' }; }}This approach has several problems:
- Inconsistent error responses - Each function returns errors differently
- Error swallowing - Some errors get logged and swallowed, never reaching the caller
- No error typing - Can’t distinguish between “user not found” and “database down”
- Stack traces lost - Console.error doesn’t preserve stack traces properly
- Database details leaked - Raw Postgres errors might expose connection strings
I needed a better approach.
The Solution: Layered Error Handling
After researching best practices and learning from painful production incidents, I implemented a three-tier error handling architecture:
- Domain Layer - Typed business errors that bubble up naturally
- Infrastructure Layer - Database/network errors caught at middleware level
- Global Layer - Safety net for unhandled rejections and exceptions
Let me walk through each layer.
Layer 1: Domain Errors (Business Logic)
Domain errors represent expected business failures. These aren’t bugs - they’re part of your application’s logic. “User not found”, “Insufficient funds”, “Email already exists” - these are domain errors.
I created a base class for all domain errors:
export abstract class DomainError extends Error { abstract readonly code: string; abstract readonly statusCode: number;
constructor(message: string) { super(message); this.name = this.constructor.name; Error.captureStackTrace(this, this.constructor); }}The statusCode property tells Express what HTTP status to return. The code property gives clients a machine-readable error identifier.
Now I can create specific domain errors:
export class UserNotFoundError extends DomainError { readonly code = 'USER_NOT_FOUND'; readonly statusCode = 404;
constructor(userId: string) { super(`User with ID ${userId} not found`); }}
export class InsufficientFundsError extends DomainError { readonly code = 'INSUFFICIENT_FUNDS'; readonly statusCode = 400;
constructor(required: number, available: number) { super(`Insufficient funds: required ${required}, available ${available}`); }}
export class ValidationError extends DomainError { readonly code = 'VALIDATION_ERROR'; readonly statusCode = 422;
constructor(message: string) { super(message); }}Notice how each error carries context. UserNotFoundError includes the user ID. InsufficientFundsError includes both the required and available amounts. This makes debugging so much easier.
Layer 2: Infrastructure Errors (Database, Network)
Infrastructure errors are different. They represent failures in the underlying systems - database connections, external APIs, message queues. These aren’t business logic failures; they’re operational issues.
export abstract class InfrastructureError extends Error { abstract readonly code: string; abstract readonly statusCode: number; readonly isRetryable: boolean;
constructor(message: string, isRetryable: boolean = false) { super(message); this.name = this.constructor.name; this.isRetryable = isRetryable; }}
export class DatabaseConnectionError extends InfrastructureError { readonly code = 'DB_CONNECTION_ERROR'; readonly statusCode = 503;
constructor(message: string) { super(message, true); // Database errors are often retryable }}
export class ExternalServiceError extends InfrastructureError { readonly code = 'EXTERNAL_SERVICE_ERROR'; readonly statusCode = 502;
constructor(serviceName: string, originalError?: Error) { super(`External service ${serviceName} failed: ${originalError?.message}`); }}The isRetryable flag is crucial. When a database connection fails, the client might want to retry. When a payment gateway returns an error, retrying might cause duplicate charges. This distinction matters.
The Error Handler Middleware
Now I need a central place to handle all errors. In Express, this is an error handling middleware - a function with four parameters instead of three.
import { Request, Response, NextFunction } from 'express';import { DomainError } from '../errors/domain-errors';import { InfrastructureError } from '../errors/infrastructure-errors';import { logger } from '../utils/logger';
interface ErrorResponse { success: false; error: { code: string; message: string; details?: unknown; }; requestId: string; timestamp: string;}
export function errorHandler( error: Error, req: Request, res: Response, _next: NextFunction): void { const requestId = req.headers['x-request-id'] as string || 'unknown';
// Handle domain errors (expected business errors) if (error instanceof DomainError) { logger.warn({ message: 'Domain error occurred', code: error.code, requestId, stack: error.stack });
const response: ErrorResponse = { success: false, error: { code: error.code, message: error.message }, requestId, timestamp: new Date().toISOString() };
res.status(error.statusCode).json(response); return; }
// Handle infrastructure errors if (error instanceof InfrastructureError) { logger.error({ message: 'Infrastructure error occurred', code: error.code, requestId, isRetryable: error.isRetryable, stack: error.stack });
const response: ErrorResponse = { success: false, error: { code: error.code, message: 'Service temporarily unavailable', details: error.isRetryable ? { retryable: true } : undefined }, requestId, timestamp: new Date().toISOString() };
res.status(error.statusCode).json(response); return; }
// Handle unexpected errors logger.error({ message: 'Unexpected error occurred', requestId, error: error.message, stack: error.stack });
const response: ErrorResponse = { success: false, error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' }, requestId, timestamp: new Date().toISOString() };
res.status(500).json(response);}Notice how infrastructure errors don’t expose their internal messages to clients. “Service temporarily unavailable” is safer than “Connection refused to postgres@internal-db:5432”.
Clean Service Layer Code
With typed errors and a central handler, my service layer became much cleaner:
import { UserNotFoundError, InsufficientFundsError } from '../errors/domain-errors';import { UserRepository } from '../repositories/user-repository';
export class UserService { constructor(private readonly userRepository: UserRepository) {}
async getUserById(userId: string): Promise<User> { // No try/catch needed - let domain errors bubble up const user = await this.userRepository.findById(userId);
if (!user) { throw new UserNotFoundError(userId); }
return user; }
async transferFunds(fromId: string, toId: string, amount: number): Promise<void> { // Infrastructure errors are caught at middleware level // Domain errors bubble up naturally const fromUser = await this.getUserById(fromId); const toUser = await this.getUserById(toId);
if (fromUser.balance < amount) { throw new InsufficientFundsError(amount, fromUser.balance); }
// Business logic continues... await this.userRepository.transfer(fromId, toId, amount); }}No try/catch blocks. No error response objects. Just throw the appropriate domain error and let the middleware handle it.
Layer 3: Global Error Handlers (Safety Net)
Even with the best architecture, errors can slip through. Maybe someone forgets to await a promise, or a third-party library throws an unexpected exception. That’s where global handlers come in.
import { logger } from '../utils/logger';import { alertingService } from '../services/alerting';
export function setupGlobalErrorHandlers(): void { // Handle unhandled promise rejections process.on('unhandledRejection', (reason: Error, promise: Promise<unknown>) => { logger.error({ message: 'Unhandled rejection detected', reason: reason.message, stack: reason.stack });
// Alert the team alertingService.notify({ severity: 'critical', type: 'unhandledRejection', message: reason.message, stack: reason.stack });
// Optionally terminate the process // process.exit(1); });
// Handle uncaught exceptions process.on('uncaughtException', (error: Error) => { logger.error({ message: 'Uncaught exception detected', error: error.message, stack: error.stack });
// Alert immediately - this is critical alertingService.notify({ severity: 'critical', type: 'uncaughtException', message: error.message, stack: error.stack });
// Must terminate - process is in undefined state process.exit(1); });}Important: Uncaught exceptions must terminate the process. After an uncaught exception, your application is in an undefined state. Continuing might cause data corruption or worse. Log, alert, and exit.
Unhandled rejections are trickier. In some cases, you might want to continue (maybe it was a non-critical background task). But in production, I usually terminate to force a clean restart.
Putting It All Together
Here’s how the Express app looks with everything connected:
import express from 'express';import { errorHandler } from './middleware/error-handler';import { setupGlobalErrorHandlers } from './handlers/global-error-handlers';import { requestLogger } from './middleware/request-logger';import { UserService } from './services/user-service';
const app = express();const userService = new UserService(/* dependencies */);
// Setup global handlers firstsetupGlobalErrorHandlers();
// Middlewareapp.use(express.json());app.use(requestLogger);
// Routesapp.get('/users/:id', async (req, res, next) => { try { const user = await userService.getUserById(req.params.id); res.json({ success: true, data: user }); } catch (error) { next(error); // Pass to error handler middleware }});
app.post('/transfer', async (req, res, next) => { try { const { fromId, toId, amount } = req.body; await userService.transferFunds(fromId, toId, amount); res.json({ success: true }); } catch (error) { next(error); }});
// Error handler must be lastapp.use(errorHandler);
export default app;The route handlers only need minimal try/catch to pass errors to next(). You could even wrap this with a higher-order function to eliminate those as well.
What Changed in Production
After deploying this architecture, I noticed immediate improvements:
- Consistent API responses - Every error follows the same structure with
code,message, andrequestId - Better debugging - Stack traces are preserved, and errors carry context
- Faster incident response - Global handlers alert the team immediately for critical issues
- Cleaner code - Service layer has almost no error handling code
- No more leaked secrets - Infrastructure errors hide connection details
The layered approach separates concerns at the right boundaries. Domain errors flow naturally through the call stack. Infrastructure errors get caught and sanitized at the middleware level. And global handlers catch anything that slips through.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Node.js Error Handling Best Practices
- 👨💻 Express Error Handling
- 👨💻 TypeScript Discriminated Unions
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments