Skip to content

Result Pattern & Structured Error Handling

Overview

Commands return an explicit Result<T> instead of throwing HTTP exceptions directly. This decouples application logic from HTTP concerns and provides a consistent, structured error shape across the API.

Error Response Shape

When result.unwrap() throws, the HTTP response body is:

json
{
  "statusCode": 404,
  "errorCode": "IDENTITY.USER_NOT_FOUND",
  "message": { "en": "User not found", "es": "Usuario no encontrado" },
  "metadata": {},
  "rawError": { "message": "...", "stack": "..." }
}
  • errorCode — namespaced string (e.g. IDENTITY.USER_NOT_FOUND).
  • message — bilingual object with en and es keys.
  • metadata — optional structured context (omitted when empty).
  • rawError — only present when NODE_ENV !== 'production'.

Core Classes

AppError (shared/application/errors/app-error.ts)

Value object holding error details: errorCode, message (bilingual), httpStatus, metadata, and optional rawError.

AppErrors factory (shared/application/errors/app-error.factory.ts)

Shortcut constructors that set the HTTP status automatically:

MethodHTTP Status
badRequest400
notFound404
conflict409
unauthorized401
forbidden403
tooManyRequests429
internal500

Result<T> (shared/application/result.ts)

typescript
// Success
Result.ok(value);

// Failure
Result.fail(appError);

// Unwrap in controller — throws HttpException on failure
const data = result.unwrap();

Module Error Catalogs

Each module defines its own error catalog. Example for identity:

typescript
// modules/identity/application/errors/identity.errors.ts
export const IdentityErrors = {
  userNotFound: () => AppErrors.notFound('IDENTITY.USER_NOT_FOUND', { ... }),
  emailAlreadyRegistered: () => AppErrors.conflict('IDENTITY.EMAIL_ALREADY_REGISTERED', { ... }),
  // ...
} as const;

Usage in Commands

typescript
async execute(command: RegisterUserCommand): Promise<Result<void>> {
  const existing = await this.userRepo.findByEmail(dto.email);
  if (existing) {
    return Result.fail(IdentityErrors.emailAlreadyRegistered());
  }
  // ... business logic ...
  return Result.ok(undefined);
}

When a command depends on a service/port that already returns Result<T>, propagate failures directly instead of catching thrown errors:

typescript
const tokenResult = await this.tokenService.generateTokenPair(...);
if (tokenResult.isFailure) {
  return Result.fail(tokenResult.error);
}

const tokenPair = tokenResult.unwrap();
return Result.ok({
  accessToken: tokenPair.accessToken,
  refreshToken: tokenPair.refreshToken,
});

Usage in Controllers

typescript
// void result (register)
async register(@Body() dto: RegisterUserDto): Promise<{ message: string }> {
  const result = await this.commandBus.execute(new RegisterUserCommand(dto));
  result.unwrap(); // throws if error
  return { message: 'Verification code sent to your email' };
}

// value result (verify-email)
async verifyEmail(@Body() dto: VerifyEmailUserDto): Promise<AuthResponseDto> {
  const result = await this.commandBus.execute(new VerifyEmailUserCommand(dto));
  return result.unwrap(); // throws if error, returns TokenPair on success
}

Usage in Guards and Strategies

Framework entrypoint layers (Passport strategies and guards) cannot return Result<T>. In these cases, build an AppError from the module error catalog and throw a structured HttpException via toHttpException(...).

typescript
// Example in a strategy/guard
if (!isAllowed) {
  throw toHttpException(IdentityErrors.invalidCredentials());
}
  • This keeps the HTTP body shape identical to Result.unwrap().
  • Auth-facing failures are normalized to generic responses to avoid information leaks.
  • Core guard errors use CORE.* codes (e.g. auth type denied, rate limit exceeded).

Identity Error Codes

CodeHTTPDescription
IDENTITY.EMAIL_ALREADY_REGISTERED409Email already in use
IDENTITY.EMAIL_ALREADY_REGISTERED_FOR_AGENCY409Email already registered for agency
IDENTITY.USER_NOT_FOUND404User not found
IDENTITY.CLIENT_NOT_FOUND404Client not found
IDENTITY.AGENCY_NOT_FOUND404Agency not found
IDENTITY.INVALID_VERIFICATION_CODE401Code doesn't match
IDENTITY.VERIFICATION_CODE_ALREADY_USED401Code was already consumed
IDENTITY.VERIFICATION_CODE_EXPIRED401Code past expiry
IDENTITY.INVALID_REFRESH_TOKEN401Refresh token invalid or expired
IDENTITY.REFRESH_TOKEN_REUSE_DETECTED401Reuse detected; subject must sign in again
IDENTITY.TOKEN_SERVICE_OPERATION_FAILED500Unexpected token infrastructure failure
IDENTITY.INVALID_CREDENTIALS401Generic login/auth credentials failure
IDENTITY.UNAUTHORIZED401Generic token/session auth failure
IDENTITY.GOOGLE_AUTH_INVALID_REQUEST400Invalid Google auth request context
IDENTITY.GOOGLE_AUTH_FAILED401Generic Google auth failure
IDENTITY.NO_AGENCY_ACCESS403User has no access to agency

Core Error Codes (Auth-facing Guards)

CodeHTTPDescription
CORE.AUTH_TYPE_FORBIDDEN403Authenticated subject type cannot access endpoint
CORE.RATE_LIMIT_EXCEEDED429Endpoint limit exceeded; metadata.retryAfterSeconds included

Notification Error Codes

CodeHTTPDescription
NOTIFICATIONS.TEMPLATE_NOT_FOUND500Email template key not registered
NOTIFICATIONS.EMAIL_DELIVERY_FAILED500Email send failed

Migrated Commands

Identity

  • RegisterUserHandlerResult<void>
  • VerifyEmailUserHandlerResult<TokenPair>
  • LoginUserHandlerResult<TokenPair>
  • GoogleLoginUserHandlerResult<TokenPair>
  • SwitchAgencyHandlerResult<TokenPair>
  • ChangeUserPasswordHandlerResult<TokenPair>
  • RefreshTokenHandlerResult<TokenPair>
  • LogoutHandlerResult<void>
  • RegisterClientHandlerResult<void>
  • VerifyEmailClientHandlerResult<TokenPair>
  • LoginClientHandlerResult<TokenPair>
  • GoogleLoginClientHandlerResult<TokenPair>
  • ChangeClientPasswordHandlerResult<TokenPair>

Notifications

  • SendEmailNotificationHandlerResult<void> (called from event handlers, not controllers)

Adding Errors for New Modules

  1. Create modules/<module>/application/errors/<module>.errors.ts.
  2. Use AppErrors.* factory methods with a MODULE.ERROR_CODE naming convention.
  3. Barrel-export from modules/<module>/application/errors/index.ts.
  4. Use Result.fail(ModuleErrors.xxx()) in command handlers.