xerr

package module
v3.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 19, 2026 License: MIT Imports: 12 Imported by: 0

README

xerr — Structured, Layer-Aware Error Handling for Go

xerr is a small, zero-dependency Go library for handling errors in a DDD / clean-architecture service. It gives you one error type to use in every layer of your app — domain, application, infrastructure — and it automatically decides what's safe to send back to an HTTP client versus what should only ever appear in your logs.

This guide walks through every feature, step by step, with runnable code. No prior knowledge of the library is assumed.


Why this library exists

In a real backend you get three very different kinds of errors, and they need three very different responses:

  1. Domain errors — "this order was already shipped", "this user was not found". These are expected, well-understood, and the client is supposed to see them.
  2. Application errors — "unauthorized", "too many requests". Also expected, also safe to show.
  3. Infrastructure errors — "the database connection timed out", "the payment gateway is unreachable". These are not safe to show. Leaking a raw database error message to a client is both bad UX and a security smell — but you still very much want the full message in your logs.

Without a library, you end up hand-writing an if somewhere in every handler to decide "is this error safe to show?" — and it's easy to forget, once, and leak something you shouldn't have.

xerr bakes that decision into the error itself, so you can't forget it.


Install

go get github.com/Ali127Dev/xerr/v3

Import it like this:

import "github.com/Ali127Dev/xerr/v3"

The Go package name is still xerr (the /v3 is just part of the module path, required by Go once a library makes a breaking change — see CHANGELOG.md). So in code you still write xerr.New(...), xerr.Code, etc., exactly as shown below.

Coming from v2? See Migrating from v2 to v3.


Step 1: Create your first error

The most basic thing you can do is create an error with a Code:

err := xerr.New(xerr.CodeNotFound)

That's it — err is now a *xerr.Error, which implements Go's standard error interface, so you can return it, wrap it, log it, and compare it just like any other error.


Step 2: Understand Code

A Code is a short, machine-readable, all-caps string identifying what kind of problem happened — e.g. "RESOURCE_NOT_FOUND", "VALIDATION_FAILED", "DATABASE_ERROR". The client's frontend can safely branch on these strings (if (error.code === "RESOURCE_NOT_FOUND")) without ever having to parse a human sentence.

xerr ships a set of common codes ready to use — see the full table below. You can also define your own:

const CodeCouponExpired xerr.Code = "COUPON_EXPIRED"

A bare constant like that isn't enough on its own — xerr has no idea what Kind or HTTP status it should carry, so until you register it, CodeCouponExpired.Kind() reports KindUnknown and .HTTPStatus() reports 500. Teach xerr about it with RegisterCode, typically from an init() so it happens once, before your server starts accepting traffic:

func init() {
    xerr.RegisterCode(CodeCouponExpired, xerr.KindDomain, http.StatusConflict)
}

RegisterCode panics if the code is already registered — whether that's one of xerr's own built-ins or a code your application registered earlier — so a typo that collides with an existing code fails loudly at startup instead of silently changing that code's behavior. It also panics on malformed input: an empty code, a Kind outside the four defined constants, or an httpStatus outside 400-599. xerr itself stays generic and knows nothing about any single application's domain (entity names, plan tiers, feature flags, ...) — every app-specific code, like CodeCouponExpired above, is defined and registered by the application that owns it.

Once registered, a custom code behaves exactly like a built-in one everywhere — Kind(), HTTPStatus(), Exposed(), MarshalJSON, ExposedCodes() (below).

Every Code has two things attached to it automatically:

xerr.CodeNotFound.HTTPStatus() // 404
xerr.CodeNotFound.Kind()       // xerr.KindDomain

HTTPStatus() is what you'd expect — the right HTTP status for that kind of problem. Kind() is explained next, and it's the most important concept in this library.


Step 3: Understand Kind and who gets to see what

Every Code belongs to a Kind:

Kind What it means Example codes Safe for a client to see?
KindDomain A rule about your business entities CodeNotFound, CodeValidationFailed, CodeConflict ✅ Yes
KindApplication A cross-cutting app concern, not tied to one entity CodeUnauthorized, CodeTooManyRequests ✅ Yes
KindInfrastructure An external system failed (DB, network, cache, ...) CodeDatabaseError, CodeTimeout ❌ No
KindUnknown Anything unclassified / unexpected CodeInternalError, CodePanic ❌ No

Kind decides Exposed() — whether the error's message and violations are allowed to reach a client:

domainErr := xerr.New(xerr.CodeNotFound)
domainErr.Kind()    // KindDomain
domainErr.Exposed() // true

infraErr := xerr.New(xerr.CodeDatabaseError)
infraErr.Kind()    // KindInfrastructure
infraErr.Exposed() // false

This matters most when you serialize the error to JSON for an HTTP response — MarshalJSON looks at Exposed() and decides what to include:

safe := xerr.New(xerr.CodeValidationFailed, xerr.WithMessage("email is invalid"))
json.Marshal(safe)
// {"code":"VALIDATION_FAILED","message":"email is invalid"}

unsafe := xerr.New(xerr.CodeDatabaseError, xerr.WithMessage("connection refused"))
json.Marshal(unsafe)
// {"code":"INTERNAL_SERVER_ERROR"}

Look closely at that second example: the message is gone, and the code itself changed to a generic INTERNAL_SERVER_ERROR. This is on purpose — the specific code DATABASE_ERROR is itself information you probably don't want a stranger fingerprinting your stack with. Nothing about what really happened crosses the boundary.

But the full information is never lost — it's just not in the JSON. You still have it in the Go value itself:

unsafe.Code()             // CodeDatabaseError  (the real one)
unsafe.Kind()              // KindInfrastructure
unsafe.Message()           // "connection refused"
unsafe.Error()             // "DATABASE_ERROR (infrastructure): connection refused"

So: log unsafe.Error() (or better, pass unsafe straight to log/slog — see Step 10), and send unsafe (the same Go value!) to json.Marshal for the HTTP response. One value, two different views, decided automatically.


Step 4: Override the default with WithKind / WithExpose

Sometimes the default is wrong for one specific error. Two options let you override it:

// Force a database error to behave like a domain error (rare — be careful):
err := xerr.New(xerr.CodeDatabaseError, xerr.WithKind(xerr.KindDomain))

// Or leave the Kind alone but just force exposure on/off directly:
err := xerr.New(xerr.CodeDatabaseError, xerr.WithExpose(true))  // now safe to expose
err := xerr.New(xerr.CodeValidationFailed, xerr.WithExpose(false)) // now hidden, even though it's a domain error

WithExpose is the more direct and usually the right tool — it overrides exactly the "should this leak" decision, without also changing what Kind() reports (which is useful for filtering/metrics separately from exposure).


Step 5: Three ways to describe an error to a client

