Files
goodgo-platform/docs/audits/CQRS_HANDLER_ERROR_HANDLING_GUIDE.md
Ho Ngoc Hai 514aa507db docs: move 8 audit report files to docs/audits/
Move remaining root-level audit and CQRS handler analysis files
to the centralized docs/audits/ directory for consistency.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-11 19:15:24 +07:00

14 KiB

CQRS Handler Error Handling Guide

GoodGo Platform Implementation Standards


📌 Quick Reference

Status Count Modules
✓ Has Error Handling 11 auth (5), listings (2), admin, notifications, payments, search
✗ Needs Error Handling 66 All other handlers + 6 auth handlers
Total 77 All modules

🎯 Pattern 1: Standard Command Handler with Error Handling

Use this pattern for most command handlers:

import { Logger } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, InternalServerErrorException } from '@modules/shared';

@CommandHandler(YourCommand)
export class YourCommandHandler implements ICommandHandler<YourCommand> {
  private readonly logger = new Logger(this.constructor.name);

  constructor(
    private readonly repository: IYourRepository,
    private readonly eventBus: EventBus,
  ) {}

  async execute(command: YourCommand): Promise<YourResult> {
    try {
      // Load aggregate
      const aggregate = await this.repository.findById(command.id);
      if (!aggregate) {
        throw new NotFoundException('Aggregate', command.id);
      }

      // Execute domain logic
      aggregate.doSomething(command.data);

      // Save state
      await this.repository.save(aggregate);

      // Publish events
      const events = aggregate.clearDomainEvents();
      for (const event of events) {
        this.eventBus.publish(event);
      }

      return { id: aggregate.id, status: 'success' };
    } catch (error) {
      // Always re-throw domain exceptions
      if (error instanceof DomainException) throw error;

      // Log unexpected errors
      this.logger.error(
        `Command execution failed: ${error instanceof Error ? error.message : String(error)}`,
        error instanceof Error ? error.stack : undefined,
        this.constructor.name,
      );

      // Throw generic HTTP exception
      throw new InternalServerErrorException('Operation failed, please try again');
    }
  }
}

🎯 Pattern 2: Standard Query Handler with Error Handling

Use this pattern for all query handlers:

import { Logger } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { InternalServerErrorException } from '@modules/shared';

@QueryHandler(YourQuery)
export class YourQueryHandler implements IQueryHandler<YourQuery> {
  private readonly logger = new Logger(this.constructor.name);

  constructor(private readonly repository: IYourRepository) {}

  async execute(query: YourQuery): Promise<YourQueryResult> {
    try {
      const result = await this.repository.find(query.criteria);
      if (!result) {
        throw new NotFoundException('Data not found');
      }
      return result;
    } catch (error) {
      this.logger.error(
        `Query execution failed: ${error instanceof Error ? error.message : String(error)}`,
        error instanceof Error ? error.stack : undefined,
        this.constructor.name,
      );
      throw new InternalServerErrorException('Unable to fetch data, please try again');
    }
  }
}

🎯 Pattern 3: Bulk Operation with Per-Item Error Handling

Use this pattern for batch operations (like bulk-moderate-listings):

@CommandHandler(BulkCommand)
export class BulkCommandHandler implements ICommandHandler<BulkCommand> {
  private readonly logger = new Logger(this.constructor.name);

  async execute(command: BulkCommand): Promise<BulkResult> {
    const succeeded: string[] = [];
    const failed: Array<{ id: string; reason: string }> = [];

    try {
      for (const id of command.ids) {
        try {
          const item = await this.repository.findById(id);
          if (!item) {
            failed.push({ id, reason: 'Item not found' });
            continue;
          }

          // Process item
          item.update(command.data);
          await this.repository.save(item);
          
          succeeded.push(id);
        } catch (itemError) {
          const message = itemError instanceof Error ? itemError.message : 'Unknown error';
          failed.push({ id, reason: message });
          // Continue processing other items
        }
      }

      return { succeeded, failed, processed: command.ids.length };
    } catch (error) {
      this.logger.error(
        `Bulk operation failed: ${error instanceof Error ? error.message : String(error)}`,
        error instanceof Error ? error.stack : undefined,
        this.constructor.name,
      );
      throw new InternalServerErrorException('Batch operation failed');
    }
  }
}

🎯 Pattern 4: Graceful Degradation (Non-Critical Services)

Use this pattern when secondary services can fail without blocking the main operation (like duplicate detection in create-listing):

