Loading content...
In well-architected software systems, exceptions don't simply bubble up unchanged from the depths of your infrastructure to the surface of your user interface. Instead, they undergo deliberate transformation at each architectural boundary—translated, enriched, and adapted to serve the needs of each layer they traverse.
Consider what happens when a database connection fails deep within your persistence layer. Should that SocketTimeoutException with its raw TCP details surface directly to your REST API consumers? Should the stack trace revealing your ORM's internal retry logic appear in a mobile app's error dialog? The answer is emphatically no—and understanding why, and how to properly translate exceptions across boundaries, is fundamental to building robust, maintainable, and secure systems.
By the end of this page, you will understand why exception translation is essential for layered architectures, how to design translation strategies that preserve meaningful information while hiding implementation details, and the patterns that world-class engineers use to maintain clean boundaries. You'll be equipped to implement exception translation that enhances debuggability, security, and user experience simultaneously.
Exception translation isn't merely a nice-to-have practice—it's a fundamental requirement for maintaining clean architecture and the principle of separation of concerns. To understand why, we must first examine what happens when exceptions are not translated.
The Leaking Abstraction Problem
When lower-level exceptions leak upward without transformation, they violate one of the most important principles in software design: abstraction boundaries. Consider a layered architecture where your application layer depends on your domain layer, which depends on your persistence layer. If a HikariPool.PoolExhaustedException from your connection pool bubbles up to your REST controller, you've just created a brittle coupling between your HTTP handling and your database infrastructure.
Exception leakage is a recognized security vulnerability. The OWASP Top 10 specifically identifies 'Security Misconfiguration' which includes verbose error messages. Untranslated exceptions can reveal your technology stack, internal paths, database schema hints, and other information that attackers can exploit for reconnaissance and targeted attacks.
The Translation Imperative
Exception translation serves multiple vital purposes simultaneously:
Encapsulation: Each layer exposes only the exception types relevant to its abstraction level. The persistence layer throws persistence-related exceptions; the domain layer throws domain-related exceptions.
Information Enrichment: Translation isn't just about hiding—it's about adding context. A raw OptimisticLockException becomes a ConcurrentModificationException with information about which entity was affected and what operation was attempted.
Contract Stability: Your API's error contract remains stable even when you refactor internal implementations. Switching from MySQL to PostgreSQL doesn't change your documented error responses.
Actionability: Translated exceptions can carry information about what the caller should do, rather than just describing what went wrong internally.
Before we can translate exceptions effectively, we must understand where translations should occur. Architectural boundaries are the interfaces between distinct components or layers, each with its own responsibility, abstraction level, and exception vocabulary.
In a typical enterprise application, several categories of boundaries exist:
| Boundary Type | Inbound Exceptions | Outbound Exceptions | Translation Focus |
|---|---|---|---|
| Infrastructure → Persistence | SocketException, TimeoutException, DriverExceptions | RepositoryException, DataAccessException | Abstract away transport and driver details |
| Persistence → Domain | DataAccessException, OptimisticLockException | EntityNotFoundException, ConcurrencyException | Frame in terms of business entities |
| Domain → Application | DomainException, ValidationException | ApplicationException, OperationFailedException | Add operation and context information |
| Application → Presentation | ApplicationException, AuthorizationException | HTTP error codes, user-friendly messages | Make actionable and user-comprehensible |
| Your Service → External Systems | HTTP errors, timeout exceptions | IntegrationException, ServiceUnavailableException | Normalize external failure modes |
| External Systems → Your Service | Partner API exceptions, malformed responses | ExternalServiceException with correlation ID | Capture provenance and enable debugging |
The Onion Model of Exception Translation
A helpful mental model is to think of your architecture as an onion, with infrastructure at the core and user interfaces at the outer layers. As exceptions move outward from the core:
Conversely, as you log and monitor exceptions, you should capture the full translation chain, so that operators and developers can trace from user-visible errors all the way back to root causes.
Exception translation is primarily a concern for exceptions moving outward (from lower to higher abstraction layers). Exceptions thrown from higher layers rarely need translation when passed to lower layers—they typically represent programming errors or misuse of the lower layer's API, and should be caught and handled appropriately at their source.
Effective exception translation requires well-designed exception hierarchies at each architectural layer. These hierarchies provide the vocabulary for expressing failures in terms appropriate to each layer's abstraction level.
Layer-Specific Base Exceptions
Each significant layer should define its own base exception class. This serves multiple purposes:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
// ============================================// INFRASTRUCTURE LAYER EXCEPTIONS// ============================================ /** * Base exception for all infrastructure-level failures. * These represent technical failures in external resources. */export abstract class InfrastructureException extends Error { constructor( message: string, public readonly cause?: Error, public readonly isRetryable: boolean = false, public readonly retryAfterMs?: number ) { super(message); this.name = 'InfrastructureException'; }} export class DatabaseConnectionException extends InfrastructureException { constructor( public readonly databaseIdentifier: string, cause?: Error ) { super(`Failed to connect to database: ${databaseIdentifier}`, cause, true, 5000); this.name = 'DatabaseConnectionException'; }} export class ExternalServiceException extends InfrastructureException { constructor( public readonly serviceName: string, public readonly statusCode?: number, cause?: Error ) { super(`External service '${serviceName}' failed${statusCode ? ` with status ${statusCode}` : ''}`, cause, true, 1000); this.name = 'ExternalServiceException'; }} // ============================================// PERSISTENCE LAYER EXCEPTIONS// ============================================ /** * Base exception for all persistence-level failures. * These represent storage and retrieval failures, abstracted * from specific database technologies. */export abstract class PersistenceException extends Error { constructor( message: string, public readonly entityType?: string, public readonly entityId?: string, public readonly cause?: Error ) { super(message); this.name = 'PersistenceException'; }} export class EntityNotFoundException extends PersistenceException { constructor(entityType: string, entityId: string) { super(`${entityType} with ID '${entityId}' was not found`, entityType, entityId); this.name = 'EntityNotFoundException'; }} export class ConcurrencyConflictException extends PersistenceException { constructor( entityType: string, entityId: string, public readonly expectedVersion: number, public readonly actualVersion: number ) { super( `Concurrent modification detected for ${entityType} '${entityId}'. Expected version ${expectedVersion}, found ${actualVersion}`, entityType, entityId ); this.name = 'ConcurrencyConflictException'; }} export class UniqueConstraintViolationException extends PersistenceException { constructor( entityType: string, public readonly conflictingField: string, public readonly conflictingValue: string ) { super( `A ${entityType} with ${conflictingField}='${conflictingValue}' already exists`, entityType ); this.name = 'UniqueConstraintViolationException'; }} // ============================================// DOMAIN LAYER EXCEPTIONS// ============================================ /** * Base exception for all domain-level failures. * These represent business rule violations and domain logic failures. */export abstract class DomainException extends Error { constructor( message: string, public readonly errorCode: string, public readonly details?: Record<string, unknown> ) { super(message); this.name = 'DomainException'; }} export class InsufficientFundsException extends DomainException { constructor( public readonly accountId: string, public readonly requestedAmount: number, public readonly availableBalance: number ) { super( `Account ${accountId} has insufficient funds. Requested: ${requestedAmount}, Available: ${availableBalance}`, 'INSUFFICIENT_FUNDS', { accountId, requestedAmount, availableBalance } ); this.name = 'InsufficientFundsException'; }} export class OrderNotCancellableException extends DomainException { constructor( public readonly orderId: string, public readonly currentStatus: string ) { super( `Order ${orderId} cannot be cancelled in status '${currentStatus}'`, 'ORDER_NOT_CANCELLABLE', { orderId, currentStatus } ); this.name = 'OrderNotCancellableException'; }} // ============================================// APPLICATION LAYER EXCEPTIONS// ============================================ /** * Base exception for application-level failures. * These represent use case failures with context for the operation being performed. */export abstract class ApplicationException extends Error { constructor( message: string, public readonly operationName: string, public readonly cause?: Error, public readonly isRetryable: boolean = false ) { super(message); this.name = 'ApplicationException'; }} export class OperationFailedException extends ApplicationException { constructor( operationName: string, reason: string, cause?: Error, isRetryable: boolean = false ) { super(`Operation '${operationName}' failed: ${reason}`, operationName, cause, isRetryable); this.name = 'OperationFailedException'; }}Key Design Principles
Notice several important patterns in this hierarchy design:
Each layer's base exception is abstract — You can't throw a generic InfrastructureException; you must use a specific subclass that carries meaningful information.
Exception classes carry structured data — Rather than concatenating values into message strings alone, we store them as typed fields. This enables programmatic inspection and consistent logging.
Cause chaining is explicit — Each exception class accepts an optional cause parameter to maintain the exception chain for debugging.
Retryability is declared — Infrastructure exceptions can signal whether retrying might succeed, enabling intelligent retry logic in upper layers.
Error codes provide stable identifiers — Domain exceptions carry error codes (INSUFFICIENT_FUNDS) that remain stable across message wording changes, useful for API contracts.
With a proper exception hierarchy in place, we can now implement translation at each boundary. Several patterns exist for performing translation, each with specific use cases and trade-offs.
Pattern 1: Catch-and-Wrap Translation
The most straightforward approach wraps caught exceptions in appropriate higher-level exceptions:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
/** * Persistence layer repository implementation with exception translation. * Translates infrastructure exceptions to persistence exceptions. */class PostgresUserRepository implements UserRepository { constructor(private readonly pool: Pool) {} async findById(id: string): Promise<User | null> { try { const result = await this.pool.query( 'SELECT * FROM users WHERE id = $1', [id] ); return result.rows[0] ? this.mapToUser(result.rows[0]) : null; } catch (error) { // Translate PostgreSQL-specific errors to persistence exceptions if (error instanceof Error) { if (error.message.includes('connection refused')) { throw new DatabaseConnectionException( 'primary-postgres', error ); } if (error.message.includes('timeout')) { throw new DatabaseTimeoutException( 'findById', 5000, // configured timeout error ); } } // Generic fallback for unexpected database errors throw new DataAccessException( 'User', 'findById', error instanceof Error ? error : new Error(String(error)) ); } } async save(user: User): Promise<void> { try { await this.pool.query( 'INSERT INTO users (id, email, name, version) VALUES ($1, $2, $3, $4) ' + 'ON CONFLICT (id) DO UPDATE SET email = $2, name = $3, version = $4 + 1 ' + 'WHERE users.version = $4', [user.id, user.email, user.name, user.version] ); } catch (error) { // Translate PostgreSQL error codes to meaningful persistence exceptions if (this.isUniqueViolation(error)) { const field = this.extractConflictingField(error); throw new UniqueConstraintViolationException( 'User', field, field === 'email' ? user.email : user.id ); } if (this.isOptimisticLockFailure(error)) { throw new ConcurrencyConflictException( 'User', user.id, user.version, user.version + 1 ); } throw new DataAccessException( 'User', 'save', error instanceof Error ? error : new Error(String(error)) ); } } private isUniqueViolation(error: unknown): boolean { // PostgreSQL error code for unique_violation return (error as any)?.code === '23505'; } private isOptimisticLockFailure(error: unknown): boolean { // This would depend on your specific OLF detection mechanism return (error as any)?.message?.includes('version mismatch'); } private extractConflictingField(error: unknown): string { // Parse PostgreSQL error detail to find the conflicting column const detail = (error as any)?.detail || ''; if (detail.includes('email')) return 'email'; return 'id'; } private mapToUser(row: any): User { return new User(row.id, row.email, row.name, row.version); }}Pattern 2: Exception Mapping Registry
For more complex translation needs, a registry-based approach provides centralized, configurable translation rules:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
/** * Exception translator that uses registered mappings to transform exceptions. * Provides a centralized, type-safe approach to exception translation. */type ExceptionMapper<TIn extends Error, TOut extends Error> = ( exception: TIn, context: TranslationContext) => TOut; interface TranslationContext { layerName: string; operationName: string; entityType?: string; entityId?: string; additionalContext?: Record<string, unknown>;} class ExceptionTranslator { private mappings = new Map< new (...args: any[]) => Error, ExceptionMapper<any, any> >(); /** * Register a translation mapping from one exception type to another. */ register<TIn extends Error, TOut extends Error>( sourceType: new (...args: any[]) => TIn, mapper: ExceptionMapper<TIn, TOut> ): this { this.mappings.set(sourceType, mapper); return this; } /** * Translate an exception using registered mappings. * Falls back to a generic wrapper if no specific mapping exists. */ translate( exception: Error, context: TranslationContext ): Error { // Find the most specific mapper by walking up the prototype chain let currentProto = exception.constructor; while (currentProto && currentProto !== Error) { const mapper = this.mappings.get(currentProto as any); if (mapper) { return mapper(exception, context); } currentProto = Object.getPrototypeOf(currentProto); } // No specific mapping found - create generic translation return new UnmappedInfrastructureException( context.layerName, context.operationName, exception ); }} // Example usage: Configure translator for infrastructure → persistence translationconst infrastructureToPersistenceTranslator = new ExceptionTranslator() .register( DatabaseConnectionException, (ex, ctx) => new DataAccessException( ctx.entityType || 'Unknown', ctx.operationName, ex ) ) .register( TimeoutException, (ex, ctx) => new DatabaseTimeoutException( ctx.operationName, ex.timeoutMs, ex ) ) .register( PostgresUniqueViolationError, (ex, ctx) => new UniqueConstraintViolationException( ctx.entityType || 'Unknown', ex.constraintName, ex.conflictingValue ) ); // Base repository class that uses the translatorabstract class TranslatingRepository<T extends Entity> { constructor( protected readonly translator: ExceptionTranslator, protected readonly entityType: string ) {} protected translateError(error: Error, operationName: string): never { throw this.translator.translate(error, { layerName: 'persistence', operationName, entityType: this.entityType }); } protected async withTranslation<R>( operationName: string, operation: () => Promise<R> ): Promise<R> { try { return await operation(); } catch (error) { this.translateError( error instanceof Error ? error : new Error(String(error)), operationName ); } }}Use catch-and-wrap when translation logic is simple and context-specific. Use a mapping registry when you have many exception types, want centralized configuration, or need to share translation rules across multiple repositories or services. The registry pattern also makes unit testing translation logic easier—you can test the mappings in isolation.
One of the most critical aspects of exception translation is preserving sufficient context for debugging while still achieving abstraction. Poorly implemented translation can make system failures impossible to diagnose.
The Exception Chain Pattern
Every translated exception should maintain a reference to its cause—the original exception that triggered the translation. This creates a navigable chain from user-visible error back to root cause:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
/** * Base exception that properly maintains cause chains and generates * complete diagnostic information. */export abstract class ChainedError extends Error { public readonly timestamp: Date; public readonly correlationId?: string; constructor( message: string, public readonly cause?: Error, correlationId?: string ) { super(message); this.timestamp = new Date(); this.correlationId = correlationId; // Ensure stack trace includes the cause chain if (cause && cause.stack) { this.stack = `${this.stack}\n\nCaused by: ${cause.stack}`; } } /** * Get the root cause of this exception chain. */ getRootCause(): Error { let current: Error = this; while (current instanceof ChainedError && current.cause) { current = current.cause; } return current; } /** * Get the full exception chain as an array. */ getExceptionChain(): Error[] { const chain: Error[] = []; let current: Error | undefined = this; while (current) { chain.push(current); current = (current as ChainedError).cause; } return chain; } /** * Generate a comprehensive diagnostic summary for logging. */ toDiagnosticString(): string { const chain = this.getExceptionChain(); const lines: string[] = [ '=== Exception Diagnostic Report ===', `Timestamp: ${this.timestamp.toISOString()}`, `Correlation ID: ${this.correlationId || 'N/A'}`, `Exception Chain Depth: ${chain.length}`, '' ]; chain.forEach((ex, index) => { const prefix = index === 0 ? 'TOP LEVEL' : `Cause Level ${index}`; lines.push(`[${prefix}] ${ex.name}: ${ex.message}`); // Include structured data from custom exceptions if (ex instanceof ChainedError) { const metadata = this.extractMetadata(ex); if (Object.keys(metadata).length > 0) { lines.push(` Metadata: ${JSON.stringify(metadata)}`); } } }); lines.push('', '=== Full Stack Trace ===', this.stack || 'No stack trace available'); return lines.join('\n'); } private extractMetadata(error: ChainedError): Record<string, unknown> { const metadata: Record<string, unknown> = {}; // Extract known properties from various exception types if ('entityType' in error) metadata.entityType = (error as any).entityType; if ('entityId' in error) metadata.entityId = (error as any).entityId; if ('errorCode' in error) metadata.errorCode = (error as any).errorCode; if ('operationName' in error) metadata.operationName = (error as any).operationName; if ('isRetryable' in error) metadata.isRetryable = (error as any).isRetryable; return metadata; }} // Example: Complete translation chaintry { // Infrastructure layer throws throw new SocketTimeoutError('Connection to db-primary:5432 timed out after 5000ms');} catch (socketError) { try { // Persistence layer catches and translates throw new DatabaseConnectionException( 'db-primary', socketError as Error ); } catch (dbError) { try { // Application layer catches and translates throw new OperationFailedException( 'CreateUser', 'Database connectivity issue', dbError as Error, true // isRetryable ); } catch (appError) { // Final exception preserves complete chain: // OperationFailedException -> DatabaseConnectionException -> SocketTimeoutError console.log((appError as ChainedError).toDiagnosticString()); } }}In distributed systems, a correlation ID (sometimes called a trace ID or request ID) should flow through all layers and be attached to every translated exception. This enables correlating log entries, exceptions, and monitoring data across the entire request path—essential for debugging failures in microservices architectures.
The final and perhaps most critical translation point is at your external API boundary—where your application meets consumers. Here, exceptions must be translated into API responses with careful attention to security, usability, and documentation.
Designing API Error Responses
A well-designed API error response provides:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
/** * Structured API error response that provides clients with actionable information. */interface ApiErrorResponse { error: { code: string; // Stable identifier: 'INSUFFICIENT_FUNDS' message: string; // Human-readable: 'Insufficient balance for this transaction' details?: Record<string, unknown>; // Additional context retryable?: boolean; // Can the client retry this operation? retryAfterSeconds?: number; // Suggested retry delay documentationUrl?: string; // Link to error documentation }; requestId: string; // For support correlation timestamp: string; // ISO 8601 timestamp} /** * Centralized exception-to-response translator for REST APIs. */class ApiExceptionHandler { private readonly errorMappings = new Map< new (...args: any[]) => Error, (error: any, requestId: string) => { status: number; response: ApiErrorResponse } >(); constructor() { this.registerMappings(); } private registerMappings(): void { // Domain exceptions -> 4xx responses this.errorMappings.set(InsufficientFundsException, (error, requestId) => ({ status: 400, response: { error: { code: 'INSUFFICIENT_FUNDS', message: 'The account does not have sufficient funds for this transaction.', details: { requestedAmount: error.requestedAmount, // DO NOT include availableBalance - security concern! }, retryable: false }, requestId, timestamp: new Date().toISOString() } })); this.errorMappings.set(EntityNotFoundException, (error, requestId) => ({ status: 404, response: { error: { code: 'RESOURCE_NOT_FOUND', message: `The requested ${error.entityType.toLowerCase()} was not found.`, details: { resourceType: error.entityType }, retryable: false, documentationUrl: '/docs/errors/resource-not-found' }, requestId, timestamp: new Date().toISOString() } })); this.errorMappings.set(ConcurrencyConflictException, (error, requestId) => ({ status: 409, response: { error: { code: 'CONCURRENT_MODIFICATION', message: 'The resource was modified by another request. Please refresh and try again.', details: { resourceType: error.entityType }, retryable: true }, requestId, timestamp: new Date().toISOString() } })); this.errorMappings.set(UniqueConstraintViolationException, (error, requestId) => ({ status: 409, response: { error: { code: 'DUPLICATE_RESOURCE', message: `A ${error.entityType.toLowerCase()} with this ${error.conflictingField} already exists.`, details: { field: error.conflictingField }, retryable: false }, requestId, timestamp: new Date().toISOString() } })); // Infrastructure exceptions -> 5xx responses this.errorMappings.set(DatabaseConnectionException, (error, requestId) => ({ status: 503, response: { error: { code: 'SERVICE_UNAVAILABLE', message: 'The service is temporarily unavailable. Please try again later.', retryable: true, retryAfterSeconds: 30 }, requestId, timestamp: new Date().toISOString() } })); } /** * Handle an exception and return an appropriate HTTP response. * Logs the full exception chain internally while returning a safe response externally. */ handle( exception: Error, requestId: string, logger: Logger ): { status: number; response: ApiErrorResponse } { // Log the complete exception with full context for debugging logger.error('Request failed', { requestId, exception: exception instanceof ChainedError ? exception.toDiagnosticString() : { name: exception.name, message: exception.message, stack: exception.stack } }); // Find appropriate mapping let currentProto = exception.constructor; while (currentProto && currentProto !== Error) { const mapper = this.errorMappings.get(currentProto as any); if (mapper) { return mapper(exception, requestId); } currentProto = Object.getPrototypeOf(currentProto); } // Fallback for unmapped exceptions - never expose internal details! return { status: 500, response: { error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred. Please try again or contact support.', retryable: true, retryAfterSeconds: 60 }, requestId, timestamp: new Date().toISOString() } }; }} // Express.js error handling middleware exampleconst apiExceptionHandler = new ApiExceptionHandler(); app.use((error: Error, req: Request, res: Response, next: NextFunction) => { const requestId = req.headers['x-request-id'] as string || generateRequestId(); const { status, response } = apiExceptionHandler.handle(error, requestId, logger); res.status(status).json(response);});Never include in API error responses: full stack traces, database error messages, internal paths, server names, SQL queries, entity IDs that weren't provided by the client, or any information about your technology stack. Attackers parse error responses looking for reconnaissance information. When in doubt, leave it out.
Exception translation logic is critical path code that must be thoroughly tested. If translation fails or produces incorrect results, your system's error handling degrades severely.
Testing Strategies
Test exception translation at multiple levels:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
import { describe, it, expect, beforeEach, mock } from 'bun:test'; /** * Unit tests for exception translation logic. */describe('ExceptionTranslator', () => { let translator: ExceptionTranslator; beforeEach(() => { translator = new ExceptionTranslator() .register(DatabaseConnectionException, (ex, ctx) => new DataAccessException(ctx.entityType!, ctx.operationName, ex) ); }); describe('specific exception mapping', () => { it('should translate DatabaseConnectionException to DataAccessException', () => { const originalError = new DatabaseConnectionException('db-primary'); const context: TranslationContext = { layerName: 'persistence', operationName: 'findById', entityType: 'User' }; const translated = translator.translate(originalError, context); expect(translated).toBeInstanceOf(DataAccessException); expect((translated as DataAccessException).entityType).toBe('User'); expect((translated as DataAccessException).operation).toBe('findById'); expect((translated as DataAccessException).cause).toBe(originalError); }); it('should preserve exception chain through translation', () => { const rootCause = new Error('Connection refused'); const infraError = new DatabaseConnectionException('db-primary', rootCause); const context: TranslationContext = { layerName: 'persistence', operationName: 'save', entityType: 'Order' }; const translated = translator.translate(infraError, context); expect((translated as ChainedError).getRootCause()).toBe(rootCause); }); }); describe('unmapped exception handling', () => { it('should wrap unmapped exceptions in generic wrapper', () => { const unknownError = new Error('Something unexpected'); const context: TranslationContext = { layerName: 'persistence', operationName: 'unknownOp', entityType: 'Thing' }; const translated = translator.translate(unknownError, context); expect(translated).toBeInstanceOf(UnmappedInfrastructureException); expect(translated.cause).toBe(unknownError); }); });}); /** * Integration tests for end-to-end translation through API boundary. */describe('ApiExceptionHandler', () => { let handler: ApiExceptionHandler; let mockLogger: Logger; beforeEach(() => { handler = new ApiExceptionHandler(); mockLogger = { error: mock(() => {}), warn: mock(() => {}), info: mock(() => {}) }; }); describe('domain exception to HTTP response', () => { it('should map InsufficientFundsException to 400 with correct code', () => { const error = new InsufficientFundsException('acc-123', 100, 50); const requestId = 'req-456'; const { status, response } = handler.handle(error, requestId, mockLogger); expect(status).toBe(400); expect(response.error.code).toBe('INSUFFICIENT_FUNDS'); expect(response.requestId).toBe(requestId); // Verify sensitive data is NOT included expect(response.error.details).not.toHaveProperty('availableBalance'); }); it('should map EntityNotFoundException to 404', () => { const error = new EntityNotFoundException('Order', 'ord-789'); const { status, response } = handler.handle(error, 'req-1', mockLogger); expect(status).toBe(404); expect(response.error.code).toBe('RESOURCE_NOT_FOUND'); }); }); describe('internal exception handling', () => { it('should never expose internal details for unmapped exceptions', () => { const internalError = new Error('SELECT * FROM users failed with ORA-00942'); const { status, response } = handler.handle(internalError, 'req-1', mockLogger); expect(status).toBe(500); expect(response.error.code).toBe('INTERNAL_ERROR'); expect(response.error.message).not.toContain('SELECT'); expect(response.error.message).not.toContain('ORA'); expect(response.error.message).not.toContain('users'); }); it('should log full exception details even when hiding from response', () => { const detailedError = new Error('Database password expired for user admin@db-primary'); handler.handle(detailedError, 'req-1', mockLogger); // Verify logger received the full details expect(mockLogger.error).toHaveBeenCalled(); const logCall = (mockLogger.error as any).mock.calls[0]; expect(logCall[1].exception).toContain('Database password expired'); }); });});Exception translation is a fundamental practice that separates well-architected systems from brittle, insecure ones. Let's consolidate the essential principles:
What's Next:
With exception translation mastered, the next page explores user-facing error messages—how to craft error communications that help users understand what went wrong and what they can do about it, without exposing technical details or creating confusion.
You now understand why exception translation is essential for layered architectures and how to implement it effectively. You've learned patterns for catch-and-wrap translation, registry-based mapping, cause chain preservation, and secure API error responses. Apply these patterns to create systems that are both debuggable and secure.