Professional Error Handling
Published on October 14, 2025
Professional Error Handling
Error handling is one of the most important parts of software development, and also one of the most ignored. I’ve seen applications that work perfectly on the “happy path” but collapse unexpectedly when something goes wrong. I’ve seen code where business logic is mixed with error handling, making both hard to understand.
Professional error handling isn’t just about catching exceptions. It’s about clearly communicating what went wrong, why it went wrong, and what the calling code can do to handle it. It’s about separating business logic from error handling and using patterns that make the code more robust and maintainable.
In this article I’ll share why returning null is problematic, how to use Result Patterns, and how to manage errors professionally without cluttering your business logic.
The problem with returning null
Returning null when something fails is a common but problematic practice. null doesn’t communicate information about what went wrong or why.
❌ Bad: Returning null
function getUser(id) {
const user = database.findById(id);
if (!user) {
return null; // Why is it null? Doesn't exist? DB error?
}
return user;
}
function processUser(userId) {
const user = getUser(userId);
// What if user is null?
// Is it an error? Should I continue?
console.log(user.name); // 💥 Error if user is null
}
Problems:
nulldoesn’t communicate what went wrong- It’s easy to forget to check for
null - No distinction between “not found” and “database error”
- The calling code has to guess what
nullmeans
✅ Good: Throwing specific exceptions
class UserNotFoundError extends Error {
constructor(userId) {
super(`User with id ${userId} not found`);
this.name = 'UserNotFoundError';
this.userId = userId;
}
}
class DatabaseError extends Error {
constructor(originalError) {
super('Database operation failed');
this.name = 'DatabaseError';
this.originalError = originalError;
}
}
function getUser(id) {
try {
const user = database.findById(id);
if (!user) {
throw new UserNotFoundError(id);
}
return user;
} catch (error) {
if (error instanceof UserNotFoundError) {
throw error; // Re-throw specific errors
}
throw new DatabaseError(error);
}
}
function processUser(userId) {
try {
const user = getUser(userId);
console.log(user.name); // Safe: user is never null
} catch (error) {
if (error instanceof UserNotFoundError) {
// Handle user not found
console.error('User not found:', error.userId);
} else if (error instanceof DatabaseError) {
// Handle database error
console.error('Database error:', error.originalError);
} else {
// Unexpected error
throw error;
}
}
}
Now the code clearly communicates what went wrong and why.
Result Pattern: An alternative to exceptions
The Result Pattern is a functional alternative to exceptions. Instead of throwing exceptions, you return a Result object that contains either the success value or the error.
Basic Result implementation
class Result {
constructor(isSuccess, value, error) {
this.isSuccess = isSuccess;
this.isFailure = !isSuccess;
this.value = value;
this.error = error;
}
static success(value) {
return new Result(true, value, null);
}
static failure(error) {
return new Result(false, null, error);
}
map(fn) {
if (this.isFailure) {
return this;
}
try {
return Result.success(fn(this.value));
} catch (error) {
return Result.failure(error);
}
}
flatMap(fn) {
if (this.isFailure) {
return this;
}
return fn(this.value);
}
onSuccess(fn) {
if (this.isSuccess) {
fn(this.value);
}
return this;
}
onFailure(fn) {
if (this.isFailure) {
fn(this.error);
}
return this;
}
getOrElse(defaultValue) {
return this.isSuccess ? this.value : defaultValue;
}
getOrThrow() {
if (this.isFailure) {
throw this.error;
}
return this.value;
}
}
Using the Result Pattern
function getUser(id) {
try {
const user = database.findById(id);
if (!user) {
return Result.failure(new UserNotFoundError(id));
}
return Result.success(user);
} catch (error) {
return Result.failure(new DatabaseError(error));
}
}
function processUser(userId) {
return getUser(userId)
.onSuccess(user => {
console.log('User found:', user.name);
})
.onFailure(error => {
if (error instanceof UserNotFoundError) {
console.error('User not found:', error.userId);
} else {
console.error('Error:', error);
}
});
}
// Or using map/flatMap for transformations
function getUserEmail(userId) {
return getUser(userId)
.map(user => user.email)
.getOrElse('unknown@example.com');
}
Advantages of the Result Pattern
- Explicit: The return type communicates that it can fail
- Composable: You can chain operations easily
- No exceptions: You don’t need try/catch at every call
- Forced handling: The calling code must handle the error explicitly
Disadvantages of the Result Pattern
- More verbose: Every function must return
Result - Not standard: Not all languages support it natively
- Learning curve: Requires understanding functional programming
Separating business logic from error handling
One of the keys to professional error handling is separating business logic from error handling.
❌ Bad: Logic mixed with error handling
function processOrder(order) {
try {
// Validate order
if (!order.items || order.items.length === 0) {
throw new Error('Order has no items');
}
// Calculate total
let total = 0;
for (let item of order.items) {
if (!item.price || item.price < 0) {
throw new Error('Invalid item price');
}
total += item.price * item.quantity;
}
// Save to database
try {
database.save(order);
} catch (error) {
console.error('Database error:', error);
throw new Error('Failed to save order');
}
// Send email
try {
emailService.send(order.customerEmail, 'Order confirmed');
} catch (error) {
console.error('Email error:', error);
// What to do? Reverse the order?
}
return total;
} catch (error) {
console.error('Error processing order:', error);
throw error;
}
}
Problems:
- Business logic is mixed with error handling
- Hard to understand what the function does
- Hard to test
- Error handling is scattered
✅ Good: Logic separated from error handling
// Pure business logic
class OrderProcessor {
validateOrder(order) {
if (!order.items || order.items.length === 0) {
throw new ValidationError('Order must have at least one item');
}
for (let item of order.items) {
if (!item.price || item.price < 0) {
throw new ValidationError(`Invalid price for item ${item.id}`);
}
}
}
calculateTotal(order) {
return order.items.reduce(
(total, item) => total + (item.price * item.quantity),
0
);
}
async saveOrder(order) {
return await orderRepository.save(order);
}
async sendConfirmationEmail(order) {
return await emailService.send(
order.customerEmail,
'Order confirmed',
`Your order total is $${order.total}`
);
}
}
// Error handling in an upper layer
class OrderService {
constructor(orderProcessor, orderRepository, emailService) {
this.processor = orderProcessor;
this.repository = orderRepository;
this.emailService = emailService;
}
async processOrder(orderData) {
try {
// Business logic (no error handling)
this.processor.validateOrder(orderData);
const total = this.processor.calculateTotal(orderData);
const order = { ...orderData, total };
await this.processor.saveOrder(order);
// Email not critical: don't fail if it doesn't send
try {
await this.processor.sendConfirmationEmail(order);
} catch (emailError) {
logger.warn('Failed to send confirmation email', { order, emailError });
// Continue: order is already saved
}
return Result.success(order);
} catch (error) {
// Centralized error handling
return this.handleError(error, orderData);
}
}
handleError(error, orderData) {
if (error instanceof ValidationError) {
logger.warn('Validation error', { error, orderData });
return Result.failure(new InvalidOrderError(error.message));
} else if (error instanceof DatabaseError) {
logger.error('Database error', { error, orderData });
return Result.failure(new OrderProcessingError('Failed to save order'));
} else {
logger.error('Unexpected error', { error, orderData });
return Result.failure(new UnexpectedError(error));
}
}
}
Now:
- Business logic is clear and testable
- Error handling is centralized
- It’s easy to understand what each part does
- It’s easy to add new error types
Error types and when to use them
Validation errors
Errors that indicate input data is invalid. The client can fix them.
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
// Usage
if (!email || !email.includes('@')) {
throw new ValidationError('Invalid email format', 'email');
}
Domain errors
Errors that violate business rules. The client can fix them.
class InsufficientFundsError extends Error {
constructor(required, available) {
super(`Insufficient funds. Required: ${required}, Available: ${available}`);
this.name = 'InsufficientFundsError';
this.required = required;
this.available = available;
}
}
// Usage
if (account.balance < amount) {
throw new InsufficientFundsError(amount, account.balance);
}
Infrastructure errors
Technical errors the client can’t fix. They require team action.
class DatabaseError extends Error {
constructor(originalError) {
super('Database operation failed');
this.name = 'DatabaseError';
this.originalError = originalError;
}
}
// Usage
try {
await database.save(data);
} catch (error) {
throw new DatabaseError(error);
}
Unexpected errors
Errors that shouldn’t happen. They indicate bugs or system issues.
class UnexpectedError extends Error {
constructor(originalError) {
super('An unexpected error occurred');
this.name = 'UnexpectedError';
this.originalError = originalError;
}
}
Error handling strategies
1. Fail Fast
Detect errors as soon as possible.
function processPayment(amount, account) {
// Validate early
if (amount <= 0) {
throw new ValidationError('Amount must be positive');
}
if (!account || !account.isActive) {
throw new ValidationError('Account must be active');
}
// Continue with logic
// ...
}
2. Retry with exponential backoff
For transient errors (network, database temporarily unavailable).
async function retryWithBackoff(fn, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (!isTransientError(error) || attempt === maxRetries - 1) {
throw error;
}
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
await sleep(delay);
}
}
}
async function saveWithRetry(data) {
return await retryWithBackoff(() => database.save(data));
}
3. Circuit Breaker
Prevents a failing service from overloading the system.
class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.failureCount = 0;
this.threshold = threshold;
this.timeout = timeout;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
this.nextAttempt = Date.now();
}
async execute(fn) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new CircuitBreakerOpenError('Circuit breaker is OPEN');
}
this.state = 'HALF_OPEN';
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failureCount++;
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.timeout;
}
}
}
Logging and monitoring
Professional error handling includes proper logging and monitoring.
❌ Bad: Generic logging
try {
await processOrder(order);
} catch (error) {
console.error('Error:', error); // Not enough information
}
✅ Good: Structured logging
try {
await processOrder(order);
} catch (error) {
logger.error('Failed to process order', {
error: error.message,
stack: error.stack,
orderId: order.id,
userId: order.userId,
amount: order.total,
timestamp: new Date().toISOString(),
context: {
service: 'OrderService',
method: 'processOrder',
environment: process.env.NODE_ENV
}
});
// Also send to monitoring service
monitoringService.recordError('order_processing_failed', {
errorType: error.constructor.name,
orderId: order.id
});
throw error;
}
My practical experience
I’ve seen projects where error handling was an afterthought. Generic errors, null returned everywhere, business logic mixed with error handling. Every bug was hard to find and fix.
I’ve seen projects with professional error handling. Specific errors, Result Patterns where they made sense, logic separated from error handling. Bugs were easy to find and fix.
In practice you can use a combination of specific exceptions and Result Patterns. For critical operations, use exceptions. For operations that can fail frequently, use Result Patterns.
For applications where precision is critical (finance, health), use specific exceptions for each error type, with detailed logging and monitoring.
My personal perspective
Professional error handling isn’t just about catching exceptions. It’s about:
- Communicating clearly: Errors should say what went wrong and why
- Separating responsibilities: Business logic shouldn’t be mixed with error handling
- Using appropriate patterns: Exceptions for exceptional errors, Result Patterns for operations that can fail
- Logging and monitoring: Errors should be logged and monitored
I’ve seen projects where error handling was an afterthought. Generic errors, null returned everywhere, business logic mixed with error handling. Every bug was hard to find and fix.
I’ve seen projects with professional error handling. Specific errors, Result Patterns where they made sense, logic separated from error handling. Bugs were easy to find and fix.
Don’t return null. Don’t use generic exceptions. Don’t mix business logic with error handling.
Write code that clearly communicates what can go wrong and handles those cases professionally. Your future self (and your team) will thank you.