System Design

Error Handling in Practice: From User Input to Production Failures

15 mins
The AI Space Team

Error handling is not just about catching exceptions. In real-world applications, it is a full-stack design concern that affects user experience, backend reliability, security, debugging, and production resilience. This article explores how to handle errors from user input to backend failures, and how to design safer, clearer, and more maintainable error-handling flows.

Error Handling in Practice: From User Input to Production Failures

When we develop an application or system, error handling should be part of the solution design from the beginning.

Good error handling is not only about showing an error message. It is also about helping users recover, helping developers debug production issues, protecting sensitive information, and making the system more resilient when dependencies fail.

In real projects, errors can happen in many places: user input, frontend runtime logic, API communication, backend validation, business rules, databases, third-party services, infrastructure, or even unexpected bugs. A good error-handling strategy should make these errors predictable, traceable, and safe to expose to users.


Error Handling on the Frontend

Frontend errors usually come from three main sources:

  • User interaction errors
  • Backend or external service errors
  • Runtime UI errors

In a Web3 application, the “backend service” may not only be a normal API server. It may also include blockchain RPC nodes, wallet providers, smart contracts, or indexers.

The frontend’s main responsibility is to help users understand what happened and guide them to the next action. At the same time, the frontend should avoid exposing technical details that users do not need to know.


Error Sources on the Frontend

Errors from User Interaction

These errors usually happen when the user enters invalid data or performs an invalid action.
For example:

  • Required fields are missing
  • The email format is invalid
  • The password does not meet the rules
  • The user clicks a button before selecting required options
  • The uploaded file type or file size is invalid

These errors should be handled as early as possible on the frontend through form validation.
In modern frontend development, we often use libraries such as React Hook Form, Zod, or Yup to handle form validation. Many component libraries, such as MUI, Ant Design, and shadcn/ui, also provide useful UI components for showing validation states.

Common ways to handle user input errors include:

  • Showing inline validation messages near the field
  • Disabling the submit button when required fields are missing
  • Giving clear and friendly error messages
  • Avoiding technical messages that users cannot understand

However, frontend validation is only for user experience. It should never be treated as the only validation layer. The backend still needs to validate the same payload again, because requests can be sent without using the frontend.


Errors from Backend or External Services

The frontend also needs to handle errors returned from backend APIs or external services.
For example:

  • The API returns 400 Bad Request
  • The user is not authenticated
  • The user does not have permission
  • The backend service is temporarily unavailable
  • Payment fails
  • The wallet transaction is rejected
  • The blockchain RPC node is unavailable
  • The smart contract transaction fails
  • The request times out

In this part, the frontend and backend should agree on a consistent error response format. The backend should also define proper business error codes, so the frontend can decide how to handle different errors.

For example:

{
  "statusCode": 409,
  "errorCode": "LEAD_ALREADY_ASSIGNED",
  "message": "This lead has already been assigned.",
  "requestId": "req_123456"
}

The errorCode is the business error code. The frontend can use it to decide what message to show or what action to take.

For example:

Error CodeFrontend Behavior
UNAUTHORIZEDRedirect user to login
FORBIDDENShow “You do not have permission”
RESOURCE_NOT_FOUNDShow empty state or not-found page
PAYMENT_FAILEDShow retry/payment instruction
SERVICE_UNAVAILABLEShow temporary failure message
UNKNOWN_ERRORShow generic fallback message

The main goal is:

Show enough information for the user to understand what happened, but do not expose internal system details.

In real projects, we usually avoid handling common API errors in every component. Instead, we often create a shared API client or request wrapper. For example, 401 Unauthorized can trigger login or token refresh, 403 Forbidden can show a permission message, and 500 Internal Server Error can show a generic fallback message.

This keeps UI components cleaner and avoids duplicated error-handling logic across many pages.


Frontend Runtime Errors

Frontend errors are not only API errors. Runtime errors can also happen inside the UI.
For example:

  • A React component crashes
  • A property is undefined
  • A third-party script fails
  • A browser API is not supported
  • A page fails during hydration
  • A lazy-loaded chunk fails to load

In React applications, we can use Error Boundaries to prevent one component crash from breaking the whole page. Instead of showing a blank screen, the application can show a fallback UI.
For example:
"Something went wrong while loading this section. Please refresh the page."

In Next.js or React applications, we can also use route-level error pages, fallback UI, loading states, and retry buttons.

The key idea is that the user should not be blocked by a confusing blank page. Even when something fails, the frontend should give the user a clear next step.


How to Track Errors on the Frontend

We can integrate error-tracking tools on the frontend, such as Sentry or New Relic.
These tools can capture useful information, such as:

  • Error message
  • Stack trace
  • Page URL
  • Browser and device information
  • User action before the error
  • API request ID, if available
  • Release version

Some tools, such as Sentry, also provide replay features. This can help developers understand what the user did before the error happened, which makes production debugging much easier.
However, we should be careful not to capture sensitive information, such as passwords, tokens, private keys, or personal information that is not needed for debugging.

A useful rule is:

Capture enough information to debug the issue, but never expose or store sensitive data unnecessarily.


Error Handling on the Backend

Backend errors usually come from several sources:

  • Request boundary errors
  • Authentication and authorization errors
  • Business / domain errors
  • Dependency / integration errors
  • Infrastructure / configuration errors
  • Unexpected application errors

Compared with the frontend, backend error handling has more responsibilities. The backend needs to validate incoming requests, protect sensitive information, enforce business rules, handle dependency failures, and provide enough logs for debugging production issues.


Error Sources on the Backend

Request Boundary Errors

The backend should never fully trust the frontend.

Even if the frontend already validates the form, the backend must validate the request again. These errors usually happen when the request first enters the backend, before the real business logic starts.

For example:

  • Required fields are missing
  • The data type is incorrect
  • The email format is invalid
  • The enum value is not allowed
  • The date format is invalid
  • The file type or file size is invalid
  • The request body shape does not match the expected DTO/model

In NestJS, this is commonly handled with DTOs, class-validator, and ValidationPipe.
For example:

export class CreateLeadDto {
  @IsEmail()
  email: string;

  @IsString()
  @IsNotEmpty()
  name: string;
}

Then in main.ts:

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    transform: true,
    forbidNonWhitelisted: true,
  }),
);

Here, ValidationPipe validates the incoming request before it reaches the controller method. If the payload is invalid, the request can be rejected early with a 400 Bad Request.

In ASP.NET Core / .NET, a similar idea is usually handled by request models, Data Annotations, and model validation.
For example:

public class CreateLeadRequest
{
    [Required]
    public string Name { get; set; } = string.Empty;

    [EmailAddress]
    public string Email { get; set; } = string.Empty;
}

With [ApiController], ASP.NET Core can automatically return 400 Bad Request when the model state is invalid.

The key idea is:

Validate data at the backend boundary before it enters the business logic.


Authentication and Authorization Errors

Authentication and authorization errors should be treated separately from normal validation or business errors, because they are usually handled by the security layer before the request reaches the service layer.

Authentication answers: "Who are you?"

Authorization answers: "What are you allowed to do?"

For example:
Authentication errors:

  • Missing access token
  • Invalid JWT
  • Expired session
  • Invalid API key

Authorization errors:

  • The user does not have the required role
  • The user does not have permission to access this endpoint
  • The user belongs to a different organization
  • The user is not allowed to update this resource

In NestJS, authentication and authorization are commonly handled by Guards. Guards can be applied at the route level or controller level.

For example:

@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req) {
  return req.user;
}

A role-based example:

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('manager')
@Post(':id/assign')
assignLead(@Param('id') id: string, @Body() body: AssignLeadDto) {
  return this.leadService.assignLead(id, body.userId);
}

In this structure, JwtAuthGuard checks whether the user is authenticated, RolesGuard checks whether the user has the required role, ValidationPipe checks whether the request payload is valid.

In ASP.NET Core / .NET, authentication and authorization are usually handled by middleware and attributes such as [Authorize].

For example:

[ApiController]
[Route("api/leads")]
[Authorize]
public class LeadsController : ControllerBase
{
    [HttpGet]
    public IActionResult GetLeads()
    {
        return Ok();
    }
    [HttpPost("{id}/assign")]
    [Authorize(Policy = "ManagerOnly")]
    public IActionResult AssignLead(string id, AssignLeadRequest request)
    {
        return Ok();
    }
}

One important point is that not all authorization can be handled at the endpoint level.
Some rules are simple:

  • Only managers can assign leads
  • Only admins can access this endpoint
  • Only authenticated users can view their profile

These can be handled by guards, roles, policies, or [Authorize].
But some permissions depend on the actual resource. For example:

  • Only the assigned sales rep can update this lead
  • Only the owner of a document can delete it
  • Only users from the same organization can view this record

For these cases, the backend may need to load the resource first and then check permission inside the service layer.

The key idea is:

Authentication and endpoint-level authorization usually happen before the controller, but resource-level authorization may still happen inside the service layer.


Business / Domain Errors

Business errors are expected errors caused by business rules.
These errors happen when the request is technically valid and the user is authenticated, but the action is not allowed by the business logic.
For example:

  • A lead has already been assigned
  • An order has already been paid
  • The payment amount does not match the invoice
  • An NFT has already been listed
  • The wallet balance is not enough
  • The booking time slot is no longer available

These errors should be represented clearly in the backend and returned to the frontend in a consistent format.
For example:

throw new BusinessException(
  'LEAD_ALREADY_ASSIGNED',
  'This lead has already been assigned.',
  409,
);

The backend can return:

{
  "statusCode": 409,
  "errorCode": "LEAD_ALREADY_ASSIGNED",
  "message": "This lead has already been assigned."
}

Here, the HTTP status code and business error code have different responsibilities.
The HTTP status code describes the general category of the failure. The business error code describes the application-specific reason.
For example:

ScenarioHTTP StatusBusiness Error Code
Invalid request payload400VALIDATION_ERROR
Missing or invalid token401UNAUTHORIZED
User has no permission403FORBIDDEN
Resource does not exist404RESOURCE_NOT_FOUND
Lead already assigned409LEAD_ALREADY_ASSIGNED
Third-party service unavailable503SERVICE_UNAVAILABLE
Unknown server error500INTERNAL_SERVER_ERROR

Business errors are usually thrown from the service layer, because the service layer understands the business rules.

The key idea is:

Business errors should be explicit, predictable, and safe for the frontend to handle.


Dependency / Integration Errors

Dependency or integration errors happen when the backend communicates with databases, internal services, or external services.
For example:

  • Database connection timeout
  • Redis is unavailable
  • Email provider failed
  • SMS provider failed
  • Payment provider timeout
  • Blockchain RPC node is unavailable
  • Queue job failed
  • Cloud storage upload failed
  • Another internal microservice returned an error

This is where try/catch is very useful.
The backend should catch low-level errors, log the technical details, and convert them into safe application errors.
For example:

try {
  await this.smsService.sendSms(phone, message);
} catch (error) {
  this.logger.error('Failed to send SMS', {
    phone,
    error,
  });
  throw new BusinessException(
    'SMS_SEND_FAILED',
    'We could not send the SMS. Please try again later.',
    503,
  );
}

The repository layer is also a dependency boundary because it talks directly to the database. However, this does not mean we should wrap every repository method with try/catch.

For simple queries, it is usually fine to let unexpected database errors bubble up to the global exception handler. We should catch database errors in the repository layer only when we can translate known errors, such as:

  • Unique constraint violations
  • Duplicate key errors
  • Foreign key errors
  • Transaction failures
  • Concurrency conflicts

For these dependency or integration errors, we may also need to consider reliability patterns:

  • Timeout
  • Retry with backoff
  • Circuit breaker
  • Fallback
  • Dead-letter queue

The key idea is:

Catch dependency errors where we can add useful context, translate low-level errors, or apply recovery strategies.


Infrastructure / Configuration Errors

Infrastructure and configuration errors are caused by the runtime environment or deployment setup.
For example:

  • Missing environment variables
  • Wrong database connection string
  • Invalid API key configuration
  • Wrong CORS origin
  • Expired certificate
  • Network issue
  • Cloud service permission issue
  • Storage bucket does not exist
  • Service account has missing permissions

Some of these should be checked when the application starts.
For example:

  • DATABASE_URL is missing
  • TWILIO_ACCOUNT_SID is missing
  • AUTH0_DOMAIN is missing
  • JWT_SECRET is missing

For critical configuration, it is better to fail fast during application startup instead of letting the app start and fail later during user requests.

The key idea is:

Critical configuration should be validated during startup, so the service fails early and clearly if the environment is not ready.


Unexpected Application Errors

Unexpected application errors are real bugs or unhandled edge cases in the application.
For example:

  • Cannot read property of undefined
  • Null reference error
  • Logic bug
  • Unhandled edge case
  • Wrong data assumption
  • Serialization error
  • Memory issue

For these errors, we usually return a generic response to the frontend:

{
  "statusCode": 500,
  "errorCode": "INTERNAL_SERVER_ERROR",
  "message": "Something went wrong. Please try again later."
}

But internally, we should log the full details, including the stack trace, request path, request ID, user ID if available, service name, and release version.

These errors should also be tracked by tools such as Sentry, Cloud Logging, New Relic, or OpenTelemetry.

The key idea is:

Unexpected errors should be hidden from users but visible to developers through logs and monitoring tools.


Where Should We Throw and Catch Errors?

When deciding where to throw or catch an error, we should not only ask:
"Where did the error happen?"
A better question is:
"Which layer understands this error best?"
"Which layer can add useful context?"
"Which layer should convert it into the final API response?"

A useful rule is:

Throw the error where the problem is understood, catch the error where we can add value, and format the final response in one centralized place.

For example, request validation errors should be handled at the request boundary. Authentication and authorization errors should be handled by guards, middleware, attributes, or policies. Business errors should usually be thrown from the service layer because the service layer understands the business rules. Database errors may be translated in the repository layer if they are known persistence errors, such as unique constraint violations or concurrency conflicts. Third-party service errors are usually caught at integration boundaries, where low-level provider errors can be logged and converted into safe application errors.

Controllers should usually stay thin. They should read request parameters, call the service layer, and return the result. They should not manually catch every error and build response payloads. If every controller handles errors by itself, the API responses can easily become inconsistent and the code becomes harder to maintain.

A cleaner approach is to let the service throw a meaningful business error:

async assignLead(leadId: string, userId: string) {
  const lead = await this.leadRepository.findById(leadId);
  if (!lead) {
    throw new BusinessException(
      'LEAD_NOT_FOUND',
      'Lead not found.',
      404,
    );
  }
  if (lead.assignedTo) {
    throw new BusinessException(
      'LEAD_ALREADY_ASSIGNED',
      'This lead has already been assigned.',
      409,
    );
  }
  return this.leadRepository.assignLead(leadId, userId);
}

Then the controller can stay simple:

@Post(':id/assign')
assignLead(
  @Param('id') id: string,
  @Body() body: AssignLeadDto,
) {
  return this.leadService.assignLead(id, body.userId);
}

The global exception filter or centralized error-handling middleware can catch the error and convert it into a consistent response:

{
  "statusCode": 409,
  "errorCode": "LEAD_ALREADY_ASSIGNED",
  "message": "This lead has already been assigned."
}

For dependency or integration errors, try/catch is still very useful. But it should be used at the boundary where we call the external dependency, not randomly across the codebase.
For example:

try {
  await this.smsProvider.send(phone, message);
} catch (error) {
  this.logger.error('Failed to send SMS', {
    phone,
    error,
  });

  throw new IntegrationException(
    'SMS_SEND_FAILED',
    'Failed to send SMS.',
    error,
  );
}

In this example, the low-level SMS provider error is caught and converted into an application-level error. The original technical error can still be logged for debugging, but the frontend does not need to know the internal provider details.

The repository layer follows the same principle. It can catch known database errors if it can translate them into meaningful persistence errors. For example, a unique constraint violation can become DUPLICATE_RECORD, and the service layer can later decide whether that means EMAIL_ALREADY_USED, LEAD_ALREADY_EXISTS, or another business error.

However, the repository does not need to catch every database error. If the database is down or an unexpected database error happens, it is often fine to let the error bubble up to the centralized error handler.

So the best-practice flow is:

Validation layer
→ handles invalid request payloads
Guard / auth layer
→ handles authentication and authorization errors
Service layer
→ throws business/domain errors
Repository layer
→ optionally translates known database errors
Integration layer
→ catches and translates third-party service errors
Global exception filter / middleware
→ formats the final API response

The key idea is:

Do not catch errors just because you can. Catch them only when you can add context, translate them into a better error, apply recovery logic, or prevent internal details from leaking.

This keeps the code clean, avoids duplicated try/catch blocks, and makes error responses consistent across the whole system.


Global Exception Filter / Centralized Error Handling

In real projects, we usually create a centralized error-handling layer to make all API errors consistent.
The purpose of this layer is to:

  • Convert known application errors into consistent API responses
  • Hide internal implementation details from users
  • Log unexpected errors for debugging
  • Attach useful information such as requestId, path, timestamp, and statusCode
  • Avoid duplicated try/catch blocks in every controller

For example, when the service layer throws a business error like this:

throw new BusinessException(
  'LEAD_ALREADY_ASSIGNED',
  'This lead has already been assigned.',
  409,
);

The controller does not need to catch it manually. The global exception filter can catch it and convert it into a consistent response:

{
  "statusCode": 409,
  "errorCode": "LEAD_ALREADY_ASSIGNED",
  "message": "This lead has already been assigned.",
  "path": "/api/leads/123/assign",
  "timestamp": "2026-05-05T00:00:00.000Z"
}

In NestJS, this can be handled by a global exception filter.

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(GlobalExceptionFilter.name);
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest<Request>();
    const response = ctx.getResponse<Response>();
    if (exception instanceof BusinessException) {
      return response.status(exception.statusCode).json({
        statusCode: exception.statusCode,
        errorCode: exception.errorCode,
        message: exception.message,
        path: request.url,
        timestamp: new Date().toISOString(),
      });
    }
    this.logger.error('Unhandled exception', exception as Error);
    return response.status(500).json({
      statusCode: 500,
      errorCode: 'INTERNAL_SERVER_ERROR',
      message: 'Something went wrong. Please try again later.',
      path: request.url,
      timestamp: new Date().toISOString(),
    });
  }
}

Then we can register it globally during application bootstrap:
app.useGlobalFilters(new GlobalExceptionFilter());

In ASP.NET Core / .NET, the same idea can be handled by centralized exception-handling middleware or a custom exception handler.

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;
    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    {
        _logger = logger;
    }
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is BusinessException businessException)
        {
            httpContext.Response.StatusCode = businessException.StatusCode;
            await httpContext.Response.WriteAsJsonAsync(new
            {
                statusCode = businessException.StatusCode,
                errorCode = businessException.ErrorCode,
                message = businessException.Message,
                path = httpContext.Request.Path.Value,
                timestamp = DateTime.UtcNow
            }, cancellationToken);
            return true;
        }
        _logger.LogError(exception, "Unhandled exception");
        httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
        await httpContext.Response.WriteAsJsonAsync(new
        {
            statusCode = 500,
            errorCode = "INTERNAL_SERVER_ERROR",
            message = "Something went wrong. Please try again later.",
            path = httpContext.Request.Path.Value,
            timestamp = DateTime.UtcNow
        }, cancellationToken);
        return true;
    }
}

Then register it in the application setup:

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

app.UseExceptionHandler();

The implementation is different between frameworks, but the principle is the same:
Controllers stay thin, services throw meaningful errors, and the centralized error handler converts those errors into safe and consistent API responses.


Logging and Monitoring on the Backend

Backend error handling is incomplete without logging and monitoring.

Returning a safe error response to the frontend is only one part of error handling. Internally, we also need enough information to understand what happened, where it happened, and how to fix it.

In small or medium projects, tools like Sentry, Cloud Logging, or New Relic can help capture backend exceptions and error trends. In larger systems, we may also use structured logging, metrics, and distributed tracing.

These tools help us answer production questions such as:

  • Which endpoint is failing?
  • How often does this error happen?
  • Which users are affected?
  • Did the error start after a new release?
  • Is the error from our code, the database, or a third-party service?
  • Is the system becoming slower before errors happen?

The key idea is:

API responses are for users. Logs, metrics, and traces are for developers and operators.

Good logging and monitoring make production issues easier to detect, investigate, and fix.


Final Takeaways

Error handling is not just about catching exceptions. It is a design decision across the whole system.

On the frontend, good error handling helps users understand what happened and guides them to the next action. We should validate user input early, show clear messages, handle API failures properly, and avoid exposing technical details.

On the backend, good error handling protects the system and keeps the application predictable. We should validate requests at the boundary, handle authentication and authorization before business logic, throw clear business errors from the service layer, and catch dependency errors only when we can add useful context or recovery logic.

In production systems, error handling also needs to include retry safety, idempotency, logging, monitoring, and alerting. Retry can help the system recover from temporary failures, but only when the operation is safe to retry or protected against duplication.
The final goal is not to remove all errors. That is impossible. The real goal is to make errors safe, understandable, traceable, and recoverable.

A good error-handling strategy should help users recover, help developers debug, protect sensitive information, and keep the system stable when something goes wrong.