605 lines
14 KiB
Markdown
605 lines
14 KiB
Markdown
---
|
|
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)
|
|
|
|
```typescript
|
|
// ❌ BAD: Base error class creates coupling
|
|
export abstract class DomainError extends Error {
|
|
// Forces all contexts to use same error structure
|
|
}
|
|
|
|
// ✅ GOOD: Each context has its own errors
|
|
// contexts/auth/domain/errors/auth-validation.error.ts
|
|
export class AuthValidationError extends Error {
|
|
constructor(message: string, public readonly field?: string) {
|
|
super(message);
|
|
this.name = "AuthValidationError";
|
|
}
|
|
}
|
|
|
|
// contexts/tax/domain/errors/tax-calculation.error.ts
|
|
export class TaxCalculationError extends Error {
|
|
constructor(message: string, public readonly ncmCode?: string) {
|
|
super(message);
|
|
this.name = "TaxCalculationError";
|
|
}
|
|
}
|
|
```
|
|
|
|
### Error Handling Rules
|
|
|
|
1. **Each context owns its errors** - No shared error classes
|
|
2. **Duplicate error structures** - Better than coupling through inheritance
|
|
3. **Context-specific metadata** - Each error has relevant context data
|
|
4. **Simple over clever** - Avoid complex error hierarchies
|
|
|
|
## Core Principles
|
|
|
|
### 1. Use Exceptions, Not Return Codes
|
|
|
|
```typescript
|
|
// ✅ 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
|
|
|
|
```typescript
|
|
// ✅ 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
|
|
|
|
```typescript
|
|
// ✅ 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// ✅ 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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)
|
|
|
|
```typescript
|
|
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
|