Server-Side Error Handling
Best practices for handling and throwing errors in ObjectStack plugins — custom errors, hook propagation, validation, transactions, and safeParsePretty
Server-Side Error Handling
This guide covers best practices for handling and throwing errors within ObjectStack plugins and server-side code — from creating custom errors with proper codes to transaction rollback and integration with safeParsePretty().
Error Contract: ObjectStack uses a standardized error format across all layers. Server-side errors are automatically serialized to the ErrorResponseSchema format when returned through the API.
1. Creating Custom Errors with Proper Error Codes
Extend the base ObjectStack error class to create domain-specific errors:
import { ObjectStackError } from '@objectstack/core';
// Define error codes as constants for consistency
const ErrorCodes = {
TASK_ALREADY_COMPLETED: 'TASK_ALREADY_COMPLETED',
INVALID_STATUS_TRANSITION: 'INVALID_STATUS_TRANSITION',
BUDGET_EXCEEDED: 'BUDGET_EXCEEDED',
DUPLICATE_ASSIGNMENT: 'DUPLICATE_ASSIGNMENT',
} as const;
class TaskAlreadyCompletedError extends ObjectStackError {
constructor(taskId: string) {
super({
code: ErrorCodes.TASK_ALREADY_COMPLETED,
message: `Task ${taskId} is already completed and cannot be modified`,
status: 409,
details: [
{ field: 'status', message: 'Task is in terminal state', code: 'conflict' },
],
});
}
}
class InvalidStatusTransitionError extends ObjectStackError {
constructor(from: string, to: string) {
super({
code: ErrorCodes.INVALID_STATUS_TRANSITION,
message: `Cannot transition from '${from}' to '${to}'`,
status: 400,
details: [
{ field: 'status', message: `Invalid transition: ${from} → ${to}`, code: 'invalid_transition' },
],
});
}
}Error Code Naming: Use UPPER_SNAKE_CASE for error codes. Prefix with the domain (e.g., TASK_, ORDER_, INVOICE_) to avoid collisions across plugins.
2. Error Propagation in Hooks
Hooks are the primary extension point for business logic. Errors thrown in hooks are automatically caught by the Kernel and returned as API errors.
Before Hooks (Validation / Guard)
import { defineHook } from '@objectstack/core';
export const validateStatusTransition = defineHook({
object: 'task',
event: 'beforeUpdate',
handler: async ({ record, changes, context }) => {
// Guard: prevent modifying completed tasks
if (record.status === 'done' && changes.status !== undefined) {
throw new TaskAlreadyCompletedError(record.id);
}
// Validate status transitions
if (changes.status) {
const validTransitions: Record<string, string[]> = {
open: ['in_progress', 'cancelled'],
in_progress: ['open', 'done', 'blocked'],
blocked: ['in_progress', 'cancelled'],
done: [], // terminal state
cancelled: [], // terminal state
};
const allowed = validTransitions[record.status] ?? [];
if (!allowed.includes(changes.status)) {
throw new InvalidStatusTransitionError(record.status, changes.status);
}
}
return changes;
},
});After Hooks (Side Effects)
Errors in after hooks do not roll back the main operation, but they are logged and can trigger alerts:
export const notifyOnCompletion = defineHook({
object: 'task',
event: 'afterUpdate',
handler: async ({ record, changes, context }) => {
if (changes.status === 'done') {
try {
// notificationService is injected via the plugin's dependency container
await notificationService.send({
to: record.assigned_to,
template: 'task_completed',
data: { taskTitle: record.title },
});
} catch (error) {
// Log but don't fail the response
context.logger.error('Failed to send completion notification', {
taskId: record.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
},
});3. Validation Error Formatting
Format validation errors so the client can map them to specific form fields:
import { z } from 'zod';
import { ObjectStackError } from '@objectstack/core';
function createValidationError(zodError: z.ZodError): ObjectStackError {
const details = zodError.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
}));
return new ObjectStackError({
code: 'VALIDATION_ERROR',
message: `Validation failed: ${details.length} error(s)`,
status: 400,
details,
});
}
// Usage in a hook
export const validateTaskData = defineHook({
object: 'task',
event: 'beforeCreate',
handler: async ({ data }) => {
const schema = z.object({
title: z.string().min(1, 'Title is required').max(200),
priority: z.enum(['low', 'medium', 'high', 'critical']),
estimated_hours: z.number().min(0).max(1000).optional(),
});
const result = schema.safeParse(data);
if (!result.success) {
throw createValidationError(result.error);
}
return data;
},
});4. Error Logging and Monitoring
Structure error logs for observability:
import { defineHook } from '@objectstack/core';
export const errorLoggingMiddleware = defineHook({
object: '*', // applies to all objects
event: 'onError',
handler: async ({ error, context, operation }) => {
const logEntry = {
code: error.code,
message: error.message,
status: error.status,
object: context.object,
operation,
userId: context.user?.id,
requestId: context.requestId,
timestamp: new Date().toISOString(),
stack: error.status >= 500 ? error.stack : undefined,
};
if (error.status >= 500) {
context.logger.error('Server error', logEntry);
// Alert on-call if critical
await alertService.notify('server-error', logEntry);
} else if (error.status >= 400) {
context.logger.warn('Client error', logEntry);
}
// Re-throw to propagate to the API layer
throw error;
},
});5. Error Recovery Patterns
Graceful Degradation
When a non-critical service fails, continue with a fallback:
export const enrichTaskWithAI = defineHook({
object: 'task',
event: 'afterCreate',
handler: async ({ record, context }) => {
try {
const suggestions = await aiService.suggestTags(record.description);
if (suggestions.length > 0) {
await dataEngine.update('task', record.id, {
ai_suggested_tags: suggestions,
});
}
} catch (error) {
// AI enrichment is non-critical — log and continue
context.logger.warn('AI tag suggestion failed', {
taskId: record.id,
error: error instanceof Error ? error.message : String(error),
});
// Don't re-throw — the task was already created successfully
}
},
});Circuit Breaker
Prevent cascading failures when an external service is down:
class CircuitBreaker {
private failures = 0;
private lastFailure = 0;
private readonly threshold: number;
private readonly resetTimeout: number;
constructor(threshold = 5, resetTimeout = 60000) {
this.threshold = threshold;
this.resetTimeout = resetTimeout;
}
get isOpen(): boolean {
if (this.failures >= this.threshold) {
if (Date.now() - this.lastFailure > this.resetTimeout) {
this.failures = 0; // Reset after timeout
return false;
}
return true;
}
return false;
}
recordFailure() {
this.failures++;
this.lastFailure = Date.now();
}
recordSuccess() {
this.failures = 0;
}
async execute<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
if (this.isOpen) return fallback;
try {
const result = await fn();
this.recordSuccess();
return result;
} catch (error) {
this.recordFailure();
return fallback;
}
}
}
const emailCircuit = new CircuitBreaker(5, 60000);6. Transaction Rollback on Error
Ensure data consistency when multi-step operations fail:
import { defineHook } from '@objectstack/core';
export const createProjectWithTasks = defineHook({
object: 'project',
event: 'afterCreate',
handler: async ({ record, context }) => {
// Wrap multi-step operations in a transaction
await dataEngine.transaction(async (tx) => {
// Create default tasks for the project
const defaultTasks = [
{ title: 'Project kickoff', status: 'open', project: record.id },
{ title: 'Requirements gathering', status: 'open', project: record.id },
{ title: 'Design review', status: 'open', project: record.id },
];
for (const task of defaultTasks) {
await tx.create('task', task);
}
// Update project with task count
await tx.update('project', record.id, {
task_count: defaultTasks.length,
});
// If any operation fails, all changes are rolled back
});
},
});7. Bulk Operation Error Handling
Handle partial failures in batch operations gracefully:
interface BulkOperationResult<T> {
succeeded: T[];
failed: Array<{ index: number; data: unknown; error: string }>;
}
async function bulkCreateTasks(
tasks: Record<string, unknown>[]
): Promise<BulkOperationResult<Record<string, unknown>>> {
const result: BulkOperationResult<Record<string, unknown>> = {
succeeded: [],
failed: [],
};
for (let i = 0; i < tasks.length; i++) {
try {
const created = await dataEngine.create('task', tasks[i]);
result.succeeded.push(created);
} catch (error) {
result.failed.push({
index: i,
data: tasks[i],
error: error instanceof Error ? error.message : String(error),
});
}
}
// If everything failed, throw a combined error
if (result.succeeded.length === 0 && result.failed.length > 0) {
throw new ObjectStackError({
code: 'BULK_OPERATION_FAILED',
message: `All ${result.failed.length} operations failed`,
status: 400,
details: result.failed.map((f) => ({
field: `operations[${f.index}]`,
message: f.error,
code: 'operation_failed',
})),
});
}
return result;
}8. Integration with safeParsePretty()
ObjectStack provides safeParsePretty() for Zod schemas that returns human-readable error messages:
import { safeParsePretty } from '@objectstack/spec';
import { ObjectSchema } from '@objectstack/spec/data';
function validateObjectDefinition(input: unknown) {
const result = safeParsePretty(ObjectSchema, input);
if (!result.success) {
// result.error contains formatted, human-readable messages
console.error('Validation failed:');
console.error(result.error);
// Output:
// ✗ fields.0.name: Field name must be snake_case (received "firstName")
// ✗ fields.1.type: Invalid enum value. Expected 'text' | 'number' | ...
// ✗ name: Required
// Convert to ObjectStack error format
throw new ObjectStackError({
code: 'VALIDATION_ERROR',
message: 'Invalid object definition',
status: 400,
details: result.issues.map((issue) => ({
field: issue.path,
message: issue.message,
code: issue.code,
})),
});
}
return result.data;
}Using safeParsePretty in Hooks
import { safeParsePretty } from '@objectstack/spec';
import { z } from 'zod';
const TaskBusinessRules = z.object({
estimated_hours: z.number().max(1000, 'Estimated hours cannot exceed 1000'),
due_date: z.string().refine(
(d) => new Date(d) > new Date(),
'Due date must be in the future'
),
});
export const validateBusinessRules = defineHook({
object: 'task',
event: 'beforeCreate',
handler: async ({ data }) => {
const result = safeParsePretty(TaskBusinessRules, data);
if (!result.success) {
throw new ObjectStackError({
code: 'VALIDATION_ERROR',
message: result.error,
status: 400,
details: result.issues.map((i) => ({
field: i.path,
message: i.message,
code: 'business_rule',
})),
});
}
return data;
},
});safeParsePretty vs safeParse: Always prefer safeParsePretty() in server-side code. It provides formatted error messages that are suitable for both logging and API responses, with "Did you mean?" suggestions for common typos.