Solving Common Problems in Application Error Handling

Alex Mubarakshin
8 min readNov 22, 2024

--

When vague error messages distract developers from solving real issues. Clear context is key to efficient debugging!

In today’s interconnected systems, where applications span multiple teams and domains, effective error management is vital to maintaining user trust and operational reliability. Traditional error-handling approaches quickly reveal their limitations when it comes to large, distributed systems. This article examines the common challenges associated with error handling in modern applications and provides a structured approach to addressing them effectively.

Common Problems with Error Handling

To understand how to improve error management, let’s first explore the challenges that commonly arise in modern applications.

1. Lack of Context in Errors

In distributed systems, tracking down the root cause of an error is often a challenge. Traditional error handling mechanisms, such as throwing and catching generic Error objects, fail to provide valuable context about the source of the problem—whether it originated in the frontend, backend, or microservice.

For example, consider the following scenario in a payment system:

try {
processPayment();
} catch (error) {
console.error("Error occurred:", error.message);
}

Output:

Error occurred: Payment failed

While this may seem like a clear error message, it provides no clue as to where the error occurred in the system. In a microservice architecture with multiple teams responsible for different components, this lack of context can lead to:

  • Slow troubleshooting: Teams are left guessing which part of the system the error came from (frontend, backend, database, etc.).
  • Fragmented error logs: Without a clear organizational structure for logging, errors across different services can get lost, complicating debugging.
  • Difficult prioritization: When multiple teams work on different parts of the system, there’s no clear way to categorize errors for resolution.

The absence of context becomes a serious bottleneck in large organizations where debugging must span multiple domains.

2. Deep Inheritance Hierarchies

To make errors more specific, many developers create custom error classes and rely on inheritance hierarchies. While this approach may work in small applications, it can quickly become unmanageable as the application grows, leading to:

  • Tangled error hierarchies: Creating error classes like PaymentError and CardPaymentError seems reasonable initially, but as more specific error types are added (e.g., LoanPaymentError, RefundError), the hierarchy becomes deep and unwieldy.
  • Breakage from inheritance changes: Modifying or extending the hierarchy without careful consideration of other parts of the system often leads to unintentional breakages.
  • Maintenance challenges: Updating or refactoring error classes in a deep inheritance structure is risky and can require extensive changes throughout the application.

Here’s an example of a simple inheritance hierarchy:

class PaymentError extends Error {
constructor(message) {
super(message);
this.name = "PaymentError";
}
}

class CardPaymentError extends PaymentError {
constructor(message) {
super(message);
this.name = "CardPaymentError";
}
}

While this approach works at first, scaling this design results in a complex inheritance chain that’s difficult to extend without breaking existing functionality.

3. Inconsistent Error Reporting

In many applications, different teams and services use different methods for error reporting. One service may log errors to the console, while another may integrate with an external service like Sentry or Datadog. This inconsistency creates significant challenges:

  • Gaps in error reporting: Errors may go unreported if different parts of the system use disparate logging mechanisms.
  • Difficulty aggregating error data: When teams rely on different tools, it becomes harder to aggregate error data for system-wide visibility, preventing timely responses to issues.
  • Delayed incident response: Without a unified approach to error handling, critical errors may go unnoticed or take longer to identify, slowing down response times and impacting production environments.

Here’s an example of inconsistent error logging:

console.error("An error occurred:", error);  // Service A

logErrorToSentry(error); // Service B

Without a standardized error reporting mechanism, issues may be missed, and teams may struggle to coordinate fixes.

A Scalable Solution to Error Handling: conway-errors

The challenges outlined above — lack of context, deep inheritance hierarchies, and inconsistent error reporting — are common in large applications, particularly in complex microservices architectures and distributed systems. To address these issues, you need a solution that provides clarity, consistency, and scalability. This is where conway-errors comes in.

Why conway-errors?

The conway-errors library is built with scalability and simplicity in mind. It is inspired by Conway’s Law, which states: “Organizations design systems that mirror their communication structure.” In essence, the structure of an organization, particularly in how teams communicate and collaborate, directly influences the design of the systems they build.

Conway’s Law highlights the importance of aligning error handling with the organizational structure, particularly when multiple teams are responsible for different parts of a system. conway-errors enables a more modular, team-oriented approach to error handling, allowing each team to define their own error contexts, while ensuring consistency and ease of management.

Solving the Problems with conway-errors

