Error Handling Architecture
A startup's API returned 500 errors for a full hour before anyone noticed. The errors were logged—to a file on a single server. No alerts. No aggregation. The payment provider had been rejecting requests due to an expired certificate, and customers were seeing "Something went wrong" with no path to resolution.
The code threw errors. The architecture didn't handle them.
Error handling isn't just about try-catch. It's about where errors are caught, how they propagate, what gets logged, and how the system recovers. Get the architecture wrong and errors become invisible or overwhelming.
Error Handling at System Boundaries
Errors should be caught and transformed at boundaries: the edges where your system meets the outside world.
The Boundary Principle
Inside your system, let errors propagate. Don't catch unless you can handle meaningfully. At the boundary—HTTP handlers, message queue consumers, CLI entry points—catch, log, and respond appropriately.
// BAD: Catching everywhere
async function getUserController(req: Request) {
try {
const user = await userService.getUser(req.params.id);
return res.json(user);
} catch (e) {
return res.status(500).json({ error: 'Failed' });
}
}
async function userServiceGetUser(id: string) {
try {
const user = await db.users.findById(id);
return user;
} catch (e) {
throw e; // Just rethrowing—why catch at all?
}
}
// GOOD: Catch at the boundary only
async function getUserController(req: Request) {
try {
const user = await userService.getUser(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
return res.json(user);
} catch (error) {
logger.error('Get user failed', { userId: req.params.id, error });
if (error instanceof NotFoundError) {
return res.status(404).json({ error: error.message });
}
if (error instanceof ValidationError) {
return res.status(400).json({ error: error.message });
}
return res.status(500).json({ error: 'Unable to retrieve user' });
}
}
// Service: let errors propagate
async function getUser(id: string): Promise<User | null> {
const user = await db.users.findById(id);
return user;
}
The controller is the boundary. It knows about HTTP status codes and response formats. The service doesn't. Let the service throw; the controller translates.
Boundary Types
- HTTP/API: Translate to status codes and JSON error responses
- Message queue: Log, optionally dead-letter, don't crash the consumer
- CLI: Print to stderr, exit with non-zero code
- React components: Error boundaries catch render errors, show fallback UI
Error Propagation Strategies
How should errors move through your system?
Strategy 1: Let It Throw
Default. Don't catch unless you're adding value. The error bubbles to the boundary.
async function processOrder(orderId: string) {
const order = await orderRepo.findById(orderId);
const validated = validateOrder(order);
const result = await paymentService.charge(validated);
await inventoryService.reserve(validated.items);
return result;
}
// Any error from repo, validate, payment, or inventory propagates up
Strategy 2: Transform and Rethrow
Catch to add context, then rethrow. Preserve the original as cause.
async function loadUserWithProfile(userId: string) {
try {
const user = await userRepo.findById(userId);
const profile = await profileRepo.findByUserId(userId);
return { ...user, profile };
} catch (error) {
throw new Error(`Failed to load user ${userId}`, { cause: error });
}
}
Strategy 3: Recover
Catch when you can do something useful: retry, fallback, default value.
async function getProductPrice(productId: string): Promise<number> {
try {
const product = await productService.fetch(productId);
return product.price;
} catch (error) {
const cached = await cache.get(`price:${productId}`);
if (cached !== null) {
return cached;
}
throw error;
}
}
Strategy 4: Swallow (Rarely)
Only when the error is truly ignorable and you've made a conscious decision. Log it.
async function syncAnalytics(userId: string) {
try {
await analyticsService.track(userId);
} catch (error) {
// Analytics failure must not block the main flow
logger.warn('Analytics sync failed', { userId, error });
}
}
Global Error Handlers
At the top level, have a catch-all. Unhandled errors shouldn't crash the process silently or dump raw stack traces to users.
Node.js / Express
// Last middleware—catches anything that wasn't handled
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error('Unhandled error', {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
if (err instanceof HttpError) {
return res.status(err.statusCode).json({ error: err.message });
}
res.status(500).json({ error: 'An unexpected error occurred' });
});
React Error Boundaries
class ErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: React.ReactNode },
{ hasError: boolean; error?: Error }
> {
state = { hasError: false, error: undefined };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
logger.error('Component error', { error, componentStack: info.componentStack });
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// Usage: wrap route or feature
<ErrorBoundary fallback={<ErrorFallback />}>
<CheckoutFlow />
</ErrorBoundary>
Retry and Circuit Breaker Patterns
Transient failures—network blips, temporary overload—often succeed on retry. But retrying forever against a failing service wastes resources and delays feedback.
Retry with Backoff
async function fetchWithRetry<T>(
fn: () => Promise<T>,
options: { maxAttempts?: number; baseDelayMs?: number } = {}
): Promise<T> {
const { maxAttempts = 3, baseDelayMs = 100 } = options;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (attempt === maxAttempts) throw lastError;
await sleep(baseDelayMs * Math.pow(2, attempt - 1));
}
}
throw lastError!;
}
Exponential backoff: 100ms, 200ms, 400ms. Gives the failing service time to recover.
Circuit Breaker
When a service is down, stop hammering it. Fail fast, then periodically probe to see if it's back.
class CircuitBreaker {
private failures = 0;
private lastFailure = 0;
private state: 'closed' | 'open' | 'half-open' = 'closed';
constructor(
private threshold = 5,
private resetTimeoutMs = 30000
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - this.lastFailure > this.resetTimeoutMs) {
this.state = 'half-open';
} else {
throw new Error('Circuit breaker open');
}
}
try {
const result = await fn();
if (this.state === 'half-open') this.state = 'closed';
this.failures = 0;
return result;
} catch (error) {
this.failures++;
this.lastFailure = Date.now();
if (this.failures >= this.threshold) this.state = 'open';
throw error;
}
}
}
Closed: normal operation. Open: fail immediately. Half-open: try once to see if the service recovered.
Error Monitoring and Observability
Logging to a file isn't enough. You need to see errors across services, get alerted, and trace them to causes.
What to Capture
- Error message and stack trace
- Request context: ID, path, user, timestamp
- Structured metadata: IDs, error codes, custom fields
logger.error('Order processing failed', {
error: err.message,
stack: err.stack,
requestId: req.id,
userId: req.user?.id,
orderId: req.params.orderId,
errorCode: err.code,
});
Error Aggregation
Use a service: Sentry, Datadog, Rollbar, or similar. They aggregate errors, deduplicate, and let you see patterns. "This error spiked 10x in the last hour" is actionable; "we have 10,000 log lines" is not.
Alerting
Alert on what matters: error rate spikes, new error types, failures in critical paths. Don't alert on every single error—you'll get alert fatigue and ignore them.
Key insight: Error handling architecture is about where errors are caught, how they propagate, and how the system recovers. Catch at boundaries—HTTP handlers, queue consumers, React error boundaries. Let errors propagate internally. Use global handlers as a safety net. For transient failures, retry with backoff; for failing dependencies, use a circuit breaker. Capture structured error data and aggregate it in a monitoring service. The goal: errors are visible, traceable, and don't take down the system.