This commit is contained in:
David Arranz 2025-10-04 17:40:41 +02:00
parent 6e5b14305e
commit 5534f06f21
8 changed files with 212 additions and 52 deletions

View File

@ -0,0 +1,13 @@
import { BaseError } from "./base-error";
/** Errores de aplicación: orquestación, validaciones de caso de uso, seguridad, idempotencia */
export class ApplicationError extends BaseError<"application"> {
public readonly layer = "application" as const;
constructor(
message: string,
code = "APPLICATION_ERROR",
options?: ErrorOptions & { metadata?: Record<string, unknown> }
) {
super("ApplicationError", message, code, options);
}
}

View File

@ -0,0 +1,38 @@
/**
* Error base para todas las capas con soporte de `code`, `cause` y serialización segura.
* Mantiene instanceof correcto y añade un discriminador de capa.
*/
export abstract class BaseError<
L extends "domain" | "application" | "infrastructure",
> extends Error {
public abstract readonly layer: L;
public readonly code: string;
public readonly metadata?: Record<string, unknown>;
constructor(
name: string,
message: string,
code: string,
options?: ErrorOptions & { metadata?: Record<string, unknown> }
) {
super(message, options);
this.name = name;
this.code = code;
this.metadata = options?.metadata;
Object.setPrototypeOf(this, new.target.prototype); // mantener instanceof
}
/** Serialización segura para logs/observabilidad */
toJSON() {
const cause = (this as any).cause as unknown | undefined;
return {
name: this.name,
message: this.message,
code: this.code,
layer: this.layer,
metadata: this.metadata,
cause: cause instanceof Error ? { name: cause.name, message: cause.message } : undefined,
};
}
}

View File