You get to choose (per error) how the client should learn what went wrong. None is required, and you can combine them freely.

Option A — Structured Violations (the client builds its own text)

Good when you have a frontend that wants to translate error messages itself, or render them next to specific form fields.

err := xerr.New(xerr.CodeValidationFailed,
    xerr.WithViolation("email", xerr.ErrorReasonInvalidFormat),
    xerr.WithViolation("password", xerr.ErrorReasonTooShort, xerr.P("min", 8)),
)
{
  "code": "VALIDATION_FAILED",
  "violations": [
    { "field": "email", "reason": "invalid_format" },
    { "field": "password", "reason": "too_short", "params": { "min": 8 } }
  ]
}
  • field — which field the problem is about.
  • reason — a fixed, stable string from the ErrorReason enum. Give this list to your frontend team once, they build one translation table (too_short → "must be at least {min} characters" in every supported language), and it never needs to change again, no matter how the English wording evolves.
  • params — the dynamic values a translated message needs, e.g. {"min": 8}. This is the one place that's intentionally not a fixed enum, because it has to carry arbitrary numbers/strings.

If you already have a list of violations built by something else (e.g. a third-party validation library), attach them all at once instead of one by one:

var violations []xerr.Violation
// ... fill violations from your validator ...
err := xerr.New(xerr.CodeValidationFailed, xerr.WithViolations(violations...))

WithViolation and WithViolations can be combined and both can be called more than once — every call appends.

Option B — A direct Message (the backend decides the exact text)

Good for one-off cases where there's no "field", just a sentence — or when you don't have (or don't trust) a frontend translation layer for this specific case.

err := xerr.New(xerr.CodeConflict, xerr.WithMessage("this coupon has already been redeemed"))
{ "code": "CONFLICT", "message": "this coupon has already been redeemed" }
Using both together

Nothing stops you from setting both — e.g. a short message for a toast notification, plus violations for inline field errors:

err := xerr.New(xerr.CodeValidationFailed,
    xerr.WithMessage("please fix the highlighted fields"),
    xerr.WithViolation("email", xerr.ErrorReasonInvalidFormat),
)

Remember: both Message and Violations only reach the client if Exposed() is true (see Step 3). Set them on any error, regardless of Kind — they just won't be serialized if that error turns out to be unsafe to expose.

Option C — Error-level Params (dynamic detail with no natural field)

Violation.Params carries dynamic values for one field's rule ({"min": 8} next to an email violation). Sometimes the dynamic detail isn't about a single field at all — it's about the error as a whole: which resource, what limit was hit. That's what WithParam is for:

err := xerr.New(xerr.CodeInvalidParam,
    xerr.WithMessage("seat limit exceeded"),
    xerr.WithParam("resource", "seats"),
    xerr.WithParam("max", 5),
)
{ "code": "INVALID_PARAMETER", "message": "seat limit exceeded", "params": { "resource": "seats", "max": 5 } }

Same rule as Message and Violations: params is only serialized when Exposed() is true, and .Params() always has the full value server-side regardless.


Step 6: Diagnostics — notes that never leave the server

Diagnostics are for internal debugging notes that must never reach a client, no matter what — not even if the error is otherwise Exposed(). Use them for things like which operation was running, or an internal resource id:

err := xerr.New(xerr.CodeDatabaseError,
    xerr.WithDiagnostic(xerr.DiagnosticOperation, "CreateUser"),
    xerr.WithDiagnostic(xerr.DiagnosticResource, "users"),
)

They show up in err.Error() (for plain-text logs) and in LogValue() (for log/slog), but json.Marshal(err) never includes them, under any circumstance. Built-in keys are DiagnosticOperation, DiagnosticReason, DiagnosticResource — DiagnosticKey is just a string type, so you can define your own too.

Rule of thumb: if it must never leak, it's a Diagnostic. If it's fine to leak when the error is Exposed, it's a Message, a Violation, or a Param.


Step 7: Wrapping an existing error

New starts a fresh error. Wrap does the same thing but also attaches an existing Go error as the cause, which is preserved for logs and for errors.Is / errors.As:

row := db.QueryRow("SELECT ...")
if err := row.Scan(&user); err != nil {
    return xerr.Wrap(err, xerr.CodeRecordNotFound)
}

Wrap(nil, ...) returns nil — handy when you write return xerr.Wrap(err, ...) at the end of a function and err might already be nil.

You can see the wrapped cause with .Err(), and it also shows up automatically at the end of .Error():

err.Err()   // the original *sql.ErrNoRows (or whatever it was)
err.Error() // "RECORD_NOT_FOUND (infrastructure): sql: no rows in result set"

.Err() is never included in the JSON response — same rule as Diagnostics.


Step 8: The full DDD pattern — repository → service → HTTP

This is the pattern the whole library is built around: an infrastructure failure gets wrapped where it happens, then translated into a safe domain error one layer up, while the original stays attached for your logs.

// --- repository layer ---
// A raw database error. Kind defaults to KindInfrastructure (unsafe),
// so even if this accidentally bubbled all the way to an HTTP response
// by itself, nothing about the database would leak.
func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
    var u User
    if err := r.db.First(&u, "id = ?", id).Error; err != nil {
        return nil, xerr.Wrap(err, xerr.CodeRecordNotFound,
            xerr.WithDiagnostic(xerr.DiagnosticOperation, "UserRepo.FindByID"),
        )
    }
    return &u, nil
}

// --- service layer ---
// Translate the infra failure into a well-known, client-safe domain
// error. The original error stays reachable through Unwrap/errors.As.
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
    u, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return nil, xerr.New(xerr.CodeNotFound,
            xerr.WithMessage("user not found"),
            xerr.WithErr(err), // chain preserved
        )
    }
    return u, nil
}

// --- HTTP layer ---
func GetUserHandler(w http.ResponseWriter, r *http.Request) {
    user, err := userService.GetUser(r.Context(), id)
    if err != nil {
        xe, _ := xerr.FromError(err)
        slog.Error("get user failed", "err", xe) // full detail, safely
        w.WriteHeader(xe.HTTPStatus())
        json.NewEncoder(w).Encode(xe) // {"code":"RESOURCE_NOT_FOUND","message":"user not found"}
        return
    }
    // ...
}

What the client sees: {"code":"RESOURCE_NOT_FOUND","message":"user not found"} — clean and safe.

What your logs see (via slog.Error("...", "err", xe)): the full chain, including the original sql: no rows in result set and the UserRepo.FindByID diagnostic — because slog's view of an *xerr.Error is a completely different, unfiltered view from the JSON one. That's covered next.


Step 9: errors.Is, errors.As, and FromError

*xerr.Error works with Go's standard errors package.

