DEV Community

ivklgn
ivklgn

Posted on

conway-errors: Bringing Order to Errors as Part of Your Projects Architecture

While working with a large frontend application codebase, I noticed that functionality gradually clusters around teams (domains). Each functional group eventually imposes its own architectural constraints. As it turned out, error handling when comparing code from two different teams was inconsistent. In one case, developers structured errors using standard JS/TS inheritance, while in another case, error catching and logging were used.

It became clear that we needed to generalize our approach to how we structure (name, inherit) and throw errors. As practice showed, coding conventions weren't sufficient.

What did we want to achieve?

  1. A unified and strict way to create error hierarchies (from base to final functionality)
  2. The ability to describe the relationship between an error and the team's context
  3. Generalize the logging mechanism in Sentry and improve error readability when working with the tracking system
  4. Provide a convenient API for passing additional parameters

Moving Away from Classes

"Good primitive is more than a framework"

Reatom Zen

Classes by default don't impose restrictions on inheritance options and depth. Therefore, conway-errors provides a factory as its programming API for creating error hierarchies.

We arrived at the following schema:

┌─────────────────────────────────────────────┐
│           createError([types])              │
│                      │                      │
│                      │                      │
│              ErrorContext                   │
│                      │                      │
│         ┌────────────┼────────────┐         │
│         │            │            │         │
│   .subcontext()  .subcontext()  .feature()  │
│    AuthError      APIError     UserAction   │
│         │            │            │         │
│         │            │            │         │
│   .feature()    .feature()     Error()      │
│   OauthError    PaymentAPI                  │
│         │            │                      │
│         │            │                      │
│     Error()      Error()                    │
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
  • createError - unified root for defining error types and configuration (context)
  • .subcontext() - ability to define as many nested contexts as needed
  • .feature() - creation of final errors

Example:

import { createError } from "conway-errors";

// Configuration and description of base types of possible errors
const createErrorContext = createError([
  { errorType: "FrontendLogicError" },
  { errorType: "BackendLogicError" },
] as const);

// Creating root context
const errorContext = createErrorContext("MyProject");

// Creating subcontexts
const apiErrorContext = errorContext.subcontext("APIError");
const authErrorContext = errorContext.subcontext("AuthError");

// Creating specific errors
const oauthError = authErrorContext.feature("OauthError");
const apiPaymentError = apiErrorContext.feature("APIPaymentError");

// Throwing errors
throw oauthError("FrontendLogicError", "User not found");
// Result: "FrontendLogicError: MyProject/AuthError/OauthError: User not found"

throw apiPaymentError("BackendLogicError", "Payment already processed");
// Result: "BackendLogicError: MyProject/APIError/APIPaymentError: Payment already processed"
Enter fullscreen mode Exit fullscreen mode

Single Configuration Point

Errors created with conway-errors are simply objects. You decide what to do with them:

import { createError } from "conway-errors";

const createErrorContext = createError([
  { errorType: "ValidationError" },
  { errorType: "NetworkError" },
] as const);

const appErrors = createErrorContext("MyApp");
const loginError = appErrors.feature("LoginError");

// Option 1: throwing via standard throw e;
throw loginError("ValidationError", "Invalid email format");

// Option 2: logging without throwing (console.error() by default)
loginError("ValidationError", "Invalid email format").emit();
Enter fullscreen mode Exit fullscreen mode

This way, the primitive allows you to choose the layer in your application for throwing and catching.
One of our key ideas was to define integration with Sentry logging "at the top":

import { createError } from "conway-errors";
import * as Sentry from "@sentry/nextjs";

const createErrorContext = createError([
  { errorType: "FrontendLogicError" },
  { errorType: "BackendLogicError" }
] as const, {
  // Custom error handling for monitoring
  handleEmit: (err) => {
    Sentry.captureException(err);
  },
});

const appErrors = createErrorContext("MyApp");
const userError = appErrors.feature("UserAction");

// Automatically logs to <a href="https://sentry.io" target="_blank" rel="noopener">Sentry</a> when using emit()
userError("FrontendLogicError", "Form validation failed").emit();
Enter fullscreen mode Exit fullscreen mode

Adding Extended Parameters

At different application layers, there may be necessary data that could be useful for throwing and logging final errors.
An important feature of the entire library API is the ability to specify extendedParams in all methods.

Important: Nested contexts and features override upper parameters (extendedParams)

Best demonstrated with an example:

import { createError } from "conway-errors";
import * as Sentry from "@sentry/nextjs";

const createErrorContext = createError(
  ["FrontendLogicError", "BackendLogicError"],
  {
    extendedParams: {
      environment: process.env.NODE_ENV,
      version: "1.2.3"
    },
  }
);

const paymentErrors = createErrorContext("Payment", {
  extendedParams: { service: "stripe" }
});

const cardPayment = paymentErrors.feature("CardPayment", {
  extendedParams: { region: "us-east-1" }
});

const error = cardPayment("BackendLogicError", "Payment processing failed");

error.emit({
  extendedParams: {
    userId: "user-123",
    action: "checkout",
    severity: "critical"
  }
});
Enter fullscreen mode Exit fullscreen mode

Domain Separation

"Any organization that designs a system (in a broad sense) is constrained to produce a design whose structure is a copy of the organization's communication structure."

Conway's Law, Wikipedia

If your project is developed by multiple teams and the code starts to split into different subdomains, conway-errors provides several structuring options due to its flexible API.
You can choose the most convenient option for your project.

  1. Root context for each team
import { createError } from "conway-errors";

const createErrorContextPaymentTeamErrorContext = createError([
  { errorType: "BackendLogicError" },
] as const);

const createAuthTeamErrorContext = createError([
  { errorType: "FrontendLogicError" },
  { errorType: "BackendLogicError" },
] as const);

// define subcontexts for each team
// ...
Enter fullscreen mode Exit fullscreen mode
  1. Subcontexts with parameters for each team
import { createError } from "conway-errors";

// unified root context
const createErrorContext = createError([
  { errorType: "FrontendLogicError" },
  { errorType: "BackendLogicError" },
] as const);

// Using extendedParams for team attribution (recommended)

const authErrors = projectErrors.subcontext("Auth", {
  extendedParams: { team: "Auth Team" }
});

const paymentErrors = projectErrors.subcontext("Payment", {
  extendedParams: { team: "Payment Team" }
});
Enter fullscreen mode Exit fullscreen mode
  1. Subcontexts for each team
import { createError } from "conway-errors";

// unified root context
const createErrorContext = createError([
  { errorType: "FrontendLogicError" },
  { errorType: "BackendLogicError" },
] as const);

const authErrors = createErrorContext("Auth Team");
const paymentErrors = createErrorContext("Payment Team");
Enter fullscreen mode Exit fullscreen mode

Don't forget that you can try to come up with your own method of team (or subdomain) attribution.

Conclusion

After transitioning to conway-errors, we achieved the same behavior with less code. Additionally, we generalized error creation and structuring for all teams in the project.

I invite you to visit the project repository, install and try it! If you have ideas for improving the library share them in GitHub Issues!

Top comments (0)