async execute(command: CreateListingCommand): Promise<CreateListingResult> {
  try {
    // Critical path - must succeed
    const listing = await this.createListing(command);
    await this.repository.save(listing);

    // Non-critical: Duplicate detection
    let duplicates = [];
    try {
      duplicates = await this.duplicateDetector.find({
        title: command.title,
        location: command.location,
      });
    } catch (error) {
      this.logger.warn(
        'Duplicate detection failed, continuing without warnings',
        'CreateListingHandler'
      );
      // Continue - duplicates are optional
    }

    // Non-critical: Price validation
    let priceWarning: PriceWarning | undefined;
    try {
      const result = await this.priceValidator.validate(command);
      if (result.isSuspicious) {
        priceWarning = result;
      }
    } catch (error) {
      this.logger.warn(
        'Price validation failed, continuing without warning',
        'CreateListingHandler'
      );
      // Continue - price warning is optional
    }

    return {
      listingId: listing.id,
      duplicates,
      priceWarning,
    };
  } catch (error) {
    if (error instanceof DomainException) throw error;
    this.logger.error(
      `Create listing failed: ${error instanceof Error ? error.message : String(error)}`,
      error instanceof Error ? error.stack : undefined,
      'CreateListingHandler'
    );
    throw new InternalServerErrorException('Failed to create listing');
  }
}

🎯 Pattern 5: Authorization/Authentication Errors

Use this pattern when authentication can fail (like login-user):

@CommandHandler(LoginUserCommand)
export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
  private readonly logger = new Logger(this.constructor.name);

  constructor(private readonly tokenService: TokenService) {}

  async execute(command: LoginUserCommand): Promise<TokenPair> {
    try {
      return await this.tokenService.generateTokenPair({
        sub: command.userId,
        phone: command.phone,
        role: command.role,
      });
    } catch (error) {
      this.logger.error(
        `Token generation failed for user ${command.userId}: ${error instanceof Error ? error.message : error}`,
        error instanceof Error ? error.stack : undefined,
        'LoginUserHandler'
      );
      // Use specific exception for authentication
      throw new UnauthorizedException('Unable to create session, please try again');
    }
  }
}

Common Mistakes to Avoid

Mistake 1: Silent Catch Block

// BAD ❌
try {
  await this.repository.save(entity);
} catch (error) {
  // Silent - no logging, no error thrown
}

Fix:

// GOOD ✓
try {
  await this.repository.save(entity);
} catch (error) {
  this.logger.error(`Save failed: ${error}`, error?.stack, 'HandlerName');
  throw new InternalServerErrorException('Save failed');
}

Mistake 2: Swallowing Domain Exceptions

// BAD ❌
try {
  const result = entity.validate();
  if (result.isErr) {
    throw result.unwrapErr(); // ValidationException
  }
  // ...
} catch (error) {
  // This swallows the validation error
  throw new InternalServerErrorException('Failed');
}

Fix:

// GOOD ✓
try {
  const result = entity.validate();
  if (result.isErr) {
    throw result.unwrapErr();
  }
  // ...
} catch (error) {
  // Re-throw domain exceptions
  if (error instanceof DomainException) throw error;
  
  this.logger.error(`Unexpected: ${error}`, error?.stack);
  throw new InternalServerErrorException('Failed');
}

Mistake 3: Logging to Console

// BAD ❌
try {
  // ...
} catch (error) {
  console.error(error); // Not structured, not captured by logging system
  throw error;
}

Fix:

// GOOD ✓
try {
  // ...
} catch (error) {
  this.logger.error(
    `Error: ${error instanceof Error ? error.message : String(error)}`,
    error instanceof Error ? error.stack : undefined,
    this.constructor.name,
  );
  throw new InternalServerErrorException('Operation failed');
}

Mistake 4: Partial Logging

// BAD ❌
try {
  // ...
} catch (error) {
  this.logger.error(error.message); // Missing stack trace!
  throw error;
}

Fix:

// GOOD ✓
try {
  // ...
} catch (error) {
  this.logger.error(
    error instanceof Error ? error.message : String(error),
    error instanceof Error ? error.stack : undefined,
    this.constructor.name
  );
  throw error;
}

Mistake 5: Publishing Events Before Confirming Success

// BAD ❌
try {
  aggregate.apply(command);
  this.eventBus.publish(aggregate.events); // Before save!
  await this.repository.save(aggregate);
} catch (error) {
  // Event published but entity not saved!
}