errors.Is — compares by Code only (not message, not violations — those are runtime detail that shouldn't matter for identity checks):

if errors.Is(err, xerr.New(xerr.CodeNotFound)) {
    // handle "not found" generically, wherever it came from
}

errors.As / FromError — pull the concrete *xerr.Error back out of an error chain (e.g. after it's been wrapped by fmt.Errorf("...: %w", err) somewhere):

var xe *xerr.Error
if errors.As(err, &xe) {
    fmt.Println(xe.Code(), xe.Kind())
}

// FromError is the same thing, just shorter to write:
xe, ok := xerr.FromError(err)

Unwrap — also works, since xerr.Error implements the standard Unwrap() error method, so errors.Unwrap(err) walks the chain one step at a time just like it would for any wrapped error.


Step 10: Logging with log/slog

*xerr.Error implements slog.LogValuer. Pass it straight to your logger and every field gets logged as structured data — including the fields the client never sees (Kind, Diagnostics, the wrapped cause, and the stack trace if you captured one):

slog.Error("request failed", "err", xerr.Wrap(dbErr, xerr.CodeDatabaseError,
    xerr.WithDiagnostic(xerr.DiagnosticOperation, "CreateUser"),
))

Produces (with the JSON handler):

{
  "msg": "request failed",
  "err": {
    "code": "DATABASE_ERROR",
    "kind": "infrastructure",
    "cause": "dial tcp 10.0.0.5:5432: connection refused",
    "diagnostics": { "operation": "CreateUser" }
  }
}

No manual "code", xe.Code(), "kind", xe.Kind(), ... boilerplate needed at every call site — just log the error value itself.


Step 11: Call-stack capture

WithStack() records the current call stack, so later you can see exactly where an error was created — very useful for infrastructure errors you're trying to debug.

if err != nil {
    return xerr.Wrap(err, xerr.CodeExternalService, xerr.WithStack())
}
fmt.Println(xe.Stack())
// github.com/you/app/internal/payment.(*Client).Charge
//     /home/you/app/internal/payment/client.go:42
// github.com/you/app/internal/service.(*OrderService).Pay
//     /home/you/app/internal/service/order.go:88
// ...

It's opt-in on purpose. Capturing a stack costs a little bit of time, and most errors (a failed validation that happens on every request) don't need one. Reach for it on the errors you'll actually want to debug — typically the KindInfrastructure / KindUnknown ones.

Stack() is log-only: it's never part of the JSON response, and it's deliberately left out of Error()'s one-line output too (so your logs don't get a giant multi-line blob by accident) — call .Stack() explicitly, or just log the error through slog (see Step 10), which includes it automatically whenever one was captured.


Step 12: Recovering from panics

Recover turns a recovered panic into a normal *xerr.Error, with a stack trace captured automatically — because a stack trace is the entire reason you'd want to catch a panic in the first place.

func (s *Service) Handle(ctx context.Context, req Request) (resp Response, err error) {
    defer func() {
        if v := recover(); v != nil {
            err = xerr.Recover(v)
        }
    }()

    // ... code that might panic ...
    return doSomething(req)
}

The resulting error uses CodePanic (KindUnknown — unsafe by default, so a client only ever sees a generic internal-error response, never the panic message). Recover(nil) returns nil, so it's safe to call unconditionally right after recover().


Step 13: DefaultMessage() — a plain-English fallback

Sometimes there's no frontend to build a message from Reason + Params — a CLI tool, a log meant for a human to read directly, a quick prototype. DefaultMessage() gives you a best-effort English sentence:

err := xerr.New(xerr.CodeValidationFailed,
    xerr.WithViolation("email", xerr.ErrorReasonRequired),
    xerr.WithViolation("password", xerr.ErrorReasonTooShort, xerr.P("min", 8)),
)

fmt.Println(err.DefaultMessage())
// "email is required; password must be at least 8 characters"

The rule it follows: use the explicit Message if one was set; otherwise join every Violation's own DefaultMessage(); otherwise fall back to a generic sentence based on Kind (the code itself if the error is safe, or just "something went wrong" if it isn't).

Important: this is not a translation/localization system. There's no language catalog, no locale switching, nothing pluggable — it only ever produces English. Wherever you actually have a frontend, prefer letting it build the message itself from Reason + Params (that's exactly what that enum exists for). Use DefaultMessage() only where that's not an option.


Step 14: HTTP status codes

Every error already knows its HTTP status, derived from its Code:

xerr.New(xerr.CodeNotFound).HTTPStatus()        // 404
xerr.New(xerr.CodeDatabaseError).HTTPStatus()   // 500
xerr.New(xerr.CodeTooManyRequests).HTTPStatus() // 429

See the full table below for every built-in code.


Step 15: Wiring it into an HTTP framework

A minimal example with Gin, as a central error-handling middleware:

func ErrorHandler(c *gin.Context) {
    c.Next()

    if len(c.Errors) == 0 {
        return
    }

    err := c.Errors.Last().Err

    xe, ok := xerr.FromError(err)
    if !ok {
        // some code returned a plain error, not an *xerr.Error — treat
        // it as an unknown, unsafe-to-expose failure
        xe = xerr.New(xerr.CodeInternalError, xerr.WithErr(err))
    }

    slog.Error("request failed", "err", xe)
    c.JSON(xe.HTTPStatus(), xe)
}

The same pattern works with net/http, Echo, Fiber, chi, etc. — the important part is always the same two lines: log the full *xerr.Error, then json.Marshal/serialize that same value for the response body.


Step 16: Swagger / OpenAPI docs

For swaggo/swag-style generators, use the dedicated DTOs — they mirror the exact JSON shape without exposing any internal fields:

// @Failure 400 {object} xerr.SwaggerErrOutput
// @Failure 404 {object} xerr.SwaggerErrOutput
// @Failure 500 {object} xerr.SwaggerErrOutput
type SwaggerErrOutput struct {
    Code       string                   `json:"code" example:"VALIDATION_FAILED"`
    Message    string                   `json:"message,omitempty" example:"invalid request body"`
    Params     map[string]any           `json:"params,omitempty" example:"resource:product"`
    Violations []SwaggerViolationOutput `json:"violations,omitempty"`
}

type SwaggerViolationOutput struct {
    Field  string         `json:"field" example:"email"`
    Reason string         `json:"reason" example:"invalid_format"`
    Params map[string]any `json:"params,omitempty" example:"min:8"`
}
Making code render as an enum

SwaggerErrOutput.Code is a plain string, not a generated enum — xerr only knows its own built-in codes plus whatever your application registered with RegisterCode, so it can't bake a closed set into this struct without also knowing every domain code your application defines. If you want code to render as an OpenAPI enum, build the value list yourself and apply it as a swaggo enums:"..." tag (or an equivalent doc-generation step) on your own copy of the struct:

