| name | error-handling-patterns |
| description | Error handling patterns including exceptions, Result pattern, validation strategies, retry logic, and circuit breakers. **ALWAYS use when implementing error handling in backend code, APIs, use cases, or validation logic.** Use proactively for robust error handling, recovery mechanisms, and failure scenarios. Examples - "handle errors", "Result pattern", "throw exception", "validate input", "error recovery", "retry logic", "circuit breaker", "exception hierarchy". |
You are an expert in error handling patterns and strategies. You guide developers to implement robust, maintainable error handling that provides clear feedback and proper recovery mechanisms.
For complete backend implementation examples using these error handling patterns (Clean Architecture layers, DI Container, Use Cases, Repositories), see backend-engineer skill
When to Engage
You should proactively assist when:
- Implementing error handling in functions
- Designing validation logic
- Creating custom exception types
- Implementing retry or recovery mechanisms
- User asks about error handling strategies
- Reviewing error handling in code
Core Principles
1. Use Exceptions, Not Return Codes
// ✅ Good - Use exceptions with context
export class CreateUserUseCase {
async execute(dto: CreateUserDto): Promise<User> {
if (!this.isValidEmail(dto.email)) {
throw new ValidationError("Invalid email format", {
email: dto.email,
field: "email",
});
}
try {
return await this.repository.save(user);
} catch (error) {
throw new DatabaseError("Failed to create user", {
originalError: error,
userId: user.id,
});
}
}
}
// ❌ Bad - Return codes
export class CreateUserUseCase {
async execute(dto: CreateUserDto): Promise<{
success: boolean;
user?: User;
error?: string;
}> {
if (!this.isValidEmail(dto.email)) {
return { success: false, error: "Invalid email" };
}
// Forces caller to check success flag everywhere
}
}
2. Never Return Null for Errors
// ✅ Good - Explicit optional with undefined
export class UserService {
async findById(id: string): Promise<User | undefined> {
return this.repository.findById(id);
}
// Or throw if must exist
async getUserById(id: string): Promise<User> {
const user = await this.repository.findById(id);
if (!user) {
throw new NotFoundError(`User ${id} not found`);
}
return user;
}
}
// ❌ Bad - Returning null loses error context
export class UserService {
async findById(id: string): Promise<User | null> {
// Why null? Not found? Database error? Network error?
return null;
}
}
3. Provide Context with Exceptions
// ✅ Good - Rich error context
export class ValidationError extends Error {
constructor(
message: string,
public readonly context: Record<string, unknown>
) {
super(message);
this.name = "ValidationError";
}
}
throw new ValidationError("Invalid email format", {
email: dto.email,
field: "email",
rule: "email-format",
attemptedAt: new Date().toISOString(),
});
// ❌ Bad - No context
throw new Error("Invalid");
Exception Hierarchy
Custom Domain Exceptions
// Base domain exception
export abstract class DomainError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly context?: Record<string, unknown>
) {
super(message);
this.name = this.constructor.name;
}
}
// Specific domain exceptions
export class UserAlreadyExistsError extends DomainError {
constructor(email: string) {
super(`User with email ${email} already exists`, "USER_ALREADY_EXISTS", {
email,
});
}
}
export class InvalidPasswordError extends DomainError {
constructor(reason: string) {
super("Password does not meet requirements", "INVALID_PASSWORD", {
reason,
});
}
}
export class InsufficientPermissionsError extends DomainError {
constructor(userId: string, resource: string, action: string) {
super(
`User ${userId} cannot ${action} ${resource}`,
"INSUFFICIENT_PERMISSIONS",
{ userId, resource, action }
);
}
}
Infrastructure Exceptions
export class DatabaseError extends Error {
constructor(
message: string,
public readonly originalError: unknown,
public readonly query?: string
) {
super(message);
this.name = "DatabaseError";
}
}
export class ExternalServiceError extends Error {
constructor(
public readonly service: string,
message: string,
public readonly statusCode?: number,
public readonly originalError?: unknown
) {
super(`${service}: ${message}`);
this.name = "ExternalServiceError";
}
}
Result Pattern
For operations with expected failures:
export type Result<T, E = Error> =
| { success: true; value: T }
| { success: false; error: E };
export class UserService {
async findByEmail(email: string): Promise<Result<User, NotFoundError>> {
const user = await this.repository.findByEmail(email);
if (!user) {
return {
success: false,
error: new NotFoundError("User not found"),
};
}
return { success: true, value: user };
}
}
// Usage
const result = await userService.findByEmail("user@example.com");
if (!result.success) {
console.error("User not found:", result.error.message);
return;
}
// TypeScript knows result.value is User here
const user = result.value;
When to Use Result Pattern
Use Result for:
- Expected business failures (user not found, insufficient balance)
- Operations where failure is part of normal flow
- When caller needs to handle different failure types
Use Exceptions for:
- Unexpected errors (database connection lost, out of memory)
- Programming errors (invalid state, null pointer)
- Infrastructure failures
Validation Patterns
Input Validation at Boundaries
import { z } from "zod";
// ✅ Good - Validate at system boundaries
const CreateUserSchema = z.object({
email: z.string().email("Invalid email format"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain uppercase letter")
.regex(/[0-9]/, "Password must contain number"),
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(100, "Name must be at most 100 characters"),
age: z
.number()
.int("Age must be an integer")
.min(18, "Must be at least 18 years old")
.optional(),
});
export type CreateUserDto = z.infer<typeof CreateUserSchema>;
// In Hono controller
import { zValidator } from "@hono/zod-validator";
app.post("/users", zValidator("json", CreateUserSchema), async (c) => {
const data = c.req.valid("json"); // Type-safe and validated
const user = await createUserUseCase.execute(data);
return c.json(user, 201);
});
Domain Validation
// ✅ Good - Validate in domain entities
export class Email {
private constructor(private readonly value: string) {}
static create(value: string): Result<Email, ValidationError> {
if (!value) {
return {
success: false,
error: new ValidationError("Email is required", { field: "email" }),
};
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
return {
success: false,
error: new ValidationError("Invalid email format", {
email: value,
field: "email",
}),
};
}
return { success: true, value: new Email(value) };
}
toString(): string {
return this.value;
}
}
// Usage
const emailResult = Email.create(dto.email);
if (!emailResult.success) {
throw emailResult.error;
}
const email = emailResult.value;
Error Recovery Patterns
Retry Logic
export async function withRetry<T>(
operation: () => Promise<T>,
options: {
maxRetries: number;
delayMs: number;
shouldRetry?: (error: unknown) => boolean;
}
): Promise<T> {
const { maxRetries, delayMs, shouldRetry = () => true } = options;
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt === maxRetries || !shouldRetry(error)) {
break;
}
await new Promise((resolve) =>
setTimeout(resolve, delayMs * (attempt + 1))
);
}
}
throw lastError;
}
// Usage
const user = await withRetry(() => userRepository.findById(id), {
maxRetries: 3,
delayMs: 1000,
shouldRetry: (error) => error instanceof DatabaseError,
});
Circuit Breaker
export class CircuitBreaker {
private failureCount = 0;
private lastFailureTime?: number;
private state: "CLOSED" | "OPEN" | "HALF_OPEN" = "CLOSED";
constructor(
private readonly failureThreshold: number,
private readonly resetTimeoutMs: number
) {}
async execute<T>(operation: () => Promise<T>): Promise<T> {
if (this.state === "OPEN") {
if (Date.now() - (this.lastFailureTime || 0) > this.resetTimeoutMs) {
this.state = "HALF_OPEN";
} else {
throw new Error("Circuit breaker is OPEN");
}
}
try {
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.failureCount = 0;
this.state = "CLOSED";
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = "OPEN";
}
}
}
// Usage
const breaker = new CircuitBreaker(5, 60000); // 5 failures, 60s timeout
const data = await breaker.execute(() => externalService.fetchData());
Fallback Values
export class ConfigService {
async get<T>(key: string, fallback: T): Promise<T> {
try {
const value = await this.redis.get(key);
return value ? JSON.parse(value) : fallback;
} catch (error) {
console.error(`Failed to get config ${key}:`, error);
return fallback;
}
}
}
// Usage
const maxRetries = await configService.get("maxRetries", 3);
Error Logging
Structured Logging
export interface LogContext {
userId?: string;
requestId?: string;
operation?: string;
[key: string]: unknown;
}
export class Logger {
error(message: string, error: Error, context?: LogContext): void {
console.error({
level: "error",
message,
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
context,
timestamp: new Date().toISOString(),
});
}
warn(message: string, context?: LogContext): void {
console.warn({
level: "warn",
message,
context,
timestamp: new Date().toISOString(),
});
}
}
// Usage
try {
await userRepository.save(user);
} catch (error) {
logger.error("Failed to save user", error as Error, {
userId: user.id,
operation: "createUser",
requestId: context.requestId,
});
throw new DatabaseError("Failed to save user", error);
}
HTTP Error Handling (Hono)
import { Hono } from "hono";
import type { Context } from "hono";
const app = new Hono();
// Global error handler
app.onError((err, c) => {
logger.error("Unhandled error", err, {
path: c.req.path,
method: c.req.method,
});
if (err instanceof ValidationError) {
return c.json(
{
error: "Validation failed",
message: err.message,
context: err.context,
},
400
);
}
if (err instanceof NotFoundError) {
return c.json(
{
error: "Resource not found",
message: err.message,
},
404
);
}
if (err instanceof DomainError) {
return c.json(
{
error: err.code,
message: err.message,
context: err.context,
},
400
);
}
// Unknown error - don't leak details
return c.json(
{
error: "Internal server error",
message: "An unexpected error occurred",
},
500
);
});
Best Practices
Do:
- ✅ Throw exceptions for exceptional situations
- ✅ Use Result pattern for expected failures
- ✅ Provide rich context in errors
- ✅ Validate at system boundaries
- ✅ Log errors with correlation IDs
- ✅ Implement retry for transient failures
- ✅ Use circuit breakers for external services
- ✅ Create domain-specific exception types
Don't:
- ❌ Swallow exceptions silently
- ❌ Return null for errors
- ❌ Use exceptions for control flow
- ❌ Leak implementation details in error messages
- ❌ Throw generic Error instances
- ❌ Catch and re-throw without adding context
- ❌ Ignore errors in async operations
Remember
- Fail fast, fail loudly - Don't hide errors
- Context is king - Provide rich error information
- Recovery is better than failure - Implement fallbacks when possible
- Log for debugging - Future you will thank you