Error Handling Guide¶
Last Updated: 2025-01-22
This guide explains the error handling system used throughout the AccessALI Customer Portal, including custom error classes, patterns, and best practices.
Overview¶
AccessALI uses a structured error handling system with custom error classes that provide type safety, consistent HTTP status codes, and proper error chaining across all application layers.
Key Features¶
- Type Safety - TypeScript-based error types
- Consistent Responses - Standardized error formats across API routes
- Proper HTTP Codes - Automatic mapping to appropriate status codes
- Error Chaining - Preserves original error context with
causeparameter - Centralized Handling - Single source of truth for API error responses
Error Architecture¶
graph TB
API[API Layer<br/>handleApiError] --> UseCase[Use Case Layer<br/>Business Logic]
UseCase --> Repo[Repository Layer<br/>Data Access]
Repo --> Errors[Error Classes<br/>errors.ts]
Errors --> NotFound[NotFoundError<br/>404]
Errors --> Unauth[UnauthorizedError<br/>403]
Errors --> Valid[ValidationError<br/>400]
Errors --> DB[DatabaseError<br/>500]
Error Class Hierarchy¶
Base Class¶
RepositoryError (base)
├── NotFoundError (404)
├── UserNotFoundError (404)
├── MessageNotFoundError (404)
├── NotificationNotFoundError (404)
├── ActivationTokenNotFoundError (404)
├── UnauthorizedError (403)
├── ValidationError (400)
├── ActivationTokenExpiredError (400)
├── ActivationTokenAlreadyUsedError (400)
├── DuplicateUserError (409)
└── DatabaseError (500)
Error Properties¶
All custom errors extend RepositoryError and include:
class CustomError extends RepositoryError {
name: string // Error class name
message: string // Human-readable error message
code?: string // Machine-readable error code
statusCode?: number // HTTP status code
cause?: unknown // Original error that caused this error
stack?: string // Stack trace
}
Error Types Reference¶
Not Found Errors (404)¶
NotFoundError¶
Generic not found error for any entity.
throw new NotFoundError('Property', propertyId)
// Message: Property with id "prop-123" not found
// Code: NOT_FOUND
// Status: 404
When to use:
- Entity doesn't exist in database
- Resource cannot be located
- ID is invalid or refers to deleted entity
UserNotFoundError¶
User-specific not found error.
throw new UserNotFoundError(userId, 'id')
throw new UserNotFoundError(email, 'email')
// Message: User with id "user-123" not found
// Code: USER_NOT_FOUND
// Status: 404
When to use:
- User lookup by ID or email fails
- User account doesn't exist
Unauthorized Errors (403)¶
UnauthorizedError¶
Access denied - user doesn't own resource.
throw new UnauthorizedError('Property', propertyId, userId)
// Message: User "user-123" does not have access to Property "prop-123"
// Code: UNAUTHORIZED
// Status: 403
When to use:
- User tries to access resource they don't own
- Permission check fails
- Ownership validation fails
Authentication vs Authorization
Use 401 for authentication failures (not logged in). Use 403 for authorization failures (logged in but insufficient permissions).
Validation Errors (400)¶
ValidationError¶
Input validation failure.
throw new ValidationError('Progress must be between 0 and 100', 'progress')
throw new ValidationError('Invalid email format', 'email', { providedEmail })
// Message: Progress must be between 0 and 100
// Code: VALIDATION_ERROR
// Status: 400
// Field: progress (optional)
When to use:
- Input doesn't meet validation rules
- Required fields missing
- Format is incorrect
- Value out of range
Conflict Errors (409)¶
DuplicateUserError¶
User with email already exists.
throw new DuplicateUserError(email, prismaError)
// Message: User with email "user@example.com" already exists
// Code: DUPLICATE_USER
// Status: 409
When to use:
- User registration with existing email
- Unique constraint violation on user email
Server Errors (500)¶
DatabaseError¶
Generic database operation failure.
throw new DatabaseError('create user', prismaError)
throw new DatabaseError('update property milestone', originalError)
// Message: Database operation failed: create user
// Code: DATABASE_ERROR
// Status: 500
When to use:
- Prisma query fails
- Database connection issues
- Constraint violations (non-unique)
- Transaction failures
- Any database-level error
Always Include Cause
Always include the original error as cause for debugging.
Layer-Specific Patterns¶
Repository Layer¶
Responsibilities:
- Throw custom error classes
- Handle Prisma errors
- Validate database constraints
Pattern:
import { NotFoundError, DatabaseError, isPrismaRecordNotFoundError } from './errors'
export async function getPropertyById(id: string) {
try {
const property = await prisma.property.findUnique({ where: { id } })
if (!property) {
throw new NotFoundError('Property', id)
}
return property
} catch (error) {
// Re-throw custom errors unchanged
if (error instanceof NotFoundError) {
throw error
}
// Handle Prisma errors
if (isPrismaRecordNotFoundError(error)) {
throw new NotFoundError('Property', id, error)
}
// Wrap database errors
logger.error('Database error:', error)
throw new DatabaseError('get property by id', error)
}
}
Key Points:
- Always catch errors in try-catch
- Re-throw custom errors unchanged
- Use Prisma helper functions
- Include original error as
cause - Log before throwing DatabaseError
Use Case Layer¶
Responsibilities:
- Orchestrate repository calls
- Add business logic validation
- Catch and propagate errors
- Add context when needed
Pattern:
import { NotFoundError, UnauthorizedError, ValidationError } from '@/lib/repositories/errors'
export async function updatePropertyUseCase(userId: string, propertyId: string, data: any) {
try {
// Validate input
if (!data.name || data.name.length === 0) {
throw new ValidationError('Name is required', 'name')
}
// Get property
const property = await getPropertyById(propertyId)
// Check ownership
if (property.userId !== userId) {
throw new UnauthorizedError('Property', propertyId, userId)
}
// Update
return await updateProperty(propertyId, data)
} catch (error) {
// Re-throw known errors
if (error instanceof NotFoundError ||
error instanceof UnauthorizedError ||
error instanceof ValidationError) {
throw error
}
// Wrap unexpected errors
logger.error('Error in updatePropertyUseCase:', error)
throw new Error(`Failed to update property: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
Key Points:
- Validate business rules, throw ValidationError
- Re-throw repository errors unchanged
- Add ownership/permission checks
- Log before wrapping unknown errors
API Route Layer¶
Responsibilities:
- Authenticate requests
- Call use cases
- Handle errors with centralized handler
- Return consistent responses
Pattern:
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { handleApiError } from '@/lib/errors/api-error-handler'
import { updatePropertyUseCase } from '@/lib/use-cases/property'
export async function PUT(request: Request) {
try {
// 1. Authenticate
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
)
}
// 2. Parse request
const data = await request.json()
// 3. Call use case
const result = await updatePropertyUseCase(session.user.id, data.propertyId, data)
// 4. Return success
return NextResponse.json({ success: true, data: result })
} catch (error) {
// Use centralized error handler
return handleApiError(error)
}
}
Key Points:
- Always use
handleApiError()for error handling - Handle authentication before try-catch
- Don't duplicate error handling logic
- Return consistent response format
HTTP Status Code Mapping¶
The handleApiError() function automatically maps errors to HTTP status codes:
| Error Type | Status Code | Description |
|---|---|---|
NotFoundError |
404 | Entity not found |
UserNotFoundError |
404 | User not found |
MessageNotFoundError |
404 | Message not found |
NotificationNotFoundError |
404 | Notification not found |
ActivationTokenNotFoundError |
404 | Token not found |
UnauthorizedError |
403 | Access denied |
ValidationError |
400 | Invalid input |
ActivationTokenExpiredError |
400 | Token expired |
ActivationTokenAlreadyUsedError |
400 | Token already used |
DuplicateUserError |
409 | Duplicate user |
DatabaseError |
500 | Database error |
Generic Error |
500 | Unknown error |
Error Response Format¶
All API errors use this consistent format:
{
success: false,
error: string, // Human-readable message
code?: string, // Machine-readable code
details?: { // Additional context
field?: string, // Field name for validation errors
[key: string]: any // Other contextual data
}
}
Examples¶
Not Found (404):
Unauthorized (403):
{
"success": false,
"error": "User \"user-123\" does not have access to Property \"prop-456\"",
"code": "UNAUTHORIZED"
}
Validation Error (400):
{
"success": false,
"error": "Progress must be between 0 and 100",
"code": "VALIDATION_ERROR",
"details": {
"field": "progress"
}
}
Best Practices¶
Do's¶
Use Specific Error Types
Include Original Error as Cause
Re-throw Custom Errors Unchanged
Use Centralized API Error Handler
Don'ts¶
Don't Use Generic Error for Known Cases
Don't Lose Error Context
Don't Catch and Ignore
Common Patterns¶
CRUD with Error Handling¶
// GET by ID
export async function getById(id: string) {
try {
const entity = await prisma.entity.findUnique({ where: { id } })
if (!entity) throw new NotFoundError('Entity', id)
return entity
} catch (error) {
if (error instanceof NotFoundError) throw error
throw new DatabaseError('get entity', error)
}
}
// CREATE with duplicate handling
export async function create(data: CreateData) {
try {
return await prisma.entity.create({ data })
} catch (error) {
if (isPrismaUniqueConstraintError(error)) {
throw new DuplicateUserError(email, error)
}
throw new DatabaseError('create entity', error)
}
}
// UPDATE with not found handling
export async function update(id: string, data: UpdateData) {
try {
return await prisma.entity.update({ where: { id }, data })
} catch (error) {
if (isPrismaRecordNotFoundError(error)) {
throw new NotFoundError('Entity', id, error)
}
throw new DatabaseError('update entity', error)
}
}
Use Case with Ownership Validation¶
export async function updateEntityUseCase(
userId: string,
entityId: string,
data: UpdateData
) {
// Validate input
if (!data.name?.trim()) {
throw new ValidationError('Name is required', 'name')
}
// Get entity
const entity = await getEntityById(entityId)
// Check ownership
if (entity.userId !== userId) {
throw new UnauthorizedError('Entity', entityId, userId)
}
// Update
return await updateEntity(entityId, data)
}
Testing Error Handling¶
Unit Tests for Repositories¶
import { NotFoundError, DatabaseError } from '@/lib/repositories/errors'
describe('getEntityById', () => {
it('should throw NotFoundError when entity does not exist', async () => {
await expect(getEntityById('non-existent')).rejects.toThrow(NotFoundError)
})
it('should throw DatabaseError on database failure', async () => {
jest.spyOn(prisma.entity, 'findUnique').mockRejectedValue(new Error('DB error'))
await expect(getEntityById('id')).rejects.toThrow(DatabaseError)
})
})
Integration Tests for API Routes¶
describe('GET /api/entities/:id', () => {
it('should return 404 when entity not found', async () => {
const response = await fetch('/api/entities/non-existent')
expect(response.status).toBe(404)
const json = await response.json()
expect(json.success).toBe(false)
expect(json.code).toBe('NOT_FOUND')
})
it('should return 403 when user unauthorized', async () => {
const response = await fetch('/api/entities/other-user-entity')
expect(response.status).toBe(403)
const json = await response.json()
expect(json.code).toBe('UNAUTHORIZED')
})
})
Quick Reference¶
Import Statements¶
// Repositories
import {
NotFoundError,
UnauthorizedError,
ValidationError,
DatabaseError,
UserNotFoundError,
} from '@/lib/repositories/errors'
// API Routes
import { handleApiError } from '@/lib/errors/api-error-handler'
Common Operations¶
// Throw not found
throw new NotFoundError('EntityName', entityId)
// Throw validation error
throw new ValidationError('Message', 'fieldName')
// Throw unauthorized
throw new UnauthorizedError('EntityName', entityId, userId)
// Throw database error
throw new DatabaseError('operation name', originalError)
// Handle in API route
return handleApiError(error)
Related Documentation¶
- Source: ERROR_HANDLING.md - Detailed error handling guide
- Coding Conventions - Coding standards
- API Routes - API route patterns
- Repositories - Repository layer documentation
Next Steps¶
- Review the Coding Conventions guide
- Learn about API Routes patterns
- Understand Repository Layer documentation
- Explore Testing Strategies