func exposedCodeNames() []string {
    names := make([]string, 0)
    for code := range xerr.ExposedCodes() { // built-ins + everything you registered
        names = append(names, string(code))
    }
    sort.Strings(names)
    return names
}

ExposedCodes() returns every code whose default Kind is safe to expose — see Step 2 for RegisterCode and the note there about WithExpose overrides not being reflected here.


Reference: built-in codes

Every code below has a default Kind (safe to expose or not) and a default HTTP status, both overridable with WithKind / WithExpose.

System / Internal
Code Kind HTTP Status
CodeInternalError (INTERNAL_SERVER_ERROR) KindUnknown 500
CodeUnknownError (UNKNOWN_ERROR) KindUnknown 500
CodeServiceUnavailable (SERVICE_UNAVAILABLE) KindInfrastructure 503
CodePanic (PANIC) KindUnknown 500
Request
Code Kind HTTP Status
CodeBadRequest (BAD_REQUEST) KindDomain 400
CodeValidationFailed (VALIDATION_FAILED) KindDomain 400
CodeMalformedJSON (MALFORMED_JSON) KindDomain 400
CodeMissingField (MISSING_REQUIRED_FIELD) KindDomain 400
CodeInvalidParam (INVALID_PARAMETER) KindDomain 400
Authentication
Code Kind HTTP Status
CodeUnauthorized (UNAUTHORIZED) KindApplication 401
CodeInvalidCredentials (INVALID_CREDENTIALS) KindApplication 401
CodeInvalidToken (INVALID_TOKEN) KindApplication 401
CodeExpiredToken (TOKEN_EXPIRED) KindApplication 401
CodeRefreshTokenInvalid (INVALID_REFRESH_TOKEN) KindApplication 401
Authorization
Code Kind HTTP Status
CodeForbidden (FORBIDDEN) KindApplication 403
CodePermissionDenied (PERMISSION_DENIED) KindApplication 403
CodeInsufficientScope (INSUFFICIENT_SCOPE) KindApplication 403
Resource
Code Kind HTTP Status
CodeNotFound (RESOURCE_NOT_FOUND) KindDomain 404
CodeAlreadyExists (RESOURCE_ALREADY_EXISTS) KindDomain 409
CodeResourceLocked (RESOURCE_LOCKED) KindDomain 423
CodeResourceDeleted (RESOURCE_DELETED) KindDomain 410
Business logic
Code Kind HTTP Status
CodeConflict (CONFLICT) KindDomain 409
CodeOperationFailed (OPERATION_FAILED) KindDomain 422
CodeInvalidState (INVALID_STATE) KindDomain 409
Rate limit / security
Code Kind HTTP Status
CodeTooManyRequests (TOO_MANY_REQUESTS) KindApplication 429
Storage / database
Code Kind HTTP Status
CodeDatabaseError (DATABASE_ERROR) KindInfrastructure 500
CodeDuplicateKey (DUPLICATE_KEY) KindInfrastructure 409
CodeForeignKeyError (FOREIGN_KEY_CONSTRAINT) KindInfrastructure 409
CodeRecordNotFound (RECORD_NOT_FOUND) KindInfrastructure 404

Notice CodeRecordNotFound (infra, hidden) vs CodeNotFound (domain, shown) — this pair is exactly the repository-vs-domain distinction from Step 8.

External / network
Code Kind HTTP Status
CodeNetworkError (NETWORK_ERROR) KindInfrastructure 502
CodeTimeout (TIMEOUT) KindInfrastructure 504
CodeExternalService (EXTERNAL_SERVICE_ERROR) KindInfrastructure 502

You are not limited to these — define your own Code constants freely (const CodeCouponExpired xerr.Code = "COUPON_EXPIRED") and call RegisterCode to give it a real Kind and HTTP status (see Step 2). An unregistered code defaults to KindUnknown (hidden) and HTTP 500, or you can skip registration and just override per error with WithKind/WithExpose instead.


Reference: built-in violation reasons

Use these with WithViolation(field, reason, params...). params in the table below are the Param keys that DefaultMessage() understands for that reason — your own frontend translation table can use the same keys, or different ones entirely, since this is just a suggestion, not enforced by the type system.

ErrorReason String value Relevant params
ErrorReasonRequired required —
ErrorReasonInvalidFormat invalid_format —
ErrorReasonInvalidValue invalid_value allowed
ErrorReasonTooShort too_short min
ErrorReasonTooLong too_long max
ErrorReasonTooSmall too_small min
ErrorReasonTooLarge too_large max
ErrorReasonMismatch mismatch —
ErrorReasonAlreadyExists already_exists —
ErrorReasonNotFound not_found —
ErrorReasonCorrupted corrupted —
ErrorReasonExpired expired —

Reference: full API

Constructors
Function What it does
New(code Code, opts ...ErrorOption) *Error Create a fresh error. Kind defaults from code.Kind().
Wrap(err error, code Code, opts ...ErrorOption) *Error Same as New, plus attaches err as the cause. Returns nil if err is nil.
Recover(v any, opts ...ErrorOption) *Error Converts a recovered panic value (from recover()) into an error with CodePanic and a captured stack. Returns nil if v is nil.
FromError(err error) (*Error, bool) Finds an *Error anywhere in err's chain. Thin wrapper over errors.As.
Registration
Function What it does
RegisterCode(code Code, kind Kind, httpStatus int) Registers a new Code with its Kind and HTTP status. Panics if code is already registered (built-in or previously registered), or if code/kind/httpStatus is malformed (empty or lower-case code, unknown Kind, status outside 400-599). See Step 2.
RegisteredCodes() map[Code]Kind Every registered code (built-ins and anything from RegisterCode), regardless of whether it's safe to expose.
ExposedCodes() map[Code]Kind Every registered code whose default Kind is safe to expose (Kind.Safe()) — built-ins and anything from RegisterCode. Handy for a Swagger enum or a sync test. See Step 16.
Options (pass any combination to New/Wrap/Recover)
Option Effect
WithMessage(string) Sets a ready-to-display message. Sent to client only if Exposed().
WithParam(key string, value any) Sets one error-level param (e.g. resource, max). Sent to client only if Exposed(). Repeatable; a later call with the same key overwrites it.
WithViolation(field string, reason ErrorReason, params ...Param) Appends one field violation. Repeatable.
WithViolations(vs ...Violation) Appends a pre-built batch of violations.
WithErr(error) Attaches the wrapped cause. Log-only, never serialized.
WithDiagnostic(key DiagnosticKey, value string) Attaches an internal-only debug note. Log-only, never serialized.
WithKind(Kind) Overrides the code's default Kind.
WithExpose(bool) Overrides whether Message/Violations/Params are sent to a client.
WithStack() Captures the current call stack for .Stack().
Types
Type Shape
Error The error type itself. Implements error, Unwrap() error, Is(error) bool, MarshalJSON, slog.LogValuer.
Code string. Has .String(), .HTTPStatus() int, .Kind() Kind.
Kind string: KindDomain, KindApplication, KindInfrastructure, KindUnknown. Has .String(), .Safe() bool.
Violation struct { Field string; Reason ErrorReason; Params map[string]any }. Has .DefaultMessage() string.
Param struct { Key string; Value any }. Build with P(key, value).
ErrorReason string enum — see table above.
DiagnosticKey string. Built-ins: DiagnosticOperation, DiagnosticReason, DiagnosticResource.
SwaggerErrOutput, SwaggerViolationOutput Plain DTOs for Swagger/OpenAPI doc generators.
*Error methods
Method Returns Notes
Code() Code The real code Always safe to log; MarshalJSON substitutes CodeInternalError when not Exposed().
Kind() Kind The classification Log-only, never in JSON.
Message() string The explicit message, if set Sent to client only if Exposed().
Params() map[string]any A defensive copy of error-level params Sent to client only if Exposed().
Violations() []Violation A defensive copy Sent to client only if Exposed().
Diagnostics() map[DiagnosticKey]string A defensive copy Log-only, never in JSON, even if Exposed().
Err() error The wrapped cause, if any Log-only, never in JSON.
Stack() string Formatted call stack, or "" Log-only, never in JSON. Only set if WithStack()/Recover was used.
Exposed() bool Whether Message/Params/Violations reach the client Defaults to Kind().Safe(); overridden by WithExpose.
HTTPStatus() int HTTP status for Code()
DefaultMessage() string Best-effort English fallback text See Step 13.
Error() string One-line description for plain-text logs Always full detail; excludes Stack().
Unwrap() error The wrapped cause For errors.Unwrap/errors.Is/errors.As.
Is(target error) bool Whether target has the same Code() For errors.Is.
MarshalJSON() ([]byte, error) Client-safe JSON See Step 3.
LogValue() slog.Value Structured log view See Step 10. Unfiltered — includes everything, unlike MarshalJSON.