Fix:

// GOOD ✓
try {
  aggregate.apply(command);
  await this.repository.save(aggregate); // Save first
  
  // Publish events only after successful save
  const events = aggregate.clearDomainEvents();
  for (const event of events) {
    this.eventBus.publish(event);
  }
} catch (error) {
  // No events published - data is consistent
}

🔍 Audit Checklist for Each Handler

When reviewing error handling, verify:

  • Try-Catch Block: Wraps entire execute method logic
  • Domain Exception Re-throw: if (error instanceof DomainException) throw error;
  • Error Logging: Includes message, stack trace, and context
  • Logger Usage: Uses injected logger, not console
  • Appropriate Exception: Throws correct HTTP exception type
  • No Silent Catches: Every catch block has logging or throw
  • Event Publishing: Only after successful state persistence
  • Transaction Rollback: Handled by try-catch (implicit or explicit)
  • User Message: Meaningful error response (not technical details)
  • Logging Context: Includes handler class name

📋 Implementation Checklist for Handlers Needing Error Handling

  1. Review existing pattern in well-implemented handlers:

    • auth/commands/login-user (auth patterns)
    • listings/commands/create-listing (graceful degradation)
    • admin/commands/bulk-moderate-listings (batch operations)
  2. Add to execute method:

    async execute(command: YourCommand): Promise<YourResult> {
      try {
        // existing logic
      } catch (error) {
        // error handling
      }
    }
    
  3. Check if domain exception should be re-thrown:

    if (error instanceof DomainException) throw error;
    
  4. Add appropriate logging:

    this.logger.error(
      `Message: ${error instanceof Error ? error.message : String(error)}`,
      error instanceof Error ? error.stack : undefined,
      this.constructor.name
    );
    
  5. Throw appropriate HTTP exception:

    throw new InternalServerErrorException();
    // or
    throw new NotFoundException();
    // or
    throw new BadRequestException();
    
  6. Test error scenarios:

    • Repository returns null/error
    • Domain validation fails
    • Database save fails
    • Event publishing fails

🚀 Priority Implementation Order

TIER 1 - Do First (33 handlers)

  • admin: 14 handlers
  • leads: 5 handlers
  • inquiries: 4 handlers
  • reviews: 5 handlers
  • subscriptions: 5 handlers

Effort: ~2 developer-days

TIER 2 - Do Second (18 handlers)

  • payments: 4 handlers
  • search: 8 handlers
  • listings: 5 handlers (2 already done)
  • agents: 3 handlers

Effort: ~1 developer-day

TIER 3 - Do Last (8 handlers)

  • analytics: 8 handlers
  • auth: 6 handlers (5 already done)

Effort: ~1 developer-day


📞 FAQ

Q: Should every handler have error handling?
A: Yes. Every async operation can fail - databases go down, networks fail, etc.

Q: Should I catch and swallow domain exceptions?
A: No. Re-throw them using if (error instanceof DomainException) throw error;

Q: What if the handler has no database calls?
A: Still add error handling. External service calls, calculations, and I/O all need try-catch.

Q: Can I use generic Exception handling?
A: No. Import and use NestJS exceptions like InternalServerErrorException, NotFoundException, etc.

Q: Should I log to console?
A: No. Inject and use the NestJS Logger service for structured logging.

Q: What about promise rejection handling?
A: Async/await with try-catch handles all promise rejections. That's why we wrap the entire execute method.


🎓 Reference: Exemplary Handlers

1. Login User Handler (Excellent)

Location: apps/api/src/modules/auth/application/commands/login-user/login-user.handler.ts

✓ Clear error message for user
✓ Proper exception type (UnauthorizedException)
✓ Stack trace logged
✓ Handler context included

2. Create Listing Handler (Advanced)

Location: apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts

✓ Critical path is protected
✓ Non-critical services can degrade gracefully
✓ Continues operation even if secondary services fail
✓ Warnings still provided when possible

3. Bulk Moderate Handler (Batch Pattern)

Location: apps/api/src/modules/admin/application/commands/bulk-moderate-listings/bulk-moderate-listings.handler.ts

✓ Per-item error collection
✓ Processing continues for other items
✓ Complete result returned with failures
✓ Individual error tracking


Last Updated: April 11, 2026
Audit Coverage: 77 handlers across 12 modules
Compliance: 14.3% currently implemented