@ -1,12 +1,21 @@
/**
* Errores de capa de dominio. No deben "filtrarse" a cliente tal cual.
* Error base para la capa de dominio.
* Se usa para clasificar y manipular errores de dominio sin depender del texto de `message`.
*
* */
* Ejemplo: throw new DomainError("Customer not found", "CUSTOMER_NOT_FOUND", { cause: err });
*/
export class DomainError extends Error {
// domain/errors/domain-error.ts
import { BaseError } from "./base-error";
/** Error base de dominio: no depende de infra ni HTTP */
export class DomainError extends BaseError<"domain"> {
public readonly layer = "domain" as const;
constructor(message: string, options?: ErrorOptions) {
super(message, options);
Object.setPrototypeOf(this, new.target.prototype);
constructor(
message: string,
code = "DOMAIN_ERROR",
options?: ErrorOptions & { metadata?: Record<string, unknown> }
) {
super("DomainError", message, code, options);
}
}

View File

@ -1,60 +1,94 @@
/**
* Clase DomainValidationError
* Representa un error de validación de dominio.
*
* Esta clase extiende la clase Error de JavaScript y se utiliza para manejar errores
* específicos de validación dentro del dominio de la aplicación. Permite identificar
* el código de error, el campo afectado y un detalle descriptivo del error.
*
* @class DomainValidationError
* @extends {Error}
* @property {string} code - Código del error de validación.
* @property {string} field - Campo afectado por el error de validación.
* @property {string} detail - Detalle descriptivo del error de validación.
*
* @example
* const error = new DomainValidationError("INVALID_EMAIL", "email", "El email no es válido");
* console.error(error);
*/
import { DomainError } from "./domain-error";
/**
* Error de validación de dominio.
*
* - Extiende DomainError para aprovechar `code`, `metadata`, `cause` y `toJSON`.
* - Estructura estable para mapeo (Problem+JSON / telemetría).
*/
export class DomainValidationError extends DomainError {
// Discriminante estable para mapeo/telemetría
/** Discriminante para routing/telemetría */
public readonly kind = "VALIDATION" as const;
/** Regla/identificador de error de validación (ej. INVALID_EMAIL) */
public readonly code: string;
/** Campo afectado (path) */
public readonly field: string;
/** Mensaje legible de negocio */
public readonly detail: string;
constructor(
public readonly code: string, // id de regla del negocio (ej. 'INVALID_FORMAT')
public readonly field: string, // path: 'number' | 'date' | 'lines[0].quantity'
public readonly detail: string, // mensaje legible del negocio
options?: ErrorOptions
code: string,
field: string,
detail: string,
options?: ErrorOptions & { metadata?: Record<string, unknown> }
) {
super(`[${field}] ${detail}`, options);
// Mensaje humano compacto y útil para logs
super(`[${field}] ${detail}`, code, {
...options,
// Aseguramos metadatos ricos y estables
metadata: {
...(options?.metadata ?? {}),
field,
detail,
kind: "VALIDATION",
},
});
this.name = "DomainValidationError";
this.code = code;
this.field = field;
this.detail = detail;
Object.setPrototypeOf(this, new.target.prototype);
Object.freeze(this);
}
// Constructores rápidos
static requiredValue(field: string, options?: ErrorOptions) {
/** Atajos de construcción comunes */
static requiredValue(
field: string,
options?: ErrorOptions & { metadata?: Record<string, unknown> }
) {
return new DomainValidationError("REQUIRED_VALUE", field, "cannot be empty", options);
}
static invalidFormat(field: string, detail = "invalid format", options?: ErrorOptions) {
static invalidFormat(
field: string,
detail = "invalid format",
options?: ErrorOptions & { metadata?: Record<string, unknown> }
) {
return new DomainValidationError("INVALID_FORMAT", field, detail, options);
}
static invalidValue(
field: string,
value: unknown,
detail = "invalid value",
options?: ErrorOptions
options?: ErrorOptions & { metadata?: Record<string, unknown> }
) {
// `cause` preserva el valor problemático para inspección sin exponerlo a cliente
return new DomainValidationError("INVALID_VALUE", field, detail, { ...options, cause: value });
}
// Proyección útil para Problem+JSON o colecciones
/** Proyección mínima para Problem+JSON / colecciones */
toDetail() {
return { path: this.field, message: this.detail, rule: this.code };
}
/** Incluye proyección validación en la serialización segura */
override toJSON() {
const base = super.toJSON();
return {
...base,
kind: this.kind,
field: this.field,
detail: this.detail,
};
}
}
/** Type guard */
export const isDomainValidationError = (e: unknown): e is DomainValidationError =>
e instanceof DomainValidationError;

View File

@ -1,3 +1,6 @@
export * from "./application-error";
export * from "./base-error";
export * from "./domain-error";
export * from "./domain-validation-error";
export * from "./infrastructure-error";
export * from "./validation-error-collection";

View File

@ -0,0 +1,9 @@
import { BaseError } from "./base-error";
/** Errores de infraestructura: DB, red, serialización, proveedores externos */
export class InfrastructureError extends BaseError<"infrastructure"> {
public readonly layer = "infrastructure" as const;
constructor(message: string, code = "INFRASTRUCTURE_ERROR", options?: ErrorOptions & { metadata?: Record<string, unknown> }) {
super("InfrastructureError", message, code, options);
}
}

View File

@ -16,30 +16,76 @@
*/
import { DomainError } from "./domain-error";
import { DomainValidationError } from "./domain-validation-error";
export interface ValidationErrorDetail {
path?: string; // ejemplo: "lines[1].unitPrice.amount"
message: string; // ejemplo: "Amount must be a positive number",
value?: unknown; // valor inválido opcional
/** Path del campo inválido, ej. "lines[1].unitPrice.amount" */
path?: string;
/** Mensaje legible del fallo */
message: string;
/** Valor inválido (no se debe exponer directamente en API pública si es sensible) */
value?: unknown;
}
/**
* Error de validación múltiple. Agrega varios fallos de una sola vez.
* Error de validación múltiple de dominio.
* Permite agrupar varios fallos de validación en una única instancia.
*
* Ejemplo:
* const errors: ValidationErrorDetail[] = [
* { path: "lines[1].unitPrice.amount", message: "Amount must be positive" },
* { path: "lines[1].unitPrice.scale", message: "Scale must be non-negative" },
* ];
* throw new ValidationErrorCollection(errors);
*/
export class ValidationErrorCollection extends DomainError {
public readonly code = "VALIDATION" as const;
public readonly kind = "VALIDATION" as const;
public readonly code = "MULTIPLE_VALIDATION_ERRORS" as const;
public readonly details: ValidationErrorDetail[];
constructor(message: string, details: ValidationErrorDetail[], options?: ErrorOptions) {
super(message, options);
Object.setPrototypeOf(this, ValidationErrorCollection.prototype);
constructor(
details: ValidationErrorDetail[],
options?: ErrorOptions & { metadata?: Record<string, unknown> }
) {
super("Multiple validation errors", "MULTIPLE_VALIDATION_ERRORS", {
...options,
metadata: { ...(options?.metadata ?? {}), errors: details },
});
this.name = "ValidationErrorCollection";
this.details = details;
Object.setPrototypeOf(this, new.target.prototype);
Object.freeze(this);
}
/** Crear a partir de varios DomainValidationError */
static fromErrors(
errors: DomainValidationError[],
options?: ErrorOptions & { metadata?: Record<string, unknown> }
): ValidationErrorCollection {
const details: ValidationErrorDetail[] = errors.map((e) => ({
path: e.field,
message: e.detail,
value: (e as any).cause, // opcional: valor que provocó el error
}));
return new ValidationErrorCollection(details, options);
}
/** Serialización para Problem+JSON / logs */
override toJSON() {
const base = super.toJSON();
return {
...base,
kind: this.kind,
details: this.details.map((d) => ({
path: d.path,
message: d.message,
})),
};
}
}
/** Type guard */
export const isValidationErrorCollection = (e: unknown): e is ValidationErrorCollection =>
e instanceof ValidationErrorCollection;

View File

@ -38,17 +38,25 @@ export function extractOrPushError<T>(
const error = result.error;
if (isValidationErrorCollection(error)) {
// Agrega todos los detalles de error al array proporcionado
error.details.forEach((error) => {
// Copiar todos los detalles, rellenando path si falta
error.details.forEach((detail) => {
errors.push({
...error,
path: error.path || path,
...detail,
path: detail.path ?? path,
});
});
} else if (isDomainValidationError(error)) {
errors.push({ path, message: error.detail, value: error.cause?.toString() });
errors.push({
path,
message: error.detail,
value: (error as any).cause, // mantener la causa original
});
} else {
errors.push({ path, message: error.message });
// Fallback genérico: Error desconocido tratado como validación simple
errors.push({
path,
message: error.message ?? "Unknown error",
});
}
return undefined;