Migrating from v2 to v3

v3 closes a backdoor v2 left open: CodesKind and CodesHttpStatus used to be exported map[Code]Kind / map[Code]int variables, which meant any caller could write straight into the registry (xerr.CodesKind[x] = y) — no validation, no locking, no duplicate check. RegisterCode (added in v2.1.0) was always the recommended way in; v3 makes it the only way in by unexporting both maps. Everything else about the public API is unchanged.

1. Bump the import path.

-go get github.com/Ali127Dev/xerr/v2
+go get github.com/Ali127Dev/xerr/v3
-import "github.com/Ali127Dev/xerr/v2"
+import "github.com/Ali127Dev/xerr/v3"

Nothing else changes at the call site — xerr.New(...), xerr.Wrap(...), every option and accessor keep their exact signatures.

2. Replace any direct write to CodesKind / CodesHttpStatus with RegisterCode.

If you had application code doing this (undocumented, but technically possible in v2):

-xerr.CodesKind[CodePlanNotIncluded] = xerr.KindDomain
-xerr.CodesHttpStatus[CodePlanNotIncluded] = http.StatusForbidden
+xerr.RegisterCode(CodePlanNotIncluded, xerr.KindDomain, http.StatusForbidden)

If you were already using RegisterCode (the documented v2.1.0 path), nothing changes — just the import path from step 1.

3. If you were reading CodesKind / CodesHttpStatus directly (e.g. to enumerate codes for a test or an OpenAPI enum), switch to the public functions that return the same information as a defensive copy:

-for code := range xerr.CodesKind { ... }
+for code := range xerr.RegisteredCodes() { ... } // every registered code
-for code := range xerr.CodesKind {
-    if xerr.CodesKind[code].Safe() { ... }
-}
+for code := range xerr.ExposedCodes() { ... } // only the safe-to-expose subset

4. RegisterCode got stricter. It was already documented to panic on a duplicate/collision (v2.1.0). v3 additionally panics on malformed input: an empty or lower-case code (codes must match ^[A-Z][A-Z0-9_]*$, the same shape as every built-in), a Kind outside the four defined constants, or an httpStatus outside 400-599. If your registered codes already followed the built-in naming convention (as the examples throughout this README do), nothing changes for you.


Gotcha: typed nil

Wrap(nil, ...) and Recover(nil) return a literal nil of the concrete type *xerr.Error. That's completely fine as long as you keep using that concrete type — but Go has a well-known trap if you assign it to a plain error interface variable:

var err error = xerr.Wrap(nil, xerr.CodeInternalError)
err != nil // true! even though there's no real error

This happens because an error interface value is only truly nil when both its type and its value are nil — and here the type (*xerr.Error) is not nil, only the value inside it is. This is a general Go language behavior, not something specific to xerr — just keep it in mind at the exact point where a *xerr.Error gets assigned to a plain error. In practice, this is rarely an issue: return xerr.Wrap(err, ...) from a function that already declares an error return type has this exact shape, so always check the original err for nil first, the same way you would before calling Wrap at all.


License

MIT

Documentation

Overview

Package xerr provides a structured, layer-aware error handling system for DDD / clean-architecture Go services.

Key features:

  • Strongly typed, machine-readable error codes (Code), extensible with your own application-specific codes via RegisterCode
  • A Kind (domain / application / infrastructure / unknown) that decides, by default, whether an error is safe to expose to a client — so infrastructure failures can be logged in full while only a generic response crosses the client boundary
  • Field-level Violations with a closed, translatable Reason enum plus free-form Params for dynamic detail (e.g. a length bound)
  • An optional, independent human-readable Message for when the backend should own the exact client-facing wording
  • Internal-only Diagnostics for log context that never leaves the server
  • JSON-safe responses (no internal leakage)
  • Error wrapping compatible with errors.Is / errors.As
  • A slog.LogValuer implementation, so passing an *Error straight to log/slog logs every field structured, no boilerplate
  • Opt-in call-stack capture (WithStack) and a Recover helper that turns a recovered panic into an *Error with a stack attached
  • DefaultMessage for a best-effort plain-English fallback where there is no frontend to build one
  • Swagger-friendly error output model

What goes where

Every Error field is always available server-side (for logging, errors.Is/errors.As, metrics); only a subset ever reaches the client, and that subset is chosen automatically by Kind:

field        your logs (Error(), LogValue, accessors)   the client (MarshalJSON)
----------   ------------------------------------------  --------------------------------
Code         always                                      always (real value if Exposed,
                                                          otherwise CodeInternalError)
Kind         always                                      never
Message      always                                      only if Exposed
Params       always                                      only if Exposed
Violations   always                                      only if Exposed
Diagnostics  always                                      never, even if Exposed
Err (cause)  always                                       never
Stack        always, if captured                          never

