Skip to content

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 cause parameter
  • 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):

{
  "success": false,
  "error": "Property with id \"prop-123\" not found",
  "code": "NOT_FOUND"
}

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

// Good
throw new UserNotFoundError(userId, 'id')

// Bad
throw new Error('User not found')

Include Original Error as Cause

// Good
throw new DatabaseError('create user', originalError)

// Bad
throw new DatabaseError('Failed to create user')

Re-throw Custom Errors Unchanged

// Good
if (error instanceof NotFoundError) {
  throw error  // Don't wrap it
}

Use Centralized API Error Handler

// Good
return handleApiError(error)

// Bad
return NextResponse.json({ error: '...' }, { status: 500 })

Don'ts

Don't Use Generic Error for Known Cases

// Bad
throw new Error('Property not found')

// Good
throw new NotFoundError('Property', propertyId)

Don't Lose Error Context

// Bad
throw new DatabaseError('Failed')  // No cause

// Good
throw new DatabaseError('operation', originalError)

Don't Catch and Ignore

// Bad
try { ... } catch { /* silence */ }

// Good
try { ... } catch (error) { logger.error(...); throw ... }

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)


Next Steps

  1. Review the Coding Conventions guide
  2. Learn about API Routes patterns
  3. Understand Repository Layer documentation
  4. Explore Testing Strategies