error-handling · git:20260610.47ec162 · 2026-06-10 · sha256 11033bc593bf9c93
error-handling git:20260610.47ec162A
Immutable. This exact content never changes and is served at /api/v1/blob/11033bc593bf9c93.
---
name: error-handling
description: Domain exception hierarchy, error codes, GraphQL error formatting, and error logging standards for a NestJS + GraphQL backend
---
# Error Handling Skill
## Scope
Apply this skill for any task involving error handling, exception design, error responses, or error logging. This project uses **NestJS** with **GraphQL** (code-first) and a global exception filter.
---
## Error Philosophy
Errors are not exceptional — they are expected outcomes that the system must handle gracefully. The error handling strategy has three goals:
1. **Clients receive actionable, consistent error responses** — never raw stack traces or framework errors
2. **Developers can diagnose issues quickly** — structured logs with full context
3. **The system fails safely** — invalid state is never persisted, partial operations are rolled back
---
## Domain Exception Hierarchy
### Base Exception
All domain exceptions extend a base `DomainException` that carries a machine-readable error code:
```typescript
export abstract class DomainException extends Error {
abstract readonly code: string;
constructor(message: string) {
super(message);
this.name = this.constructor.name;
}
}
```
### Naming Convention
Exception class names describe **what went wrong**, not what the system tried to do:
| Pattern | Example |
|---|---|
| `<Entity><Problem>Exception` | `UserNotFoundException` |
| `<Entity><Constraint>Exception` | `EmailAlreadyTakenException` |
| `<Action><Reason>Exception` | `LoginFailedInvalidCredentialsException` |
| `<Resource><State>Exception` | `TokenExpiredException` |
### Error Codes
Error codes are machine-readable strings in `SCREAMING_SNAKE_CASE`, prefixed by domain (the domains below are examples — adapt the prefixes to your project's domains):
| Domain | Code Pattern | Example |
|---|---|---|
| Auth | `AUTH_*` | `AUTH_INVALID_CREDENTIALS`, `AUTH_TOKEN_EXPIRED` |
| Users | `USER_*` | `USER_NOT_FOUND`, `USER_EMAIL_ALREADY_TAKEN` |
| RBAC | `RBAC_*` | `RBAC_ROLE_NOT_FOUND`, `RBAC_PERMISSION_DENIED` |
| Validation | `VALIDATION_*` | `VALIDATION_INVALID_INPUT` |
| System | `SYSTEM_*` | `SYSTEM_INTERNAL_ERROR`, `SYSTEM_SERVICE_UNAVAILABLE` |
---
## GraphQL Error Responses
### Error Format
GraphQL errors are returned in the standard `errors` array with structured extensions:
```json
{
"errors": [
{
"message": "The email address is already associated with an account.",
"extensions": {
"code": "USER_EMAIL_ALREADY_TAKEN",
"classification": "BUSINESS_ERROR"
}
}
]
}
```
### Error Classifications
| Classification | Meaning | HTTP Analogy |
|---|---|---|
| `BUSINESS_ERROR` | Valid request, but business rule prevents it | 409, 422 |
| `NOT_FOUND` | Requested resource does not exist | 404 |
| `UNAUTHORIZED` | Missing or invalid authentication | 401 |
| `FORBIDDEN` | Authenticated but lacking permission | 403 |
| `VALIDATION_ERROR` | Input failed validation | 400 |
| `INTERNAL_ERROR` | Unexpected system failure | 500 |
### Union Type Error Pattern
For mutations, prefer modeling expected errors as union return types rather than throwing exceptions:
```typescript
@ObjectType()
export class EmailAlreadyTakenError {
@Field(() => String)
message: string;
@Field(() => String)
email: string;
}
export const SignupResult = createUnionType({
name: 'SignupResult',
types: () => [UserResult, EmailAlreadyTakenError, ValidationError] as const,
});
```
This makes error cases explicit in the schema and forces clients to handle them.
### When to Throw vs Return Error Types
| Scenario | Approach |
|---|---|
| Expected business error (email taken, role not found) | Return as union type |
| Authentication/authorization failure | Throw — handled by guards and global filter |
| Validation failure | Throw — handled by ValidationPipe |
| Unexpected system error | Throw — caught by global exception filter |
---
## Global Exception Filter
The `global-exception.filter.ts` catches all unhandled exceptions and transforms them into consistent GraphQL error responses. It must:
1. Log the full error with stack trace, correlation ID, and user ID
2. Return a sanitized error to the client — never expose internal details
3. Map known exception types to appropriate error codes and classifications
4. Default to `SYSTEM_INTERNAL_ERROR` for unknown exceptions
### What the Client Sees vs What Gets Logged
| Aspect | Client Response | Server Log |
|---|---|---|
| Message | User-friendly, actionable | Technical, detailed |
| Stack trace | Never | Always (at error level) |
| Internal IDs | Never | Always (correlation ID, user ID) |
| Error code | Always | Always |
---
## Error Logging
Every caught error must be logged with full context before being transformed into a client response:
```typescript
this.logger.error('Failed to create user', {
correlationId,
userId: requestingUserId,
errorCode: exception.code,
errorMessage: exception.message,
stack: exception.stack,
input: { email: maskEmail(input.email) }, // Mask PII
});
```
### Rules
- Log at `error` level for 5xx-equivalent errors
- Log at `warn` level for 4xx-equivalent errors (client mistakes)
- Always include `correlationId` and `userId` when available
- Never log raw passwords, tokens, or secrets — even in error context
- Mask email addresses and PII in log metadata
---
## Validation Errors
Input validation uses Zod schemas at the service boundary. Validation errors must:
1. Include the field name that failed validation
2. Include a human-readable message explaining the constraint
3. Be collected and returned together (not fail on the first error)
```typescript
// Zod schema with descriptive messages
const createUserSchema = z.object({
email: z.string().email('Must be a valid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
firstName: z.string().min(1, 'First name is required'),
});
```
---
## Hard Rules
- Never expose stack traces, internal paths, or database errors to clients
- Never swallow exceptions silently — always log and either handle or re-throw
- Never use generic `Error` — use domain-specific exception classes with error codes
- Always include `correlationId` and `userId` in error logs
- Always mask PII (emails, names) in log metadata
- Always return consistent error format through the global exception filter
- Never let validation errors leak past the service boundary — validate at the edge