Stage 7 · Master
Phase 1 — Project Foundation
Error Handling
Define one semantic error vocabulary now, in a shared module, so every future HTTP handler can translate a failure the same way instead of re-inventing the mapping per service.
An Error Needs a Business Meaning, Not Just a Message
err.Error() == "organization not found" and err.Error() == "slug already in use" are both just strings to calling code — a handler cannot safely branch on string comparison without becoming brittle the moment someone edits the message for clarity. What a handler actually needs to know is a small, closed set of categories: is this the caller's fault (not found, conflicting input) or the service's fault (something internal broke)? pkg/apperr exists to carry that category as a typed value, separately from the human-readable message.
pkg/apperr: A Code, a Message, and an Unwrap Chain
// Package apperr defines the semantic error vocabulary shared by every
// service in this platform. It deliberately knows nothing about HTTP —
// mapping a Code to a status code is a Phase 2 concern, handled at the
// HTTP boundary of the service that needs it.
package apperr
type Code string
const (
CodeNotFound Code = "NOT_FOUND"
CodeConflict Code = "CONFLICT"
CodeInvalidInput Code = "INVALID_INPUT"
CodeUnauthorized Code = "UNAUTHORIZED"
CodeForbidden Code = "FORBIDDEN"
CodeInternal Code = "INTERNAL"
)
// Error carries a semantic Code alongside a human-readable Message, an
// optional per-field Details map (used for validation failures), and an
// optional wrapped cause for internal errors that should be logged in full
// but never shown to a caller.
type Error struct {
Code Code
Message string
Details map[string]string
cause error
}
func (e *Error) Error() string {
if e.cause != nil {
return e.Message + ": " + e.cause.Error()
}
return e.Message
}
// Unwrap lets errors.Is and errors.As see through to the wrapped cause,
// so a caller can still detect a specific underlying error (for example, a
// context deadline) without losing the semantic Code at this layer.
func (e *Error) Unwrap() error {
return e.cause
}
func NotFound(message string) *Error {
return &Error{Code: CodeNotFound, Message: message}
}
func Conflict(message string) *Error {
return &Error{Code: CodeConflict, Message: message}
}
func InvalidInput(message string, details map[string]string) *Error {
return &Error{Code: CodeInvalidInput, Message: message, Details: details}
}
// Internal wraps an unexpected cause (a database error, a nil pointer
// recovered from a panic) behind a safe, generic message. The cause is
// preserved for logging via Unwrap but is never part of Error()'s string
// unless the caller explicitly logs the wrapped cause too.
func Internal(cause error, message string) *Error {
return &Error{Code: CodeInternal, Message: message, cause: cause}
}
// As reports whether err is, or wraps, an *Error, mirroring the standard
// library's errors.As so callers do not need to remember the target type.
func As(err error) (*Error, bool) {
var target *Error
ok := errorsAs(err, &target)
return target, ok
}
package apperr
import "errors"
func errorsAs(err error, target **Error) bool {
return errors.As(err, target)
}
Sentinel Checks vs. errors.As for Dynamic Detail
A package-level sentinel like var ErrNotFound = errors.New(...) only works when there is exactly one instance to compare against with errors.Is. apperr.Error carries per-call data — a specific Message, specific Details — so it cannot be a sentinel; every caller that needs to know 'was this a not-found error, and what did it say' uses apperr.As(err) to recover the typed value, not a direct equality check.
// Broken: %v discards the ability to unwrap back to the *apperr.Error.
err := fmt.Errorf("creating organization: %v", apperr.Conflict("slug already in use"))
if _, ok := apperr.As(err); !ok {
// ok is always false here — the Code is unrecoverable.
}
// Correct: %w preserves the chain so Unwrap (and therefore errors.As) works.
err = fmt.Errorf("creating organization: %w", apperr.Conflict("slug already in use"))
if appErr, ok := apperr.As(err); ok {
// appErr.Code == apperr.CodeConflict, exactly as constructed.
}
The only difference between the two blocks is %v versus %w. It is a one-character bug that silently turns every wrapped semantic error into an untyped, unrecoverable one — worth seeing once, deliberately, rather than discovering it while debugging Phase 2's handler layer.
What This Lesson Deliberately Leaves Out
pkg/apperr is not wired into any HTTP response in this phase — there is no HTTP layer yet. Mapping apperr.Code to an HTTP status code and a JSON error envelope is Phase 2's own Error Handling lesson, applied specifically to the organization service's handler layer. Teaching the vocabulary once, here, means that later lesson can focus entirely on the HTTP translation instead of re-explaining what a Code means.
Verifying the Package With Its Own Test, Not the Server
package apperr_test
import (
"errors"
"fmt"
"testing"
"github.com/hoa-platform/backend/pkg/apperr"
)
func TestAs_RecoversCodeThroughWrapping(t *testing.T) {
original := apperr.Conflict("slug already in use")
wrapped := fmt.Errorf("creating organization: %w", original)
got, ok := apperr.As(wrapped)
if !ok {
t.Fatal("expected apperr.As to recover the wrapped *Error")
}
if got.Code != apperr.CodeConflict {
t.Fatalf("got code %q, want %q", got.Code, apperr.CodeConflict)
}
}
func TestAs_FailsWhenWrappedWithPercentV(t *testing.T) {
original := apperr.Conflict("slug already in use")
brokenlyWrapped := errors.New(fmt.Sprintf("creating organization: %v", original))
if _, ok := apperr.As(brokenlyWrapped); ok {
t.Fatal("expected apperr.As to fail once the chain is broken by %v")
}
}
Applied exercise
Add InvalidInput's Details map to the vocabulary's test coverage
The tests above cover Conflict but not InvalidInput's per-field Details map, which Phase 2's Validation lesson depends on.
- Write TestInvalidInput_CarriesFieldDetails constructing apperr.InvalidInput("validation failed", map[string]string{"email": "must be a valid email"}).
- Assert the recovered *Error's Code equals apperr.CodeInvalidInput and its Details["email"] matches exactly.
- Run go test ./... -v from inside pkg/apperr and confirm both the new test and the two existing tests pass.
Deliverable
A third test function added to apperr_test.go and a passing go test -v transcript showing all three tests.
Completion checks
- The new test does not modify apperr.go — it only exercises the existing InvalidInput constructor.
- All three tests report PASS.
Why can't apperr.NotFound be implemented as a single package-level sentinel error like errors.New("not found")?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.