Skip to content

AI Prompt: Forma3D.Connect — Phase 5j: Typed Error Hierarchy

Purpose: This prompt instructs an AI to create a typed error hierarchy for consistent error handling
Estimated Effort: 2 days (~12-16 hours)
Prerequisites: Phase 5i completed (Domain Contracts)
Output: Domain error classes, consistent error responses, error handling middleware
Status: 🟡 PENDING


🎯 Mission

You are continuing development of Forma3D.Connect, building on the Phase 5i foundation. Your task is to implement Phase 5j: Typed Error Hierarchy — specifically addressing TD-008 (Missing Error Type Definitions) from the technical debt register.

Why This Matters:

Error handling uses generic Error class or string messages rather than typed error hierarchies, causing:

  1. Inconsistent Error Responses: Different formats for similar errors
  2. Difficult Error Handling: Consumers can't catch specific errors
  3. Missing Context: Stack traces without domain context
  4. Logging Gaps: Error metadata not captured

Phase 5j delivers:

  • Base error class with HTTP status and error code
  • Domain-specific error classes
  • Consistent error response format
  • Error filter for automatic response formatting
  • Improved error logging

📋 Context: Technical Debt Item

TD-008: Missing Error Type Definitions

Attribute Value
Type Code Debt
Priority High
Location Throughout backend services
Interest Rate Medium
Principal (Effort) 2 days

Current State

// Current generic error usage
throw new Error(`Retry job not found: ${jobId}`);
throw new Error(`Order ${orderId} cannot be cancelled`);

// Good existing example
throw new SimplyPrintApiError(response.statusCode, message);

🛠️ Implementation Phases

Phase 1: Create Base Error Classes (2 hours)

Priority: Critical | Impact: High | Dependencies: None

1. Create Base Domain Error

Create libs/domain/src/errors/base.error.ts:

/**
 * Base class for all domain errors.
 * Provides consistent structure for error handling and API responses.
 */
export abstract class DomainError extends Error {
  /**
   * Unique error code for client handling.
   * Format: DOMAIN_ERROR_NAME (e.g., ORDER_NOT_FOUND)
   */
  abstract readonly code: string;

  /**
   * HTTP status code for API responses.
   */
  abstract readonly httpStatus: number;

  /**
   * Whether this error should be logged at error level.
   * Set to false for expected errors (e.g., not found, validation).
   */
  readonly isOperational: boolean = true;

  /**
   * Additional context for logging and debugging.
   */
  readonly details: Record<string, unknown>;

  constructor(
    message: string,
    details: Record<string, unknown> = {},
  ) {
    super(message);
    this.name = this.constructor.name;
    this.details = details;

    // Maintains proper stack trace for where error was thrown
    Error.captureStackTrace(this, this.constructor);
  }

  /**
   * Convert to API response format.
   */
  toResponse(): ErrorResponse {
    return {
      statusCode: this.httpStatus,
      error: this.name,
      code: this.code,
      message: this.message,
      details: Object.keys(this.details).length > 0 ? this.details : undefined,
      timestamp: new Date().toISOString(),
    };
  }
}

/**
 * Standard error response format.
 */
export interface ErrorResponse {
  statusCode: number;
  error: string;
  code: string;
  message: string;
  details?: Record<string, unknown>;
  timestamp: string;
}

2. Create Common Error Classes

Create libs/domain/src/errors/common.errors.ts:

import { DomainError } from './base.error';

/**
 * Resource not found error (404).
 */
export class NotFoundError extends DomainError {
  readonly code = 'NOT_FOUND';
  readonly httpStatus = 404;

  constructor(
    resource: string,
    identifier: string,
    details: Record<string, unknown> = {},
  ) {
    super(`${resource} not found: ${identifier}`, {
      resource,
      identifier,
      ...details,
    });
  }
}

/**
 * Validation error (400).
 */
export class ValidationError extends DomainError {
  readonly code = 'VALIDATION_ERROR';
  readonly httpStatus = 400;

  constructor(
    message: string,
    fields: Record<string, string[]> = {},
  ) {
    super(message, { fields });
  }
}

/**
 * Conflict error (409).
 */
export class ConflictError extends DomainError {
  readonly code = 'CONFLICT';
  readonly httpStatus = 409;

  constructor(
    message: string,
    details: Record<string, unknown> = {},
  ) {
    super(message, details);
  }
}

/**
 * Unauthorized error (401).
 */