1. Providing Context to Errors

With conway-errors, each team can define a context for their errors, allowing the system to capture and communicate where the error originated. By associating each error with a precise, identifiable context, you can easily track down issues across your distributed system.

Here’s how you can define a clear error context:

import { createError } from "conway-errors";

// Create the root error context for the entire application
const createErrorContext = createError([
{ errorType: "PaymentError" },
{ errorType: "AuthenticationError" },
]);

// Define a context for the Payment team
const paymentErrorContext = createErrorContext("PaymentTeamContext");

// Create a specific error for the Payment team
const cardPaymentError = paymentErrorContext.feature("CardPaymentError");

// Throw an error with clear context
throw cardPaymentError("PaymentError", "Card expired");

Output:

Error Message: "PaymentTeamContext/CardPaymentError: Card expired"

By tagging errors with the context in which they occurred, conway-errors helps you easily identify where the issue took place, whether it’s in the frontend, backend, or any other module. This enables faster root cause analysis and reduces inter-team debugging efforts.

2. Simplifying Error Hierarchies

Rather than relying on deep class inheritance, conway-errors uses a more flexible and scalable approach. Error contexts are defined through configuration objects, making it easier to create, modify, and extend error types without introducing complex inheritance structures.

Here’s how you can simplify error hierarchies with conway-errors:

const createErrorContext = createError();
const errorPaymentContext = createErrorContext("PaymentContext");

// Define specific errors within this context
const cardPaymentError = errorPaymentContext.feature("CardPaymentError");

// Throw an error without the need for deep inheritance
throw cardPaymentError("PaymentError", "Card expired");

This flat, context-driven approach allows developers to define and extend error types with ease — without worrying about breaking inheritance chains or making complex changes to the codebase.

3. Consistent Error Reporting

conway-errors ensures that errors are consistently reported, regardless of the tool or method used. By centralizing error reporting logic, you can integrate with various external systems (like Sentry, Datadog, or custom logging tools) while maintaining uniformity across the system.

Here’s how you can integrate conway-errors with an external tool like Sentry:

import * as Sentry from "@sentry/nextjs";

// Configure error context with Sentry integration
const createErrorContextWithSentry = createError(
[{ errorType: "PaymentError" }, { errorType: "AuthenticationError" }],
{
handleEmit: (err) => {
Sentry.captureException(err); // Send error to Sentry
},
}
);

const paymentErrorContext = createErrorContextWithSentry("PaymentContext");

const paymentError = paymentErrorContext.feature("PaymentError");

throw paymentError("PaymentError", "Payment processing failed");

By configuring a global error handling strategy, conway-errors ensures that all errors — regardless of where they occur in your system — are emitted through a consistent and standardized process.

How conway-errors Solves These Challenges

The conway-errors library provides a structured, context-aware framework for defining, handling, and reporting errors. It offers the following key benefits:

  1. Structured error contexts for better debugging.
  2. Flat, maintainable error hierarchies.
  3. Centralized and customizable error emission logic.
  4. Dynamic extra parameter passing for context-specific customization.

Let’s dive into examples that illustrate how conway-errors simplifies error handling and enables scalable solutions.

1. Structured Error Contexts

With conway-errors, you can define hierarchical contexts for different parts of your application or organization. These contexts help encapsulate errors with relevant metadata, making it easier to identify the source and understand its implications.

Here’s how to define a root context and subcontexts:

import { createError } from "conway-errors";

// Define root error types
const createErrorContext = createError([
{ errorType: "FrontendError" },
{ errorType: "BackendError" },
]);

// Create a root context for the application
const rootContext = createErrorContext("ApplicationRoot");

// Add a subcontext for the Authentication team
const authContext = rootContext.subcontext("Authentication");
const authError = authContext.feature("OAuth");

// Create an error with detailed context
throw authError("BackendError", "OAuth token validation failed");

Output:

Error Message: "ApplicationRoot/Authentication/OAuth: OAuth token validation failed"

With this structure, you can trace the error to Authentication -> OAuth, providing clear insight into the issue’s location.

2. Passing Extra Parameters to Subcontexts

One of the cool features of conway-errors is the ability to pass custom parameters to subcontexts or specific error types. These parameters can enrich error metadata, making it more actionable.

For example, suppose you want to track the user ID and request ID associated with an error:

const userId = "user-123";
const requestId = "req-456";

const createErrorContext = createError();
const rootContext = createErrorContext("ApplicationRoot");

// Pass extra parameters when creating a subcontext
const paymentContext = rootContext.subcontext("Payments", { userId, requestId });

const paymentError = paymentContext.feature("CardPayment");

// Create and emit an error
const error = paymentError("BackendError", "Card declined", {
extendedParams: { cardType: "VISA" },
});

error.emit();

Output:

Error Message: "ApplicationRoot/Payments/CardPayment: Card declined"
Extended Params: { userId: 'user-123', requestId: 'req-456', cardType: 'VISA' }

3. Centralized Error Emission

The conway-errors library includes centralized emission logic, making it easy to integrate with external logging tools like Sentry, Datadog, or Splunk. This ensures that errors are consistently reported across all modules.

Here’s how to configure error emission globally:

import * as Sentry from "@sentry/node";

// Configure global error context with Sentry integration
const createErrorContextWithSentry = createError(
[{ errorType: "FrontendError" }, { errorType: "BackendError" }],
{
handleEmit: (err, extendedParams) => {
Sentry.captureException(err, { extra: extendedParams });
},
}
);

// Define a payment error context
const paymentContext = createErrorContextWithSentry("Payments");
const paymentError = paymentContext.feature("Transaction");

throw paymentError("BackendError", "Transaction failed", {
extendedParams: { transactionId: "txn-789" },
});

With this setup, errors are automatically reported to Sentry, along with their extended parameters for better traceability.

4. Error Ownership in Multi-Team Organizations

In large organizations, teams often manage distinct modules, such as authentication, payments, or notifications. conway-errors enables clear error ownership by associating errors with specific teams or subsystems.

const createErrorContext = createError();
const rootContext = createErrorContext("ApplicationRoot");

// Define team contexts
const authContext = rootContext.subcontext("Authentication");
const paymentContext = rootContext.subcontext("Payments");
const notificationContext = rootContext.subcontext("Notifications");

// Define team-specific features and errors
const oauthError = authContext.feature("OAuthError");
const cardPaymentError = paymentContext.feature("CardPaymentError");
const emailError = notificationContext.feature("EmailDeliveryError");

// Throw and emit errors
throw oauthError("BackendError", "Invalid OAuth token");
throw cardPaymentError("BackendError", "Card expired", {
extendedParams: { cardType: "MasterCard" },
});
throw emailError("BackendError", "SMTP server not reachable", {
extendedParams: { retryCount: 3 },
});

Output:

Error: "ApplicationRoot/Authentication/OAuthError: Invalid OAuth token"
Error: "ApplicationRoot/Payments/CardPaymentError: Card expired"
Extended Params: { cardType: "MasterCard" }
Error: "ApplicationRoot/Notifications/EmailDeliveryError: SMTP server not reachable"
Extended Params: { retryCount: 3 }

5. Flexible Postfix Creation

Another powerful feature of conway-errors is the ability to append dynamic postfixes to error messages. This is useful for adding additional details, such as originating system or version.

Here’s an example:

const createErrorWithPostfix = createError(
[
{
errorType: "BackendError",
createMessagePostfix: (originalError) => {
return originalError?.source ? ` (Source: ${originalError.source})` : "";
}
},
]
);

const systemErrorContext = createErrorWithPostfix("System");
const databaseError = systemErrorContext.feature("DatabaseError");

throw databaseError("BackendError", "Connection timed out", {
originalError: { source: "PostgreSQL" },
});

Output:

Error Message: "System/DatabaseError: Connection timed out (Source: PostgreSQL)"

This flexibility makes it easy to enrich error messages dynamically, providing additional insights without manual intervention.

Conclusion

As applications grows, particularly in large organizations with distributed systems and microservices, traditional error-handling approaches quickly become inadequate. Lack of context, deep inheritance hierarchies, and inconsistent error reporting create significant hurdles to managing errors efficiently.

conway-errors offers a structured, scalable approach to error handling that addresses these challenges. By aligning error handling with team structures and communication patterns, conway-errors simplifies error management, improves visibility, and ensures consistency across large applications.

By adopting conway-errors, teams can:

  • Clearly categorize errors with context for better troubleshooting.
  • Simplify error hierarchies without sacrificing extensibility.
  • Ensure consistent, centralized error reporting across services.

Ultimately, conway-errors streamlines error handling for large applications, making it easier for teams to manage and resolve issues as they arise.

--

--

No responses yet