| 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 within bounded contexts
- Designing context-specific validation logic
- Creating context-specific exception types (no base classes)
- Implementing retry or recovery mechanisms per context
- User asks about error handling strategies
- Reviewing error handling without over-abstraction
Modular Monolith Error Handling
Context-Specific Errors (No Base Classes)
export abstract class DomainError extends Error {
}
export class AuthValidationError extends Error {
constructor(message: string, public readonly field?: string) {
super(message);
this.name = "AuthValidationError";
}
}
export class TaxCalculationError extends Error {
constructor(message: string, public readonly ncmCode?: string) {
super(message);
this.name = "TaxCalculationError";
}
}
Error Handling Rules
- Each context owns its errors - No shared error classes
- Duplicate error structures - Better than coupling through inheritance
- Context-specific metadata - Each error has relevant context data
- Simple over clever - Avoid complex error hierarchies
Core Principles
1. Use Exceptions, Not Return Codes
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,
});
}
}
}
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" };
}
}
}
2. Never Return Null for Errors
export class UserService {
async findById(id: string): Promise<User | undefined> {
return this.repository.findById(id);
}
async getUserById(id: string): Promise<User> {
const user = await this.repository.findById(id);
if (!user) {
throw new NotFoundError(`User ${id} not found`);
}
return user;
}
}
export class UserService {
async findById(id: string): Promise<User | null> {
return null;
}
}
3. Provide Context with Exceptions
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(),
});
throw new Error("Invalid");
Exception Hierarchy
Custom Domain Exceptions
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;
}
}
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 };
}
}
const result = await userService.findByEmail("user@example.com");
if (!result.success) {
console.error("User not found:", result.error.message);
return;
}
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";
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>;
import { zValidator } from "@hono/zod-validator";
app.post("/users", zValidator("json", CreateUserSchema), async (c) => {
const data = c.req.valid("json");
const user = await createUserUseCase.execute(data);
return c.json(user, 201);
});
Domain Validation
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;
}
}
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;
}
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";
}
}
}
const breaker = new CircuitBreaker(5, 60000);
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;
}
}
}
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(),
});
}
}
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();
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
);
}
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