Appearance
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 withenandeskeys.metadata— optional structured context (omitted when empty).rawError— only present whenNODE_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:
| Method | HTTP Status |
|---|---|
badRequest | 400 |
notFound | 404 |
conflict | 409 |
unauthorized | 401 |
forbidden | 403 |
tooManyRequests | 429 |
internal | 500 |
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
| Code | HTTP | Description |
|---|---|---|
IDENTITY.EMAIL_ALREADY_REGISTERED | 409 | Email already in use |
IDENTITY.EMAIL_ALREADY_REGISTERED_FOR_AGENCY | 409 | Email already registered for agency |
IDENTITY.USER_NOT_FOUND | 404 | User not found |
IDENTITY.CLIENT_NOT_FOUND | 404 | Client not found |
IDENTITY.AGENCY_NOT_FOUND | 404 | Agency not found |
IDENTITY.INVALID_VERIFICATION_CODE | 401 | Code doesn't match |
IDENTITY.VERIFICATION_CODE_ALREADY_USED | 401 | Code was already consumed |
IDENTITY.VERIFICATION_CODE_EXPIRED | 401 | Code past expiry |
IDENTITY.INVALID_REFRESH_TOKEN | 401 | Refresh token invalid or expired |
IDENTITY.REFRESH_TOKEN_REUSE_DETECTED | 401 | Reuse detected; subject must sign in again |
IDENTITY.TOKEN_SERVICE_OPERATION_FAILED | 500 | Unexpected token infrastructure failure |
IDENTITY.INVALID_CREDENTIALS | 401 | Generic login/auth credentials failure |
IDENTITY.UNAUTHORIZED | 401 | Generic token/session auth failure |
IDENTITY.GOOGLE_AUTH_INVALID_REQUEST | 400 | Invalid Google auth request context |
IDENTITY.GOOGLE_AUTH_FAILED | 401 | Generic Google auth failure |
IDENTITY.NO_AGENCY_ACCESS | 403 | User has no access to agency |
Core Error Codes (Auth-facing Guards)
| Code | HTTP | Description |
|---|---|---|
CORE.AUTH_TYPE_FORBIDDEN | 403 | Authenticated subject type cannot access endpoint |
CORE.RATE_LIMIT_EXCEEDED | 429 | Endpoint limit exceeded; metadata.retryAfterSeconds included |
Notification Error Codes
| Code | HTTP | Description |
|---|---|---|
NOTIFICATIONS.TEMPLATE_NOT_FOUND | 500 | Email template key not registered |
NOTIFICATIONS.EMAIL_DELIVERY_FAILED | 500 | Email send failed |
Migrated Commands
Identity
RegisterUserHandler—Result<void>VerifyEmailUserHandler—Result<TokenPair>LoginUserHandler—Result<TokenPair>GoogleLoginUserHandler—Result<TokenPair>SwitchAgencyHandler—Result<TokenPair>ChangeUserPasswordHandler—Result<TokenPair>RefreshTokenHandler—Result<TokenPair>LogoutHandler—Result<void>RegisterClientHandler—Result<void>VerifyEmailClientHandler—Result<TokenPair>LoginClientHandler—Result<TokenPair>GoogleLoginClientHandler—Result<TokenPair>ChangeClientPasswordHandler—Result<TokenPair>
Notifications
SendEmailNotificationHandler—Result<void>(called from event handlers, not controllers)
Adding Errors for New Modules
- Create
modules/<module>/application/errors/<module>.errors.ts. - Use
AppErrors.*factory methods with aMODULE.ERROR_CODEnaming convention. - Barrel-export from
modules/<module>/application/errors/index.ts. - Use
Result.fail(ModuleErrors.xxx())in command handlers.