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:
- Inconsistent Error Responses: Different formats for similar errors
- Difficult Error Handling: Consumers can't catch specific errors
- Missing Context: Stack traces without domain context
- 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.