Exposed defaults to Kind.Safe(): true for KindDomain / KindApplication, false for KindInfrastructure / KindUnknown. Override with WithExpose for the rare exception. See Error's doc comment for the full rationale.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ExposedCodes

func ExposedCodes() map[Code]Kind

ExposedCodes returns every registered Code whose default Kind is safe to expose to a client (Kind.Safe()) — built-in codes and anything added via RegisterCode alike. Use it to build an OpenAPI/Swagger enum for the response "code" field, or in a test asserting your application's public codes stay in sync with what's actually registered.

This reflects each Code's registered default only. An individual *Error can still override that default with WithExpose, so a code appearing here is not a guarantee every instance of it is exposed — only that it is by default.

func RegisterCode

func RegisterCode(code Code, kind Kind, httpStatus int)

RegisterCode registers a new application Code with its Kind and HTTP status, so Code.Kind() / Code.HTTPStatus() — and everything built on them: Exposed(), MarshalJSON, ExposedCodes() — recognize it exactly like a built-in code.

xerr intentionally does not know about any single application's domain codes (e.g. a "PLAN_NOT_INCLUDED" or "USAGE_LIMIT_EXCEEDED" that only makes sense to one service). RegisterCode is how the owning application teaches xerr about them, typically from an init() so registration happens once, before the server accepts traffic:

const CodePlanNotIncluded xerr.Code = "PLAN_NOT_INCLUDED"

func init() {
    xerr.RegisterCode(CodePlanNotIncluded, xerr.KindDomain, http.StatusForbidden)
}

RegisterCode panics if code is already registered — whether it's one of xerr's own built-in codes or one your application registered earlier — since a silent overwrite would change the Kind/status of an existing code out from under whatever already depends on it. Register each code exactly once; RegisterCode is not meant to be called from a hot path.

It also panics on malformed input, so a mistake fails loudly at startup instead of quietly registering a code no error will ever match correctly:

  • code must be non-empty and match ^[A-Z][A-Z0-9_]*$ (upper-case letters, digits, and underscores, starting with a letter) — the same shape as every built-in code;
  • kind must be one of the four defined Kind constants;
  • httpStatus must fall in the 400-599 range (a registered code always represents a client- or server-side failure — there is no legitimate 2xx/3xx error code).

func RegisteredCodes

func RegisteredCodes() map[Code]Kind

RegisteredCodes returns every registered Code (built-in and anything added via RegisterCode) together with its default Kind — regardless of whether that Kind is safe to expose. Use ExposedCodes instead if you only want the subset that's safe to expose by default.

Types

type Code

type Code string

Code represents a machine-readable application error code.

const (
	CodeInternalError      Code = "INTERNAL_SERVER_ERROR"
	CodeUnknownError       Code = "UNKNOWN_ERROR"
	CodeServiceUnavailable Code = "SERVICE_UNAVAILABLE"
	CodePanic              Code = "PANIC"
)
const (
	CodeBadRequest       Code = "BAD_REQUEST"
	CodeValidationFailed Code = "VALIDATION_FAILED"
	CodeMalformedJSON    Code = "MALFORMED_JSON"
	CodeMissingField     Code = "MISSING_REQUIRED_FIELD"
	CodeInvalidParam     Code = "INVALID_PARAMETER"
)
const (
	CodeUnauthorized        Code = "UNAUTHORIZED"
	CodeInvalidCredentials  Code = "INVALID_CREDENTIALS" //nolint:gosec
	CodeInvalidToken        Code = "INVALID_TOKEN"
	CodeExpiredToken        Code = "TOKEN_EXPIRED"
	CodeRefreshTokenInvalid Code = "INVALID_REFRESH_TOKEN"
)
const (
	CodeForbidden         Code = "FORBIDDEN"
	CodePermissionDenied  Code = "PERMISSION_DENIED"
	CodeInsufficientScope Code = "INSUFFICIENT_SCOPE"
)
const (
	CodeNotFound        Code = "RESOURCE_NOT_FOUND"
	CodeAlreadyExists   Code = "RESOURCE_ALREADY_EXISTS"
	CodeResourceLocked  Code = "RESOURCE_LOCKED"
	CodeResourceDeleted Code = "RESOURCE_DELETED"
)
const (
	CodeConflict        Code = "CONFLICT"
	CodeOperationFailed Code = "OPERATION_FAILED"
	CodeInvalidState    Code = "INVALID_STATE"
)
const (
	CodeDatabaseError   Code = "DATABASE_ERROR"
	CodeDuplicateKey    Code = "DUPLICATE_KEY"
	CodeForeignKeyError Code = "FOREIGN_KEY_CONSTRAINT"
	CodeRecordNotFound  Code = "RECORD_NOT_FOUND"
)
const (
	CodeNetworkError    Code = "NETWORK_ERROR"
	CodeTimeout         Code = "TIMEOUT"
	CodeExternalService Code = "EXTERNAL_SERVICE_ERROR"
)
const (
	CodeTooManyRequests Code = "TOO_MANY_REQUESTS"
)

func (Code) HTTPStatus

func (c Code) HTTPStatus() int

func (Code) Kind

func (c Code) Kind() Kind

Kind reports the default Kind for this code, which in turn decides whether an error carrying it is exposed to clients by default. See RegisterCode.

func (Code) String

func (c Code) String() string

type DiagnosticKey

type DiagnosticKey string
const (
	DiagnosticOperation DiagnosticKey = "operation"
	DiagnosticReason    DiagnosticKey = "reason"
	DiagnosticResource  DiagnosticKey = "resource"
)

func (DiagnosticKey) String

func (d DiagnosticKey) String() string

type Error

type Error struct {
	// contains filtered or unexported fields
}

Error represents a structured application error.

An Error always carries its full detail in memory — every field below — so a single value works for both destinations: pass it to your logger directly (via Error(), or by logging Code/Kind/Diagnostics as structured fields) and pass the same value to json.Marshal for the HTTP response. The two destinations see different things on purpose:

field        goes to your logs (Error() / accessors)   goes to the client (MarshalJSON)
----------   -----------------------------------------  -------------------------------
Code         always                                     always — real code if Exposed,
                                                         otherwise CodeInternalError
Kind         always                                     never (not serialized)
Message      always                                     only if Exposed
Params       always                                     only if Exposed
Violations   always                                     only if Exposed
Diagnostics  always                                     never — not even when Exposed
Err (cause)  always (via Error() / Unwrap())             never
Stack        always, if captured (via Stack())           never

Exposed defaults from Kind: KindDomain and KindApplication are safe by default, KindInfrastructure and KindUnknown are not — see Kind.Safe. WithExpose overrides the default per error when a case genuinely needs it. Diagnostics are the one field with no exposure switch at all: they exist specifically for data that must never reach a client (internal identifiers, operation names, raw connection strings, ...), so treat WithDiagnostic as the "this can never leak" bucket and WithMessage / WithViolation as the "this is fine to leak, subject to Exposed" bucket.

