Error Handling

Typed error hierarchy with error codes for consistent error handling across layers.

Overview

The library provides a comprehensive error hierarchy based on CodedError. Each error type has a unique code for programmatic handling and maps to appropriate HTTP status codes.


CodedError Base Class

All errors extend CodedError:

import { CodedError } from '@cosmneo/onion-lasagna/backend/core/global';

abstract class CodedError extends Error {
  public readonly code: string;

  constructor(options: {
    message: string;
    code: string;
    cause?: unknown;
  });

  static fromError(cause: unknown): CodedError;
}

Key features:

  • code - Machine-readable identifier for error handling
  • cause - ES2022 error chaining support
  • fromError() - Factory method for wrapping unknown errors

Error Hierarchy

CodedError (base)
├── DomainError
│   ├── InvariantViolationError
│   └── PartialLoadError
├── UseCaseError
│   ├── NotFoundError
│   ├── ConflictError
│   └── UnprocessableError
├── InfraError
│   ├── DbError
│   ├── NetworkError
│   ├── TimeoutError
│   └── ExternalServiceError
├── ControllerError
├── AccessDeniedError
├── InvalidRequestError
└── ObjectValidationError

Error Codes

Each error has a predefined code:

import { ErrorCodes } from '@cosmneo/onion-lasagna/backend/core/global';

// Domain errors
ErrorCodes.Domain.DOMAIN_ERROR           // 'DOMAIN_ERROR'
ErrorCodes.Domain.INVARIANT_VIOLATION    // 'INVARIANT_VIOLATION'
ErrorCodes.Domain.PARTIAL_LOAD           // 'PARTIAL_LOAD'

// Use case errors
ErrorCodes.App.USE_CASE_ERROR            // 'USE_CASE_ERROR'
ErrorCodes.App.NOT_FOUND                 // 'NOT_FOUND'
ErrorCodes.App.CONFLICT                  // 'CONFLICT'
ErrorCodes.App.UNPROCESSABLE             // 'UNPROCESSABLE'

// Infrastructure errors
ErrorCodes.Infra.INFRA_ERROR             // 'INFRA_ERROR'
ErrorCodes.Infra.DB_ERROR                // 'DB_ERROR'
ErrorCodes.Infra.NETWORK_ERROR           // 'NETWORK_ERROR'
ErrorCodes.Infra.TIMEOUT_ERROR           // 'TIMEOUT_ERROR'
ErrorCodes.Infra.EXTERNAL_SERVICE_ERROR  // 'EXTERNAL_SERVICE_ERROR'

// Presentation errors
ErrorCodes.Presentation.CONTROLLER_ERROR // 'CONTROLLER_ERROR'
ErrorCodes.Presentation.ACCESS_DENIED    // 'ACCESS_DENIED'
ErrorCodes.Presentation.INVALID_REQUEST  // 'INVALID_REQUEST'

// Global errors
ErrorCodes.Global.OBJECT_VALIDATION_ERROR // 'OBJECT_VALIDATION_ERROR'

Domain Errors

Use for business rule violations in the domain layer:

import { InvariantViolationError } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

class Order {
  addItem(item: OrderItem): void {
    if (this.status !== 'draft') {
      throw new InvariantViolationError({
        message: 'Cannot add items to a submitted order',
        code: 'ORDER_NOT_DRAFT', // Custom code
      });
    }
    // ...
  }
}

PartialLoadError

Thrown when accessing aggregate fields that weren't loaded from the database. This happens when using Load State Tracking:

import { PartialLoadError } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

class OrderAggregate extends BaseAggregateRoot<OrderId, OrderProps> {
  get items(): readonly OrderItem[] {
    // Throws PartialLoadError if 'items' wasn't marked as loaded
    return this.requireLoaded('items');
  }
}

// In use case - handle partial load errors
class GetOrderItemsQuery extends BaseInboundAdapter<...> {
  protected async handle(input: GetOrderItemsInput): Promise<OrderItemsOutput> {
    const order = await this.orderRepo.findById(input.orderId);
    if (!order) {
      throw new NotFoundError({ message: 'Order not found' });
    }

    try {
      return { items: order.items };
    } catch (error) {
      if (error instanceof PartialLoadError) {
        // Re-fetch with items included
        const orderWithItems = await this.orderRepo.findById(
          input.orderId,
          { includeItems: true },
        );
        return { items: orderWithItems!.items };
      }
      throw error;
    }
  }
}

Info:

PartialLoadError generates codes like ITEMS_NOT_LOADED by default. Use custom error codes via requireLoaded('items', 'ORDER_ITEMS_MISSING') for more descriptive errors.


Use Case Errors

Use for application-level failures:

import {
  NotFoundError,
  ConflictError,
  UnprocessableError,
} from '@cosmneo/onion-lasagna/backend/core/onion-layers';

class CreateUserCommand {
  async execute(input: CreateUserInput): Promise<void> {
    const existing = await this.userRepo.findByEmail(input.email);
    if (existing) {
      throw new ConflictError({
        message: `User with email ${input.email} already exists`,
        code: 'USER_EMAIL_EXISTS', // Custom code
      });
    }
    // ...
  }
}

class GetUserQuery {
  async execute(input: GetUserInput): Promise<UserOutput> {
    const user = await this.userRepo.findById(input.userId);
    if (!user) {
      throw new NotFoundError({
        message: `User ${input.userId} not found`,
        code: 'USER_NOT_FOUND', // Custom code
      });
    }
    return this.toOutput(user);
  }
}

Infrastructure Errors

Use for external system failures. BaseOutboundAdapter automatically wraps errors:

import { DbError, NetworkError } from '@cosmneo/onion-lasagna/backend/core/onion-layers';
import { BaseOutboundAdapter } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

class UserRepository extends BaseOutboundAdapter {
  constructor(private readonly db: Database) {
    super();
  }

  // Override to customize error type
  protected createInfraError(error: unknown, methodName: string): InfraError {
    return new DbError({
      message: `Database error in ${methodName}`,
      cause: error,
    });
  }

  async findById(id: string): Promise<User | null> {
    // If this throws, it's automatically wrapped in DbError
    return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}

Presentation Errors

Use for access control and request validation:

import {
  AccessDeniedError,
  InvalidRequestError,
} from '@cosmneo/onion-lasagna/backend/core/onion-layers';

// Access guard
class CanEditResourceGuard {
  async guard(request: Request): Promise<AccessGuardResult> {
    const canEdit = await this.checkPermission(request);
    if (!canEdit) {
      throw new AccessDeniedError({
        message: 'You do not have permission to edit this resource',
      });
    }
    return { isAllowed: true };
  }
}

Validation Errors

ObjectValidationError includes structured field-level errors:

import { ObjectValidationError } from '@cosmneo/onion-lasagna/backend/core/global';

// Thrown by validators when validation fails
throw new ObjectValidationError({
  message: 'Validation failed',
  validationErrors: [
    { field: 'email', message: 'Invalid email format' },
    { field: 'password', message: 'Password must be at least 8 characters' },
  ],
});

Info:

The HTTP response transforms field to item and code to errorCode:

{
  "message": "Validation failed",
  "errorCode": "VALIDATION_ERROR",
  "errorItems": [
    { "item": "email", "message": "Invalid email format" },
    { "item": "password", "message": "Password must be at least 8 characters" }
  ]
}

HTTP Status Code Mapping

Framework integrations map errors to HTTP status codes:

Error TypeHTTP StatusResponse
ObjectValidationError400 Bad RequestIncludes validation errors
InvalidRequestError400 Bad RequestIncludes validation errors
AccessDeniedError403 ForbiddenError message
NotFoundError404 Not FoundError message
ConflictError409 ConflictError message
UnprocessableError422 Unprocessable EntityError message
DomainError500 Internal Server ErrorMasked
InfraError500 Internal Server ErrorMasked
ControllerError500 Internal Server ErrorMasked
Unknown Error500 Internal Server ErrorMasked

Internal errors (Domain, Infra, Controller) are masked in responses to prevent leaking implementation details.

Warning:

Masked errors return HTTP 500 with { "message": "An unexpected error occurred", "errorCode": "INTERNAL_ERROR" }. Your custom error message and code are not exposed to clients.


Translating Domain Errors

Since DomainError is masked, you must translate business rule violations to UseCaseError in the use-case layer if you want clients to see them:

import { DomainError, NotFoundError, ConflictError, UnprocessableError } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

class DeleteStatusUseCase extends BaseInboundAdapter<...> {
  protected async handle(input: DeleteStatusInputDto): Promise<void> {
    const project = await this.projectRepository.findById(input.data.projectId);
    if (!project) {
      throw new NotFoundError({ message: 'Project not found' });
    }

    try {
      project.deleteStatus(input.data.statusId);
    } catch (error) {
      // Translate domain errors to use-case errors
      if (error instanceof StatusNotFoundError) {
        throw new NotFoundError({
          message: error.message,
          code: 'STATUS_NOT_FOUND'
        });
      }
      if (error instanceof StatusInUseError) {
        throw new ConflictError({
          message: error.message,
          code: 'STATUS_IN_USE'
        });
      }
      if (error instanceof LastStatusError) {
        throw new UnprocessableError({
          message: error.message,
          code: 'LAST_STATUS'
        });
      }
      throw error; // Re-throw unknown errors
    }

    await this.projectRepository.save(project);
  }
}

Info:

When to translate:

  • NotFoundError (404) - Resource doesn't exist
  • ConflictError (409) - State conflict preventing action
  • UnprocessableError (422) - Business rule violation

When to keep masked:

  • Invariant violations that indicate bugs
  • Internal consistency errors

Error Handling in Route Handlers

The unified route handler automatically handles validation errors. When a route defines schemas, validation failures are automatically converted to ObjectValidationError:

import { serverRoutes } from '@cosmneo/onion-lasagna/http/server';

const routes = serverRoutes(router)
  .handle('users.create', {
    // If validation fails, ObjectValidationError is thrown automatically
    requestMapper: (req) => ({
      email: req.body.email,
      name: req.body.name,
    }),
    useCase: createUserUseCase,
    responseMapper: (output) => ({
      status: 201 as const,
      body: { userId: output.userId },
    }),
  })
  .build();

The framework error handlers then convert ObjectValidationError to HTTP 400 responses with structured validation details.


Framework Error Handlers

Each framework integration provides an error handler:

// Hono
import { onionErrorHandler } from '@cosmneo/onion-lasagna/http/frameworks/hono';
app.onError((err, c) => onionErrorHandler(err, c));

// Elysia
import { onionErrorHandler } from '@cosmneo/onion-lasagna/http/frameworks/elysia';
app.onError(({ error }) => onionErrorHandler({ error }));

// Fastify
import { onionErrorHandler } from '@cosmneo/onion-lasagna/http/frameworks/fastify';
app.setErrorHandler(onionErrorHandler);

// NestJS
import { OnionExceptionFilter } from '@cosmneo/onion-lasagna/http/frameworks/nestjs';
@UseFilters(OnionExceptionFilter)
export class AppController {}