From 5381bccbf7d36fd6d883ac2bbf05476b41cf201a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 25 Mar 2026 16:42:15 -0600 Subject: [PATCH] feat: add WithPlatformCode for domain-level error identity (v0.10.0) (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: add WithPlatformCode for domain-level error identity Adds an optional PlatformCode field to *Err, decoupled from the transport-level Code. Code drives HTTP/gRPC status mapping; PlatformCode is a stable domain identifier for consuming applications (e.g. a frontend performing i18n) to map errors to localised messages. Platform codes are optional — errors without a user-actionable meaning (500s, infrastructure failures, auth rejections) carry none. Fully backwards-compatible: no existing signatures or JSON output changed for errors without a platform code. Co-Authored-By: Claude Sonnet 4.6 Reviewed-on: https://code.nochebuena.dev/go/xerrors/pulls/2 Reviewed-by: Rene Nochebuena Co-authored-by: Claude Code Co-committed-by: Claude Code --- CHANGELOG.md | 16 ++++++++++++ xerrors.go | 54 ++++++++++++++++++++++++++++++---------- xerrors_test.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d1f1f..6013bbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this module will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] - 2026-03-25 + +### Added + +- `(*Err).WithPlatformCode(code string) *Err` — chainable builder method to attach a platform-level error code; returns the receiver for chaining +- `(*Err).PlatformCode() string` — getter that returns the platform code, or an empty string if none was set +- `platformCode` field in `MarshalJSON` output — serialised as `"platformCode"` and omitted when empty (`omitempty`) + +### Design Notes + +- Platform codes operate at the domain/system layer and are intentionally decoupled from the transport-level `Code`. `Code` drives HTTP status mapping and gRPC status codes; `PlatformCode` is a stable semantic identifier for the consuming application (e.g. a frontend performing i18n). +- Platform codes are **optional**. Errors that do not have a user-actionable meaning (500 internal errors, infrastructure failures, authentication rejections) should not carry one. +- No existing behaviour changed — `Code`, `Error`, `Unwrap`, `WithContext`, `MarshalJSON` (for errors without a platform code) are all backwards-compatible. + +[0.10.0]: https://code.nochebuena.dev/go/xerrors/compare/v0.9.0...v0.10.0 + ## [0.9.0] - 2026-03-18 ### Added diff --git a/xerrors.go b/xerrors.go index 195a591..0d872c5 100644 --- a/xerrors.go +++ b/xerrors.go @@ -13,18 +13,22 @@ import ( // interfaces that logz uses internally to enrich log records — without either // package importing the other. // -// Use the builder methods [Err.WithContext] and [Err.WithError] to attach -// additional information after construction: +// Use the builder methods [Err.WithContext], [Err.WithError], and +// [Err.WithPlatformCode] to attach additional information after construction: // // err := xerrors.New(xerrors.ErrInvalidInput, "validation failed"). // WithContext("field", "email"). // WithContext("rule", "required"). // WithError(cause) +// +// err := xerrors.New(xerrors.ErrNotFound, "employee not found"). +// WithPlatformCode("EMPLOYEE_NOT_FOUND") type Err struct { - code Code - message string - err error - fields map[string]any + code Code + message string + err error + fields map[string]any + platformCode string } // New creates an Err with the given code and message. No cause is set. @@ -80,6 +84,27 @@ func (e *Err) WithError(err error) *Err { return e } +// WithPlatformCode sets a platform-level error code and returns the receiver +// for chaining. Platform codes are domain-specific identifiers (e.g. +// "EMPLOYEE_NOT_FOUND") that operate independently of the transport-level +// [Code]. They are intended for consuming applications — such as a frontend — +// that need to map errors to localised user-facing messages without relying on +// the generic transport code. +// +// Platform codes are optional. Errors that do not have a user-actionable +// meaning (e.g. 500 internal errors, infrastructure failures) should not carry +// one; the consuming application renders a generic fallback in those cases. +func (e *Err) WithPlatformCode(code string) *Err { + e.platformCode = code + return e +} + +// PlatformCode returns the platform-level error code, or an empty string if +// none was set. +func (e *Err) PlatformCode() string { + return e.platformCode +} + // Error implements the error interface. // Format: "INVALID_ARGUMENT: username is required → original cause" func (e *Err) Error() string { @@ -151,15 +176,18 @@ func (e *Err) ErrorContext() map[string]any { } // MarshalJSON implements [json.Marshaler]. -// Output: {"code":"NOT_FOUND","message":"user not found","fields":{"id":"42"}} +// Output: {"code":"NOT_FOUND","platformCode":"EMPLOYEE_NOT_FOUND","message":"...","fields":{...}} +// platformCode and fields are omitted when empty. func (e *Err) MarshalJSON() ([]byte, error) { return json.Marshal(struct { - Code string `json:"code"` - Message string `json:"message"` - Fields map[string]any `json:"fields,omitempty"` + Code string `json:"code"` + PlatformCode string `json:"platformCode,omitempty"` + Message string `json:"message"` + Fields map[string]any `json:"fields,omitempty"` }{ - Code: string(e.code), - Message: e.message, - Fields: e.fields, + Code: string(e.code), + PlatformCode: e.platformCode, + Message: e.message, + Fields: e.fields, }) } diff --git a/xerrors_test.go b/xerrors_test.go index 0c77f5e..08c4f42 100644 --- a/xerrors_test.go +++ b/xerrors_test.go @@ -243,6 +243,72 @@ func TestErr_DuckTyping_ErrorContext_Nil(t *testing.T) { _ = err.ErrorContext() } +func TestErr_WithPlatformCode(t *testing.T) { + t.Run("set and get", func(t *testing.T) { + err := New(ErrNotFound, "employee not found"). + WithPlatformCode("EMPLOYEE_NOT_FOUND") + if err.PlatformCode() != "EMPLOYEE_NOT_FOUND" { + t.Errorf("PlatformCode() = %q, want EMPLOYEE_NOT_FOUND", err.PlatformCode()) + } + }) + + t.Run("empty by default", func(t *testing.T) { + err := New(ErrNotFound, "something not found") + if err.PlatformCode() != "" { + t.Errorf("PlatformCode() = %q, want empty string", err.PlatformCode()) + } + }) + + t.Run("chaining with WithContext", func(t *testing.T) { + err := New(ErrAlreadyExists, "email taken"). + WithPlatformCode("EMAIL_ALREADY_EXISTS"). + WithContext("field", "email") + if err.PlatformCode() != "EMAIL_ALREADY_EXISTS" { + t.Errorf("PlatformCode() = %q, want EMAIL_ALREADY_EXISTS", err.PlatformCode()) + } + if err.Fields()["field"] != "email" { + t.Errorf("Fields()[field] = %v, want email", err.Fields()["field"]) + } + }) + + t.Run("chaining preserves transport code", func(t *testing.T) { + err := New(ErrPermissionDenied, "protected"). + WithPlatformCode("ROLE_SYSTEM_PROTECTED") + if err.Code() != ErrPermissionDenied { + t.Errorf("Code() = %s, want %s", err.Code(), ErrPermissionDenied) + } + }) +} + +func TestErr_MarshalJSON_PlatformCode(t *testing.T) { + t.Run("included when set", func(t *testing.T) { + err := New(ErrNotFound, "employee not found"). + WithPlatformCode("EMPLOYEE_NOT_FOUND") + b, jsonErr := json.Marshal(err) + if jsonErr != nil { + t.Fatalf("MarshalJSON error: %v", jsonErr) + } + var out map[string]any + if jsonErr = json.Unmarshal(b, &out); jsonErr != nil { + t.Fatalf("unmarshal error: %v", jsonErr) + } + if out["platformCode"] != "EMPLOYEE_NOT_FOUND" { + t.Errorf("json platformCode = %v, want EMPLOYEE_NOT_FOUND", out["platformCode"]) + } + }) + + t.Run("omitted when not set", func(t *testing.T) { + err := New(ErrInternal, "unexpected error") + b, jsonErr := json.Marshal(err) + if jsonErr != nil { + t.Fatalf("MarshalJSON error: %v", jsonErr) + } + if strings.Contains(string(b), "platformCode") { + t.Errorf("json should omit platformCode when empty, got: %s", b) + } + }) +} + func TestCode_Description(t *testing.T) { tests := []struct { code Code