func FromError

func FromError(err error) (*Error, bool)

FromError extracts an *Error from err's chain, if present. It is a thin convenience wrapper around errors.As, handy in HTTP middleware that needs to branch on whether an error is already an xerr.Error.

func New

func New(code Code, opts ...ErrorOption) *Error

New creates a new structured xerr.Error. Its Kind defaults to the Code's registered Kind (see RegisterCode) and can be overridden with WithKind.

func Recover

func Recover(v any, opts ...ErrorOption) *Error

Recover converts a recovered panic value into an *Error. Call it directly from a deferred recover:

defer func() {
    if v := recover(); v != nil {
        err = xerr.Recover(v)
    }
}()

The result uses CodePanic (KindUnknown, unsafe to expose — the client only ever sees a generic internal error) and always carries a captured stack (see Stack), since a stack trace is the entire reason to catch a panic in the first place. Returns nil if v is nil (recover() is commonly called unconditionally; a nil v means there was no panic).

func Wrap

func Wrap(err error, code Code, opts ...ErrorOption) *Error

Wrap converts a raw error into an xerr.Error with a given code, preserving err for Unwrap/errors.Is/errors.As and log output. Its Kind defaults to the Code's registered Kind (see RegisterCode) and can be overridden with WithKind. Returns nil if err is nil.

func (*Error) Code

func (e *Error) Code() Code

Code returns the machine-readable error code. Safe to log always; safe to return to a client always (MarshalJSON substitutes CodeInternalError for the real value when the error is not Exposed).

func (*Error) DefaultMessage

func (e *Error) DefaultMessage() string

DefaultMessage returns a best-effort human-readable message: the explicit Message if one was set, otherwise each Violation's DefaultMessage joined together, otherwise a generic fallback based on Kind. Same scope note as Violation.DefaultMessage: a fallback for contexts with no frontend to build their own copy, not a replacement for one that has.

func (*Error) Diagnostics

func (e *Error) Diagnostics() map[DiagnosticKey]string

Diagnostics returns a copy of the internal-only debug context attached to this error (e.g. which operation was running, an internal resource id). Log-only, unconditionally: unlike Message and Violations, diagnostics have no Exposed switch and are never serialized by MarshalJSON no matter what — put here anything that must never reach a client.

func (*Error) Err

func (e *Error) Err() error

Err returns the wrapped underlying cause, if any. Log-only: include it in your logger output (or call Error(), which already does), but never serialize it to a client — it commonly holds raw driver/library errors (SQL, HTTP client, ...) that can leak internal topology.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Exposed

func (e *Error) Exposed() bool

Exposed reports whether this error's Message and Violations are safe to return to a client. It defaults to Kind.Safe() and can be overridden per-error with WithExpose.

func (*Error) HTTPStatus

func (e *Error) HTTPStatus() int

HTTPStatus returns the HTTP status associated with this error's Code.

func (*Error) Is

func (e *Error) Is(target error) bool

Is reports whether target is an *Error with the same Code. Message, Violations, and diagnostics are runtime detail and intentionally excluded, so xerr.New(xerr.CodeNotFound) works as a sentinel for errors.Is regardless of what detail a concrete instance carries.

func (*Error) Kind

func (e *Error) Kind() Kind

Kind returns the error's classification. Log-only: never sent to a client, and not part of MarshalJSON's output.

func (*Error) LogValue

func (e *Error) LogValue() slog.Value

LogValue implements slog.LogValuer. Passing an *Error to log/slog renders every field your logger should see — Code, Kind, Message, Params, Violations, Diagnostics, the wrapped Err, and Stack if captured — as structured attributes, without hand-writing each one at the call site:

slog.Error("request failed", "err", xerr.Wrap(dbErr, xerr.CodeDatabaseError))

This is deliberately the log-only view: unlike MarshalJSON, it never consults Exposed and always includes Kind, Diagnostics, and Err — the exact fields the client boundary hides — because this path is for your logger, never for a client response.

func (*Error) MarshalJSON

func (e *Error) MarshalJSON() ([]byte, error)

MarshalJSON produces the client-safe JSON representation of the error. Only four fields are ever eligible to appear: code, message, params, and violations — Kind, Diagnostics, and the wrapped Err are never serialized, under any circumstance.

When Exposed is false, the response collapses further, to a bare {"code":"INTERNAL_SERVER_ERROR"}: the specific code, message, params, and any wrapped error stay available server-side via Error(), Code(), Params(), and Diagnostics(), but never reach the client.

func (*Error) Message

func (e *Error) Message() string

Message returns the human-readable message, if any. Safe to log always. Only sent to a client when Exposed is true.

func (*Error) Params

func (e *Error) Params() map[string]any

Params returns a copy of the error-level dynamic detail attached via WithParam (e.g. which Resource was involved, or a limit that was exceeded). Safe to log always. Only sent to a client when Exposed is true.

func (*Error) Stack

func (e *Error) Stack() string

Stack returns the call stack captured by WithStack or Recover, if any, formatted one frame per line as "function\n\tfile:line". Returns "" if no stack was captured. Log-only: never part of MarshalJSON's output, and deliberately excluded from Error()'s compact single-line output — call Stack() explicitly (or log the *Error via slog, whose LogValue includes it) when you want it.

func (*Error) Unwrap

func (e *Error) Unwrap() error

func (*Error) Violations

func (e *Error) Violations() []Violation

Violations returns a copy of the field-level violations attached to this error. Safe to log always. Only sent to a client when Exposed is true.

type ErrorOption

type ErrorOption func(*Error)

ErrorOption is a functional constructor modifier.

func WithDiagnostic

func WithDiagnostic(key DiagnosticKey, value string) ErrorOption

WithDiagnostic attaches internal-only debug context (e.g. the operation being performed, or a resource identifier). Diagnostics appear in Error()'s log output but are never exposed via MarshalJSON.

func WithErr

func WithErr(err error) ErrorOption

WithErr attaches the underlying error being wrapped, preserving it for Unwrap/errors.Is/errors.As and for Error()'s log output. It is never exposed via MarshalJSON.

func WithExpose

func WithExpose(expose bool) ErrorOption

WithExpose forces whether Message and Violations are safe to return to a client, overriding the Kind-based default. Use this for the rare exception: an infrastructure error that is actually safe to describe, or a domain error that happens to carry sensitive detail.

func WithKind

func WithKind(k Kind) ErrorOption

WithKind overrides the Kind the error would otherwise inherit from its Code (see Code.Kind and RegisterCode), and with it, the default answer to Exposed.

func WithMessage

func WithMessage(msg string) ErrorOption