export class UnauthorizedError extends DomainError {
  readonly code = 'UNAUTHORIZED';
  readonly httpStatus = 401;

  constructor(message: string = 'Authentication required') {
    super(message);
  }
}

/**
 * Forbidden error (403).
 */
export class ForbiddenError extends DomainError {
  readonly code = 'FORBIDDEN';
  readonly httpStatus = 403;

  constructor(message: string = 'Access denied') {
    super(message);
  }
}

/**
 * External service error (502).
 */
export class ExternalServiceError extends DomainError {
  readonly code = 'EXTERNAL_SERVICE_ERROR';
  readonly httpStatus = 502;
  readonly isOperational = false;

  constructor(
    service: string,
    message: string,
    details: Record<string, unknown> = {},
  ) {
    super(`${service} error: ${message}`, {
      service,
      ...details,
    });
  }
}

/**
 * Rate limit error (429).
 */
export class RateLimitError extends DomainError {
  readonly code = 'RATE_LIMIT_EXCEEDED';
  readonly httpStatus = 429;

  constructor(
    retryAfterSeconds?: number,
  ) {
    super('Rate limit exceeded', {
      retryAfter: retryAfterSeconds,
    });
  }
}

Phase 2: Create Domain-Specific Errors (3 hours)

Priority: High | Impact: High | Dependencies: Phase 1

1. Order Errors

Create libs/domain/src/errors/order.errors.ts:

import { DomainError } from './base.error';
import { OrderStatus } from '../enums';

/**
 * Order not found error.
 */
export class OrderNotFoundError extends DomainError {
  readonly code = 'ORDER_NOT_FOUND';
  readonly httpStatus = 404;

  constructor(orderId: string) {
    super(`Order not found: ${orderId}`, { orderId });
  }
}

/**
 * Order cannot be modified in current state.
 */
export class OrderStateError extends DomainError {
  readonly code = 'ORDER_STATE_ERROR';
  readonly httpStatus = 409;

  constructor(
    orderId: string,
    currentStatus: OrderStatus,
    operation: string,
  ) {
    super(
      `Cannot ${operation} order in ${currentStatus} status`,
      { orderId, currentStatus, operation },
    );
  }
}

/**
 * Order cannot be cancelled.
 */
export class OrderCancellationError extends DomainError {
  readonly code = 'ORDER_CANCELLATION_ERROR';
  readonly httpStatus = 409;

  constructor(
    orderId: string,
    reason: string,
  ) {
    super(`Order cannot be cancelled: ${reason}`, { orderId, reason });
  }
}

/**
 * Order has no printable items.
 */
export class OrderNoPrintableItemsError extends DomainError {
  readonly code = 'ORDER_NO_PRINTABLE_ITEMS';
  readonly httpStatus = 400;

  constructor(orderId: string) {
    super('Order has no printable items', { orderId });
  }
}

2. Print Job Errors

Create libs/domain/src/errors/print-job.errors.ts:

import { DomainError } from './base.error';
import { PrintJobStatus } from '../enums';

/**
 * Print job not found error.
 */
export class PrintJobNotFoundError extends DomainError {
  readonly code = 'PRINT_JOB_NOT_FOUND';
  readonly httpStatus = 404;

  constructor(jobId: string) {
    super(`Print job not found: ${jobId}`, { jobId });
  }
}

/**
 * Print job cannot be modified in current state.
 */
export class PrintJobStateError extends DomainError {
  readonly code = 'PRINT_JOB_STATE_ERROR';
  readonly httpStatus = 409;

  constructor(
    jobId: string,
    currentStatus: PrintJobStatus,
    operation: string,
  ) {
    super(
      `Cannot ${operation} print job in ${currentStatus} status`,
      { jobId, currentStatus, operation },
    );
  }
}

/**
 * Print job retry limit exceeded.
 */
export class PrintJobRetryLimitError extends DomainError {
  readonly code = 'PRINT_JOB_RETRY_LIMIT';
  readonly httpStatus = 409;

  constructor(
    jobId: string,
    retryCount: number,
    maxRetries: number,
  ) {
    super(
      `Print job has exceeded retry limit (${retryCount}/${maxRetries})`,
      { jobId, retryCount, maxRetries },
    );
  }
}

/**
 * Print job queue error.
 */
export class PrintJobQueueError extends DomainError {
  readonly code = 'PRINT_JOB_QUEUE_ERROR';
  readonly httpStatus = 500;
  readonly isOperational = false;

  constructor(
    jobId: string,
    reason: string,
  ) {
    super(`Failed to queue print job: ${reason}`, { jobId, reason });
  }
}

3. Product Mapping Errors

Create libs/domain/src/errors/product-mapping.errors.ts:

import { DomainError } from './base.error';

/**
 * Product mapping not found.
 */
export class ProductMappingNotFoundError extends DomainError {
  readonly code = 'PRODUCT_MAPPING_NOT_FOUND';
  readonly httpStatus = 404;

  constructor(identifier: string, type: 'id' | 'sku' = 'id') {
    super(`Product mapping not found: ${identifier}`, {
      [type]: identifier,
    });
  }
}

/**
 * Product mapping already exists.
 */
export class ProductMappingDuplicateError extends DomainError {
  readonly code = 'PRODUCT_MAPPING_DUPLICATE';
  readonly httpStatus = 409;

  constructor(sku: string) {
    super(`Product mapping already exists for SKU: ${sku}`, { sku });
  }
}

/**
 * Model file not found.
 */
export class ModelFileNotFoundError extends DomainError {
  readonly code = 'MODEL_FILE_NOT_FOUND';
  readonly httpStatus = 404;

  constructor(fileId: string) {
    super(`Model file not found: ${fileId}`, { fileId });
  }
}

4. Shipment Errors

Create libs/domain/src/errors/shipment.errors.ts:

import { DomainError } from './base.error';

/**
 * Shipment not found.
 */
export class ShipmentNotFoundError extends DomainError {
  readonly code = 'SHIPMENT_NOT_FOUND';
  readonly httpStatus = 404;

  constructor(identifier: string, type: 'id' | 'orderId' = 'id') {
    super(`Shipment not found: ${identifier}`, {
      [type]: identifier,
    });
  }
}

/**
 * Shipping label creation failed.
 */
export class ShippingLabelError extends DomainError {
  readonly code = 'SHIPPING_LABEL_ERROR';
  readonly httpStatus = 500;
  readonly isOperational = false;

  constructor(
    orderId: string,
    reason: string,
  ) {
    super(`Failed to create shipping label: ${reason}`, {
      orderId,
      reason,
    });
  }
}

/**
 * Shipping not configured.
 */
export class ShippingNotConfiguredError extends DomainError {
  readonly code = 'SHIPPING_NOT_CONFIGURED';
  readonly httpStatus = 503;

  constructor() {
    super('Shipping integration is not configured');
  }
}

5. Integration Errors

Create libs/domain/src/errors/integration.errors.ts:

import { DomainError } from './base.error';

/**
 * SimplyPrint API error.
 */
export class SimplyPrintError extends DomainError {
  readonly code = 'SIMPLYPRINT_ERROR';
  readonly httpStatus = 502;
  readonly isOperational = false;

  constructor(
    statusCode: number,
    message: string,
    endpoint?: string,
  ) {
    super(`SimplyPrint API error: ${message}`, {
      statusCode,
      endpoint,
    });
  }
}

/**
 * Sendcloud API error.
 */
export class SendcloudError extends DomainError {
  readonly code = 'SENDCLOUD_ERROR';
  readonly httpStatus = 502;
  readonly isOperational = false;

  constructor(
    statusCode: number,
    message: string,
    endpoint?: string,
  ) {
    super(`Sendcloud API error: ${message}`, {
      statusCode,
      endpoint,
    });
  }
}

/**
 * Shopify webhook verification failed.
 */
export class ShopifyWebhookVerificationError extends DomainError {
  readonly code = 'SHOPIFY_WEBHOOK_VERIFICATION_FAILED';
  readonly httpStatus = 401;

  constructor() {
    super('Shopify webhook verification failed');
  }
}

6. Create Error Index

Create libs/domain/src/errors/index.ts:

// Base
export * from './base.error';

// Common
export * from './common.errors';

// Domain-specific
export * from './order.errors';
export * from './print-job.errors';
export * from './product-mapping.errors';
export * from './shipment.errors';
export * from './integration.errors';

Update libs/domain/src/index.ts:

export * from './enums';
export * from './entities';
export * from './schemas';
export * from './errors';

Phase 3: Create Error Filter (2 hours)

Priority: High | Impact: High | Dependencies: Phase 2

1. Create Domain Error Filter

Create apps/api/src/common/filters/domain-error.filter.ts:

import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Response, Request } from 'express';
import { DomainError, ErrorResponse } from '@forma3d/domain';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name);

  catch(exception: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    const errorResponse = this.buildErrorResponse(exception, request);

    // Log the error
    this.logError(exception, errorResponse, request);

    response.status(errorResponse.statusCode).json(errorResponse);
  }

  private buildErrorResponse(
    exception: unknown,
    request: Request,
  ): ErrorResponse {
    // Handle domain errors
    if (exception instanceof DomainError) {
      return {
        ...exception.toResponse(),
        path: request.url,
      };
    }

    // Handle NestJS HTTP exceptions
    if (exception instanceof HttpException) {
      const status = exception.getStatus();
      const exceptionResponse = exception.getResponse();
      const message = typeof exceptionResponse === 'string'
        ? exceptionResponse
        : (exceptionResponse as any).message || exception.message;

      return {
        statusCode: status,
        error: HttpStatus[status] || 'Error',
        code: `HTTP_${status}`,
        message: Array.isArray(message) ? message.join(', ') : message,
        timestamp: new Date().toISOString(),
        path: request.url,
      };
    }

    // Handle unknown errors
    const message = exception instanceof Error
      ? exception.message
      : 'Internal server error';

    return {
      statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
      error: 'InternalServerError',
      code: 'INTERNAL_ERROR',
      message: process.env.NODE_ENV === 'production'
        ? 'An unexpected error occurred'
        : message,
      timestamp: new Date().toISOString(),
      path: request.url,
    };
  }

  private logError(
    exception: unknown,
    errorResponse: ErrorResponse,
    request: Request,
  ): void {
    const logContext = {
      statusCode: errorResponse.statusCode,
      code: errorResponse.code,
      path: request.url,
      method: request.method,
      details: (exception instanceof DomainError) ? exception.details : undefined,
    };

    // Log at appropriate level based on error type
    if (exception instanceof DomainError) {
      if (exception.isOperational) {
        // Expected errors - log at warn level
        this.logger.warn({
          message: errorResponse.message,
          ...logContext,
        });
      } else {
        // Unexpected errors - log at error level with stack
        this.logger.error({
          message: errorResponse.message,
          ...logContext,
          stack: exception.stack,
        });
      }
    } else if (exception instanceof HttpException) {
      this.logger.warn({
        message: errorResponse.message,
        ...logContext,
      });
    } else {
      // Unknown errors - always log at error level
      this.logger.error({
        message: errorResponse.message,
        ...logContext,
        stack: exception instanceof Error ? exception.stack : undefined,
      });
    }
  }
}

2. Register Error Filter Globally

Update apps/api/src/main.ts:

import { AllExceptionsFilter } from './common/filters/domain-error.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Register global exception filter
  app.useGlobalFilters(new AllExceptionsFilter());

  // ... rest of bootstrap
}

Phase 4: Update Services to Use Typed Errors (4 hours)

Priority: High | Impact: High | Dependencies: Phase 2

1. Update Orders Service

Update apps/api/src/orders/orders.service.ts:

import {
  OrderNotFoundError,
  OrderStateError,
  OrderCancellationError,
  OrderStatus,
} from '@forma3d/domain';

@Injectable()
export class OrdersService {
  async findOne(id: string): Promise<Order> {
    const order = await this.repository.findOne(id);
    if (!order) {
      throw new OrderNotFoundError(id);
    }
    return order;
  }

  async updateStatus(id: string, newStatus: OrderStatus): Promise<Order> {
    const order = await this.findOne(id);

    if (!this.canTransitionTo(order.status, newStatus)) {
      throw new OrderStateError(id, order.status, `transition to ${newStatus}`);
    }

    return this.repository.updateStatus(id, newStatus);
  }

  async cancel(id: string): Promise<Order> {
    const order = await this.findOne(id);

    if (order.status === OrderStatus.COMPLETED) {
      throw new OrderCancellationError(id, 'Order is already completed');
    }

    if (order.status === OrderStatus.CANCELLED) {
      throw new OrderCancellationError(id, 'Order is already cancelled');
    }

    return this.repository.updateStatus(id, OrderStatus.CANCELLED);
  }
}

2. Update Print Jobs Service

Update apps/api/src/print-jobs/print-jobs.service.ts:

import {
  PrintJobNotFoundError,
  PrintJobStateError,
  PrintJobRetryLimitError,
  PrintJobStatus,
} from '@forma3d/domain';

@Injectable()
export class PrintJobsService {
  async retry(jobId: string): Promise<PrintJob> {
    const job = await this.repository.findOne(jobId);

    if (!job) {
      throw new PrintJobNotFoundError(jobId);
    }

    if (job.status !== PrintJobStatus.FAILED) {
      throw new PrintJobStateError(jobId, job.status, 'retry');
    }

    if (job.retryCount >= job.maxRetries) {
      throw new PrintJobRetryLimitError(jobId, job.retryCount, job.maxRetries);
    }

    return this.repository.update(jobId, {
      status: PrintJobStatus.QUEUED,
      retryCount: job.retryCount + 1,
    });
  }

  async cancel(jobId: string, reason?: string): Promise<PrintJob> {
    const job = await this.repository.findOne(jobId);

    if (!job) {
      throw new PrintJobNotFoundError(jobId);
    }

    if (job.status === PrintJobStatus.COMPLETED) {
      throw new PrintJobStateError(jobId, job.status, 'cancel');
    }

    if (job.status === PrintJobStatus.CANCELLED) {
      throw new PrintJobStateError(jobId, job.status, 'cancel');
    }

    return this.repository.update(jobId, {
      status: PrintJobStatus.CANCELLED,
      errorMessage: reason || 'Cancelled by user',
    });
  }
}

Phase 5: Documentation Updates (30 minutes)

Priority: Medium | Impact: Medium | Dependencies: Phase 4

1. Update Technical Debt Register

Update docs/04-development/techdebt/technical-debt-register.md:

### ~~TD-008: Missing Error Type Definitions~~ ✅ RESOLVED

**Type:** Code Debt  
**Status:****Resolved in Phase 5j**  
**Resolution Date:** 2026-XX-XX

#### Resolution

Created comprehensive typed error hierarchy:

| Category | Error Classes |
|----------|---------------|
| Base | `DomainError` |
| Common | `NotFoundError`, `ValidationError`, `ConflictError`, `UnauthorizedError`, `ForbiddenError`, `ExternalServiceError`, `RateLimitError` |
| Order | `OrderNotFoundError`, `OrderStateError`, `OrderCancellationError` |
| Print Job | `PrintJobNotFoundError`, `PrintJobStateError`, `PrintJobRetryLimitError` |
| Product Mapping | `ProductMappingNotFoundError`, `ProductMappingDuplicateError` |
| Shipment | `ShipmentNotFoundError`, `ShippingLabelError`, `ShippingNotConfiguredError` |
| Integration | `SimplyPrintError`, `SendcloudError`, `ShopifyWebhookVerificationError` |

**Key Features:**
- Consistent error response format
- HTTP status codes on error classes
- Error codes for client handling
- Automatic logging based on error type
- Global exception filter

📁 Files to Create/Modify

New Files

libs/domain/src/errors/
  base.error.ts
  common.errors.ts
  order.errors.ts
  print-job.errors.ts
  product-mapping.errors.ts
  shipment.errors.ts
  integration.errors.ts
  index.ts

apps/api/src/common/filters/domain-error.filter.ts

Modified Files

libs/domain/src/index.ts
apps/api/src/main.ts
apps/api/src/orders/orders.service.ts
apps/api/src/print-jobs/print-jobs.service.ts
apps/api/src/product-mappings/product-mappings.service.ts
apps/api/src/shipments/shipments.service.ts
docs/04-development/techdebt/technical-debt-register.md

✅ Validation Checklist

Phase 1-2: Error Classes

  • Base DomainError class created
  • Common error classes created
  • Order error classes created
  • Print job error classes created
  • Product mapping error classes created
  • Shipment error classes created
  • Integration error classes created

Phase 3: Error Filter

  • AllExceptionsFilter created
  • Filter registered globally
  • Proper logging by error type

Phase 4: Service Updates

  • OrdersService uses typed errors
  • PrintJobsService uses typed errors
  • ProductMappingsService uses typed errors
  • ShipmentsService uses typed errors

Final Verification

# All tests pass
pnpm nx test api

# Build passes
pnpm nx build api
pnpm nx build domain

# Error responses are consistent
curl http://localhost:3000/api/v1/orders/non-existent
# Should return: { "statusCode": 404, "code": "ORDER_NOT_FOUND", ... }

END OF PROMPT


This prompt resolves TD-008 from the technical debt register by creating a typed error hierarchy for consistent error handling.