Unified API error handling for .NET and Angular to improve UX

Summary

During a recent architectural review, we identified a significant fragmentation of error-handling logic across our distributed system. The backend (.NET) was inconsistently utilizing HTTP status codes—sometimes returning 200 OK with an error payload, and other times returning 500 Internal Server Error for validation issues. This inconsistency caused the Angular frontend to fail silently or display cryptic “Unknown Error” messages. We have since implemented a unified contract for API responses and a global interceptor pattern in Angular to automate user notifications.

Root Cause

The instability stemmed from three primary architectural failures:

  • Semantic Misuse of HTTP Status Codes: Developers were using 200 OK as a transport wrapper for application-level failures, bypassing the built-in error-handling capabilities of HTTP clients.
  • Lack of a Standardized Error Schema: There was no enforced structure for error payloads, meaning the frontend could not predictably parse error messages.
  • Decentralized Frontend Logic: Error handling was being implemented manually within individual Angular components rather than being centralized in the networking layer.

Why This Happens in Real Systems

In large-scale production environments, this phenomenon occurs due to:

  • Developer Autonomy vs. Standardization: When multiple teams work on different microservices without a shared API Governance document, coding patterns diverge.
  • Legacy Debt: Older services often follow “Envelope Patterns” (wrapping everything in a 200 OK), while newer services follow RESTful principles, leading to a hybrid, confusing state.
  • The “Happy Path” Bias: During initial development, engineers focus on successful data retrieval, treating error states as edge cases rather than first-class citizens of the API contract.

Real-World Impact

  • Degraded User Experience (UX): Users encounter “silent failures” where a button click does nothing, or receive technical stack traces instead of actionable instructions.
  • Increased Mean Time to Recovery (MTTR): On-call engineers struggle to diagnose issues because the HTTP status code (e.g., 500) does not reflect the true nature of the error (e.g., a 400 Bad Request due to business logic).
  • Frontend Complexity Explosion: The Angular codebase becomes bloated with repetitive try-catch blocks and manual toast calls in every single service.

Example or Code

// Standardized Error Response Model in .NET
public class ApiErrorResponse
{
    public string TraceId { get; set; }
    public string Message { get; set; }
    public IDictionary Errors { get; set; }
}

// Global Exception Middleware in .NET
public class ExceptionMiddleware
{
    private readonly RequestDelegate _next;

    public ExceptionMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (ValidationException ex)
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsJsonAsync(new ApiErrorResponse 
            { 
                Message = "Validation Failed",
                Errors = ex.Errors 
            });
        }
        catch (Exception ex)
        {
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
            await context.Response.WriteAsJsonAsync(new ApiErrorResponse 
            { 
                Message = "An unexpected error occurred." 
            });
        }
    }
}
// Global Error Interceptor in Angular
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
  constructor(private toast: ToastrService) {}

  intercept(req: HttpRequest, next: HttpHandler): Observable<HttpEvent> {
    return next.handle(req).pipe(
      catchError((error: HttpErrorResponse) => {
        let errorMessage = 'An unknown error occurred!';

        if (error.status === 400) {
          errorMessage = error.error.message || 'Invalid request parameters.';
        } else if (error.status === 401) {
          errorMessage = 'Session expired. Please login again.';
        } else if (error.status === 403) {
          errorMessage = 'You do not have permission to perform this action.';
        } else if (error.status === 500) {
          errorMessage = 'Internal server error. Our engineers are notified.';
        }

        this.toast.error(errorMessage);
        return throwError(() => error);
      })
    );
  }
}

How Senior Engineers Fix It

Senior engineers solve this by implementing Systemic Guardrails rather than fixing individual bugs:

  • Contract-First Development: Defining the error schema (using OpenAPI/Swagger) before writing any implementation code.
  • Middleware Implementation: Moving error logic out of Controllers and into Global Exception Middleware to ensure every single failure follows the same shape.
  • Centralized Interception: Using Angular HttpInterceptor to decouple the UI components from the network’s error-handling concerns.
  • Observability Integration: Ensuring the TraceId from the backend is passed to the frontend and included in the toast message, allowing users to provide a reference for support tickets.

Why Juniors Miss It

  • Focus on Functionality over Resilience: Juniors tend to focus on making the feature “work” (the happy path) and view error handling as an afterthought.
  • Local vs. Global Thinking: A junior will write a try-catch inside a specific component, whereas a senior recognizes that this pattern should be handled globally to maintain DRY (Don’t Repeat Yourself) principles.
  • Ignoring the Protocol: Juniors often treat HTTP as a mere “pipe” for data, whereas seniors treat HTTP as a semantic protocol where status codes carry critical meaning.

Leave a Comment