WithMessage sets a ready-to-display, human-readable message. Use this when the backend should own the exact wording the client shows. Independent of WithViolation — set either, both, or neither.

func WithParam

func WithParam(key string, value any) ErrorOption

WithParam attaches error-level dynamic detail a client-facing message template needs — analogous to a Violation's Params, but scoped to the whole error rather than one field. Use it for things like which resource was involved or a numeric limit that was exceeded (e.g. WithParam("resource", "product"), WithParam("max", 5)). Sent to the client only when Exposed — exactly like WithMessage and WithViolation. Can be called multiple times; a later call with the same key overwrites the earlier value.

func WithStack

func WithStack() ErrorOption

WithStack captures the current call stack, for later inspection via Stack(). Opt-in and skipped by default: runtime.Callers is cheap but not free, and errors constructed in a hot path (e.g. per-field validation, run once per request) shouldn't pay for it unasked. Reach for it on the errors you'll actually want a trace for — typically KindInfrastructure / KindUnknown ones. Recover always captures a stack, since that is the entire point of catching a panic.

func WithViolation

func WithViolation(field string, reason ErrorReason, params ...Param) ErrorOption

WithViolation adds a field-level violation: a stable, translatable Reason plus optional Params for the dynamic values a message template needs (e.g. P("min", 8)). Use this when the client should build its own localized message. Independent of WithMessage — set either, both, or neither. Can be called multiple times to report several violations.

func WithViolations

func WithViolations(vs ...Violation) ErrorOption

WithViolations appends a batch of already-built violations in one call — handy when a third-party validator (e.g. go-playground/validator) already produced its own list and you're translating it into xerr.Violation instead of building each one by hand with WithViolation. Combines with WithViolation; both are additive.

type ErrorReason

type ErrorReason string

ErrorReason is a stable, translatable identifier for why a single field violated a rule. It intentionally stays a closed enum: the frontend can build a static localization table keyed by these values. Anything that varies per-occurrence (a length bound, an allowed set, ...) belongs in a Violation's Params instead of growing this enum.

const (
	ErrorReasonRequired      ErrorReason = "required"
	ErrorReasonInvalidFormat ErrorReason = "invalid_format"
	ErrorReasonInvalidValue  ErrorReason = "invalid_value" // params: allowed
	ErrorReasonTooShort      ErrorReason = "too_short"     // params: min
	ErrorReasonTooLong       ErrorReason = "too_long"      // params: max
	ErrorReasonTooSmall      ErrorReason = "too_small"     // params: min
	ErrorReasonTooLarge      ErrorReason = "too_large"     // params: max
	ErrorReasonMismatch      ErrorReason = "mismatch"
	ErrorReasonAlreadyExists ErrorReason = "already_exists"
	ErrorReasonNotFound      ErrorReason = "not_found"
	ErrorReasonCorrupted     ErrorReason = "corrupted"
	ErrorReasonExpired       ErrorReason = "expired"
)

func (ErrorReason) String

func (e ErrorReason) String() string

type Kind

type Kind string

Kind classifies where an error originated, which in turn decides whether it is safe to expose to a client by default.

const (
	// KindDomain marks a violation of a core business/domain rule tied to a
	// specific entity or invariant (e.g. "order already shipped", "user not
	// found"). Safe to expose by default.
	KindDomain Kind = "domain"

	// KindApplication marks a cross-cutting application-layer concern that
	// is not about a specific domain entity (auth, authorization, rate
	// limiting, request-contract validation). Safe to expose by default.
	KindApplication Kind = "application"

	// KindInfrastructure marks a failure from an external system the
	// service depends on (database, cache, queue, network, third-party
	// API). Never safe to expose by default: the client should only see
	// that something failed, while full detail stays in server-side logs.
	KindInfrastructure Kind = "infrastructure"

	// KindUnknown marks an unclassified or unexpected error. Treated the
	// same as KindInfrastructure: unsafe to expose by default.
	KindUnknown Kind = "unknown"
)

func (Kind) Safe

func (k Kind) Safe() bool

Safe reports whether errors of this kind are exposed to clients by default. It can always be overridden per-error with WithExpose.

func (Kind) String

func (k Kind) String() string

type Param

type Param struct {
	Key   string
	Value any
}

Param is a single key/value entry attached to a Violation. Build one with P and pass it to WithViolation.

func P

func P(key string, value any) Param

P builds a Violation Param.

type SwaggerErrOutput

type SwaggerErrOutput struct {
	Code       string                   `json:"code" example:"VALIDATION_FAILED"`
	Message    string                   `json:"message,omitempty" example:"invalid request body"`
	Params     map[string]any           `json:"params,omitempty" swaggertype:"object" example:"resource:product"`
	Violations []SwaggerViolationOutput `json:"violations,omitempty"`

} // @name Error

SwaggerErrOutput represents the public JSON structure of xerr.Error.

This is used only for Swagger and documentation. Actual API responses are produced by MarshalJSON of xerr.Error.

Code is typed as a plain string here, not an enum: xerr only knows its own built-in codes plus whatever the owning application registers via RegisterCode, so it cannot bake a closed set into this struct without also knowing every domain code the application defines. If you want "code" to render as an OpenAPI enum, build the value list yourself — combine xerr.ExposedCodes() with your own domain codes — and apply it as a swaggo `enums:"..."` tag (or an equivalent doc-generation step) on your own copy of this struct.

type SwaggerViolationOutput

type SwaggerViolationOutput struct {
	Field  string         `json:"field" example:"email"`
	Reason string         `json:"reason" example:"invalid_format"`
	Params map[string]any `json:"params,omitempty" swaggertype:"object" example:"min:8"`

} // @name ErrorViolation

SwaggerViolationOutput represents the public JSON structure of a xerr.Violation, for Swagger and documentation purposes only.

type Violation

type Violation struct {
	Field  string         `json:"field"`
	Reason ErrorReason    `json:"reason"`
	Params map[string]any `json:"params,omitempty"`
}

Violation describes a single field-level rule violation.

Reason is the stable, translatable identifier the client uses to look up a localized message template (see ErrorReason). Params carries the dynamic values that template needs — e.g. {"min": 8} alongside ErrorReasonTooShort — and is the only place free-form data belongs: Reason itself must stay within the fixed ErrorReason enum so a frontend translation table never goes stale.

func (Violation) DefaultMessage

func (v Violation) DefaultMessage() string

DefaultMessage renders a plain-English fallback sentence for a Violation, substituting Params where its template needs them.

This is not a localization system: there is no catalog, no locale negotiation, nothing pluggable. It exists for the contexts that have no frontend to own translation — a CLI tool, a server log meant for a human, a quick prototype. Wherever there is a real client, prefer letting it build its own copy from Reason + Params (that's what the enum is for); reach for DefaultMessage only as a fallback.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL