Skip to content

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:

services/user-service.ts
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:

  1. Inconsistent error responses - Each function returns errors differently
  2. Error swallowing - Some errors get logged and swallowed, never reaching the caller
  3. No error typing - Can’t distinguish between “user not found” and “database down”
  4. Stack traces lost - Console.error doesn’t preserve stack traces properly
  5. 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:

  1. Domain Layer - Typed business errors that bubble up naturally
  2. Infrastructure Layer - Database/network errors caught at middleware level
  3. 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:

errors/domain-errors.ts
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:

errors/domain-errors.ts
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.

errors/infrastructure-errors.ts
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.

middleware/error-handler.ts
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:

services/user-service.ts
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.

handlers/global-error-handlers.ts
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:

app.ts
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 first
setupGlobalErrorHandlers();
// Middleware
app.use(express.json());
app.use(requestLogger);
// Routes
app.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 last
app.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:

  1. Consistent API responses - Every error follows the same structure with code, message, and requestId
  2. Better debugging - Stack traces are preserved, and errors carry context
  3. Faster incident response - Global handlers alert the team immediately for critical issues
  4. Cleaner code - Service layer has almost no error handling code
  5. 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments