feat: add PlatformCode field to Err for domain-level error identity #1

Closed
opened 2026-03-25 16:20:51 -06:00 by claude · 0 comments
Member

Context

xerrors.Err currently carries a Code (e.g. ErrNotFound, ErrInvalidInput) that maps to transport-layer status codes — HTTP 404, gRPC NOT_FOUND, etc. This is intentional and should not change.

However, as consumer applications grow multi-language frontends, there is a need for a second, independent error identity that operates at the platform/domain layer rather than the transport layer.

The problem

An HTTP 404 can mean many different things depending on context:

  • Employee not found
  • Role not found
  • Branch not found

A transport code (NOT_FOUND → 404) cannot distinguish between these. A frontend consuming the API cannot render a specific, actionable translated message — it can only show a generic fallback.

The same applies to 400s:

  • Email already registered
  • Role name already taken
  • Invalid UUID format

All map to ErrInvalidInput / ErrAlreadyExists at the transport layer, but each needs a distinct user-facing message in the consuming application.

Why not reuse Code

Code is intentionally transport-agnostic in the sense that it drives status code mapping:

  • REST: ErrNotFound → 404
  • gRPC: ErrNotFoundcodes.NotFound

It must remain stable and bounded. Adding domain-specific values to it would pollute the transport mapping and break the clean separation between infrastructure concerns and business concerns.

Proposed solution

Add an optional PlatformCode string field to Err with a WithPlatformCode(code string) *Err builder method.

// Example usage in a service
return xerrors.New(xerrors.ErrNotFound, "employee not found").
    WithPlatformCode("EMPLOYEE_NOT_FOUND")

return xerrors.New(xerrors.ErrAlreadyExists, "email already registered").
    WithPlatformCode("EMAIL_ALREADY_EXISTS")
// Accessing in transport layer
var xe *xerrors.Err
if errors.As(err, &xe) {
    platformCode := xe.PlatformCode() // "EMPLOYEE_NOT_FOUND"
    transportCode := xe.Code()        // xerrors.ErrNotFound
}

Behaviour

  • PlatformCode is optional — not every error needs one. Internal errors, panics, and infrastructure failures do not benefit from platform codes (a 500 is always a generic "try again" regardless of cause).
  • PlatformCode is transport-agnostic — HTTP serializes it as "platformCode" in the JSON body; gRPC passes it in the error detail. The field travels unchanged across transports.
  • PlatformCode does not affect Code resolution or status code mapping in any way.

Expected JSON response shape (HTTP consumer)

{
  "code": "NOT_FOUND",
  "platformCode": "EMPLOYEE_NOT_FOUND",
  "message": "employee not found"
}

The frontend team maintains their own dictionary mapping platformCode → translated user message. The backend stays in English throughout.

Acceptance criteria

  • Err has a platformCode string private field
  • WithPlatformCode(code string) *Err builder returns the same *Err for chaining
  • PlatformCode() string getter returns the value (empty string if not set)
  • Existing Code, Error, WithContext behaviour unchanged
  • Unit tests covering: set + get, chaining with WithContext, empty default
## Context `xerrors.Err` currently carries a `Code` (e.g. `ErrNotFound`, `ErrInvalidInput`) that maps to transport-layer status codes — HTTP 404, gRPC `NOT_FOUND`, etc. This is intentional and should not change. However, as consumer applications grow multi-language frontends, there is a need for a second, independent error identity that operates at the **platform/domain layer** rather than the transport layer. ### The problem An HTTP 404 can mean many different things depending on context: - Employee not found - Role not found - Branch not found A transport code (`NOT_FOUND` → 404) cannot distinguish between these. A frontend consuming the API cannot render a specific, actionable translated message — it can only show a generic fallback. The same applies to 400s: - Email already registered - Role name already taken - Invalid UUID format All map to `ErrInvalidInput` / `ErrAlreadyExists` at the transport layer, but each needs a distinct user-facing message in the consuming application. ### Why not reuse `Code` `Code` is intentionally transport-agnostic in the sense that it drives status code mapping: - REST: `ErrNotFound` → 404 - gRPC: `ErrNotFound` → `codes.NotFound` It must remain stable and bounded. Adding domain-specific values to it would pollute the transport mapping and break the clean separation between infrastructure concerns and business concerns. ### Proposed solution Add an optional `PlatformCode string` field to `Err` with a `WithPlatformCode(code string) *Err` builder method. ```go // Example usage in a service return xerrors.New(xerrors.ErrNotFound, "employee not found"). WithPlatformCode("EMPLOYEE_NOT_FOUND") return xerrors.New(xerrors.ErrAlreadyExists, "email already registered"). WithPlatformCode("EMAIL_ALREADY_EXISTS") ``` ```go // Accessing in transport layer var xe *xerrors.Err if errors.As(err, &xe) { platformCode := xe.PlatformCode() // "EMPLOYEE_NOT_FOUND" transportCode := xe.Code() // xerrors.ErrNotFound } ``` ### Behaviour - `PlatformCode` is **optional** — not every error needs one. Internal errors, panics, and infrastructure failures do not benefit from platform codes (a 500 is always a generic "try again" regardless of cause). - `PlatformCode` is **transport-agnostic** — HTTP serializes it as `"platformCode"` in the JSON body; gRPC passes it in the error detail. The field travels unchanged across transports. - `PlatformCode` does **not** affect `Code` resolution or status code mapping in any way. ### Expected JSON response shape (HTTP consumer) ```json { "code": "NOT_FOUND", "platformCode": "EMPLOYEE_NOT_FOUND", "message": "employee not found" } ``` The frontend team maintains their own dictionary mapping `platformCode` → translated user message. The backend stays in English throughout. ## Acceptance criteria - [ ] `Err` has a `platformCode string` private field - [ ] `WithPlatformCode(code string) *Err` builder returns the same `*Err` for chaining - [ ] `PlatformCode() string` getter returns the value (empty string if not set) - [ ] Existing `Code`, `Error`, `WithContext` behaviour unchanged - [ ] Unit tests covering: set + get, chaining with `WithContext`, empty default
Sign in to join this conversation.