← Akash Bhuiyan

Typed Error Codes Across the Stack: How We Built API Error Contracts Without Magic Strings

Errors are an interface. Design them.

9 min read · 2,314 words

By Akash Bhuiyan · Senior backend engineer. Currently building Kredvox.

Two failure modes in the Kredvox publish flow looked identical from the frontend. One was a LinkedIn authorization token that had silently expired after a few weeks. The other was a workspace where the user had skipped connecting their social account entirely. Both produced the same dead end: "Something went wrong publishing. Please try again." One needed a quick OAuth refresh. The other needed full onboarding. The user couldn't tell which. Neither could I, without opening the logs. The backend threw the right errors. The interface threw away the meaning.

Most engineers treat error handling as exception management. You write the feature. You notice a data boundary or an external API that might fail. You throw a RuntimeException, catch it in a global @ControllerAdvice, map it to a 400 or 409, and move on. The backend didn't crash, so you feel done. But you're looking at the execution from the wrong side of the server.

An error response isn't a defensive fallback. It's a runtime contract between three audiences. The end user needs a clear next action they can take to unblock themselves. The frontend needs a predictable, machine-readable signal so it knows what to render: a toast, a settings link, or a payment modal. And the engineer maintaining this codebase six months from now needs enough context to debug without parsing raw text files.

When you don't design for all three, you default to the standard developer cop-out: catching a generic failure, wrapping it in a vague payload, and walking away. You treat errors as an afterthought, something to handle when an edge case forces you to. But the edge cases come, and they come from real users.

Errors are not just exceptions. Errors are an interface. And interfaces deserve design.

To fix a broken interface, you first have to look at the patterns we keep rebuilding.

The most common culprit is the generic internal error.

{ "error": "Internal Server Error" }

The backend throws a RuntimeException. The framework catches it, maps it to a 500, and hides the details. The frontend is blind, forced to show the same fallback toast whether the failure was a network timeout, a database connection drop, or a third-party API outage.

Other architectures rely on the HTTP status as the only signal.

{} // HTTP 401 Unauthorized

A raw 401 is ambiguous. The frontend can't tell if the user typed the wrong password, if their session expired, or if an administrator locked the account. Without a machine-readable signal, the frontend guesses. Usually by redirecting to login and wiping unsaved form data.

When status codes fall short, developers reach for stringly-typed messages the frontend has to parse.

{ "error": "User not found" }

The client runs .includes("not found") to decide whether to highlight an input field or open a modal. Then a backend engineer rewrites the message to "No user exists with that email." The string match fails silently. The user sees no error at all.

Even past 500 and 401, status codes alone aren't enough vocabulary for domain logic.

{} // HTTP 409 Conflict

A 409 can mean many different conflicts. The frontend can't tell whether the user invited a teammate who's already in the workspace, approved a post that was already reviewed, or edited a record someone else just changed. The client can't route to the right UI because HTTP status codes don't have the vocabulary for domain state.

The thread connecting all four failures is the same: no stable, machine-readable identifier for what went wrong. Without an error code, the frontend parses fragile strings, renders the same notification for different scenarios, or forces users to re-authenticate blindly. The client becomes bound to the phrasing of backend log messages rather than a clear contract.

This is the gap that broke the Kredvox publish flow as it grew. Both LinkedIn failure modes (an expired token and a workspace that had never connected) returned a generic payload that stripped out the underlying state. Under the hood, the backend knew it was dealing with TOKEN_EXPIRED in one case and LINKEDIN_NOT_CONNECTED in the other. On the wire, both looked the same. The frontend hook fell back to a generic string. Users opened the dashboard and hit a dead end that told them to try again. No amount of retrying would fix a missing account connection or an invalid OAuth token.

Fixing this pattern doesn't require a rewrite. It requires an explicit wire format: a predictable contract that carries meaning across the network boundary.

{
  "success": false,
  "error": "Connect your LinkedIn account to publish and schedule posts.",
  "errorCode": "LINKEDIN_NOT_CONNECTED"
}

This single payload serves all three audiences we ignored before.

The frontend gets a stable, machine-readable errorCode. The client doesn't parse text or guess at status codes; it switches on a fixed string and routes the user to the right UI. If the code is LINKEDIN_NOT_CONNECTED, the interface renders a setup link. If it's TOKEN_EXPIRED, it prompts a fresh login.

The end user gets a plain-language message in the error field. If the frontend has no custom UI for this specific code, it falls back to showing that message, which is still better than "Something went wrong."

And the engineer reading the logs sees the same errorCode the user saw on screen. The trace is one search away.

LINKEDIN_NOT_CONNECTEDTHE CONTRACT — TRAVELS UNTOUCHED01Domain LayerthrowsThrows exception02@ControllerAdvicemaps toWraps in envelope03Wire FormatJSON payload{ errorCode: "..." }04Frontend Hookswitches onErrorCode.LINKEDIN_NOT_CONNECTED05User UXrenders"Connect LinkedIn"
The errorCode is the contract. Every other field can change; the code travels untouched from the exception throw to the rendered UI.

The principle that makes this work is simple: the errorCode is a stable contract. Backend developers can refactor internal exception names. Copywriters can rewrite the user-facing message or translate it into Swedish, German, Portuguese. The logging framework can change how it serializes output. But as long as that string identifier doesn't change, the frontend contract holds. The error code decouples system execution from user presentation.

That's the architecture as principle. Here it is as code. The backend has three pieces: a base runtime exception, a domain exception hierarchy, and a single @RestControllerAdvice that maps exceptions to the wire. Every custom error in the application extends a single root: KredvoxException.

public class KredvoxException extends RuntimeException {
    public KredvoxException(String message) {
        super(message);
    }
}

From this root, we build targeted domain exceptions. One class per failure type. When the publish layer detects a missing connection, it throws a LinkedInNotConnectedException. The exception is the signal. It carries no error code, no HTTP status, and no user message; those concerns live elsewhere.

Elsewhere is the ErrorCode enum. Every failure state in the application has a value here, and every value carries its own developer-facing message.

public enum ErrorCode {
    LINKEDIN_NOT_CONNECTED("No LinkedIn account is connected for this workspace"),
    TOKEN_EXPIRED("OAuth token has expired and must be refreshed"),
    INVALID_SCHEDULE_TIME("Schedule time must be in the future and within the allowed window"),
    SUBSCRIPTION_EXPIRED("Workspace subscription has expired or is no longer active");
 
    private final String technicalMessage;
 
    ErrorCode(String technicalMessage) {
        this.technicalMessage = technicalMessage;
    }
 
    public String message() { return this.technicalMessage; }
}

A global @RestControllerAdvice ties the two together. It catches the domain exception, picks the HTTP status, and wraps the enum value in a uniform ApiResponse envelope.

@ExceptionHandler(LinkedInNotConnectedException.class)
public ResponseEntity<ApiResponse<Void>> handleLinkedInNotConnected() {
    return ResponseEntity
        .status(HttpStatus.BAD_REQUEST)
        .body(ApiResponse.error(ErrorCode.LINKEDIN_NOT_CONNECTED));
}

The handler chooses the HTTP status. This keeps the exception as a pure domain signal: it has no knowledge of HTTP, and a status code change never requires touching the domain layer.

Centralizing tokens into an enum eliminates magic strings, but it has costs. Every new error touches five files: the enum, the exception class, the handler, and two TypeScript files on the frontend. That is overhead. For a weekend prototype with three error types, inline strings are fine. The enum pays off when you have forty.

The backend is stable. Let's move across the network boundary to the client.

The frontend has to consume the incoming error string without dropping the contract the backend just established.

We replicate the backend enum as a TypeScript constant file:

// src/constants/errorCodes.ts
export const ErrorCode = {
  LINKEDIN_NOT_CONNECTED: 'LINKEDIN_NOT_CONNECTED',
  TOKEN_EXPIRED: 'TOKEN_EXPIRED',
  INVALID_SCHEDULE_TIME: 'INVALID_SCHEDULE_TIME',
} as const
 
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]

This is the deserialization boundary. The network layer hands us a string; the as const assertion and the derived union type narrow it to a literal type. Now, if a developer writes if (res.errorCode === 'LINKEDIN_NOT_CONNNECTED') with a typo, the compiler rejects the file. The class of bug that slips through test suites and breaks silent interfaces in production is caught by the type-checker, not by a user.

We map these types directly to user presentation in a dedicated message file:

export type ErrorMessage = {
  message: string
  action?: { label: string; href: string }
}
 
export const errorMessages: Record<ErrorCode, ErrorMessage> = {
  LINKEDIN_NOT_CONNECTED: {
    message: 'Connect your LinkedIn account to publish.',
    action: { label: 'Connect LinkedIn', href: '/settings' },
  },
  TOKEN_EXPIRED: {
    message: 'Your LinkedIn session expired. Please reconnect.',
    action: { label: 'Reconnect', href: '/settings' },
  },
}

Notice that the contract carries structured data, not just text. The error carries the call to action the UI needs to render. More importantly, the Record<ErrorCode, ErrorMessage> type is the enforcement checkpoint. Exhaustiveness checking turns a documentation problem into a compiler problem. Add a new error code to the backend registry; the frontend build fails until you supply a matching UI message block.

A fallback message handles the case where the frontend hasn't caught up yet. When the backend ships a new error code that the deployed frontend doesn't know, the UI shows the fallback instead of crashing. Real deploys are not instantaneous, and the contract has to survive the gap.

The system forms a continuous chain. The Java enum, the serialized JSON, the TypeScript union, and the message map lock together. The string LINKEDIN_NOT_CONNECTED exists as a raw literal only inside the two declaration files. Everywhere else, it is a type.

The interface is now the type. And the type is checked by the build.

A contract that only lives in types is only as strong as the people maintaining it. New developers join. Teams scale. Shortcuts become attractive under shipping pressure. Without automated verification, a shared contract eventually degrades into documentation that nobody reads. The contract has to defend itself.

The previous section showed how Record<ErrorCode, ErrorMessage> catches a missing message at compile time. That covers the frontend mapping. Two other tripwires cover the rest of the system.

The backend's defense is the first layer. If a developer changes ErrorCode errorCode back to String errorCode because it requires fewer file modifications under a deadline, the type guarantee collapses silently. ArchUnit prevents this regression.

@Test
void apiResponseErrorCodeFieldMustBeErrorCodeType() {
    fields()
        .that().areDeclaredIn(ApiResponse.class)
        .and().haveName("errorCode")
        .should().haveRawType(ErrorCode.class)
        .check(importedClasses);
}

This rule treats an architectural decision as a unit test. If someone modifies the field type to a raw string, the build fails before code review even starts. The rule lives directly next to the implementation. It runs on every local compile.

ArchUnit guards the field on the backend. The frontend has its own tripwire. To catch the typo we started with (LINKEDIN_NOT_CONNNECTED with three Ns), an ESLint selector stops developers from bypassing the constant file and writing inline strings in their hooks or components.

{
  selector: "BinaryExpression:matches([operator='==='],[operator='!=='])" +
            "[left.type='MemberExpression'][left.property.name='errorCode']" +
            "[right.type='Literal']",
  message: "Use ErrorCode.<NAME> — never compare .errorCode to a raw string literal.",
}

This rule targets a specific pattern: any code comparing something.errorCode to a raw string literal. Remember the typo from the opening paragraph? This selector is the rule that catches it. The class of bug that breaks silent interfaces in production has nowhere left to live. Lint errors don't just block the deployment pipeline; they break local execution. The developer sees the red underline in their editor before they can save the file.

The system closes with a three-layer enforcement triangle. ArchUnit guards the backend field type. ESLint blocks raw string comparisons on the frontend. The Record<ErrorCode, ErrorMessage> check makes the UI message map exhaustive. Together, they protect the three places where architectural boundaries rot.

Most engineering teams have conventions. Few have assertions. Conventions rely on discipline. Discipline degrades under deadlines, team transitions, and feature growth. Build-time assertions don't. You don't protect an architecture with a wiki page. You protect it with a failing build.

The system is not complete. Two categories of gap exist: unhandled domain exceptions and legacy framework throws.

Four exceptions have no @ExceptionHandler mapping and currently produce silent 500 errors with no errorCode field: SocialAccountNotFoundException, VoiceExtractionException, VoiceScoringException, and VoiceDnaException. When these fire, the frontend drops through to the generic fallback string. It is the failure mode the article was written to prevent. The system contains its own anti-pattern.

Four legacy IllegalStateException throws are scattered through GuestGenerationService, SecurityConfig, and our token validation paths. They bypass the typed exception hierarchy because they predate the architecture. Migrating them hasn't been prioritized yet.

The architecture is a target the codebase is moving toward, not a state it has reached. Naming the gaps is how we keep them visible.

The two failure modes from the beginning (the expired OAuth token and the missing account connection) still happen. Real-world infrastructure is messy, and networks break. But when a workspace skips a LinkedIn publish today, the interface no longer throws away the meaning behind a generic gray box. The screen knows exactly what failed, the frontend renders a precise call to action, and the engineer traces the error without guessing.

By defining a continuous contract from the Java throw site through the JSON payload to the React message map, we transform a string into an architecture. The error code is the contract. The contract is the type. And the type is checked by the build. Three audiences get the context they need to act.

You don't write clean code by pretending errors don't exist. You write it by designing them into the interface.