Error Handling¶
EERP treats errors as values, not exceptions. Every function that can fail returns an error as its last return value. The system distinguishes between domain errors (meaningful to callers) and infrastructure errors (meaningful to operators).
Purpose¶
Consistent error handling has two audiences:
- API clients — need actionable information: what went wrong, what to fix
- Operators — need diagnostic information: stack trace, context, cause
These audiences need different things, and the error handling layer ensures neither gets the other's information by accident.
Responsibilities¶
- Classify errors into domain errors and infrastructure errors
- Prevent infrastructure error details from leaking to clients
- Log infrastructure errors with full context
- Map domain errors to HTTP status codes
- Provide a consistent error response format
- Recover from panics in handlers
Error Classification¶
Domain Errors¶
Domain errors represent conditions that are valid from the system's perspective — the request was understood, but the data or state doesn't allow the operation.
// core/modules/crm/internal/crm.go (illustrative)
var (
ErrContactNotFound = errors.New("contact not found")
ErrAlreadyCustomer = errors.New("contact is already a customer")
ErrInvalidStatus = errors.New("invalid status transition")
)
Domain errors: - Are defined as package-level var (or typed errors for extra data) - Are compared with errors.Is() - Map to specific HTTP status codes in the handler layer - Are returned to API clients in the response body
Infrastructure Errors¶
Infrastructure errors represent failures in the system (database down, network timeout, out of memory). They carry no meaning for the API client beyond "something went wrong on our side."
Infrastructure errors: - Come from the ORM, pgx, the OS - Are wrapped with context using fmt.Errorf("doing X: %w", err) - Are logged at Error level with full details - Are returned to API clients only as 500 Internal Server Error with a request ID
ORM Error Conventions¶
The ORM returns typed sentinel errors for common conditions:
import "eerp/core/orm"
contact, err := contacts.FindByID(ctx, id)
if errors.Is(err, orm.ErrNotFound) {
return Contact{}, ErrContactNotFound // translate to domain error
}
if err != nil {
return Contact{}, fmt.Errorf("fetching contact %s: %w", id, err)
}
| ORM Error | Meaning |
|---|---|
orm.ErrNotFound | No row matched the query |
orm.ErrMultipleRows | Expected one row, got more than one |
orm.ErrNoCondition | UPDATE or DELETE attempted without WHERE clause |
Error Wrapping Convention¶
All errors are wrapped with context as they propagate up the call stack:
func (s *Service) ConvertToCustomer(ctx context.Context, id uuid.UUID) (Contact, error) {
contact, err := s.contacts.FindByID(ctx, id)
if err != nil {
return Contact{}, fmt.Errorf("convert to customer: find contact: %w", err)
}
// ...
}
This produces a chain like:
The handler logs the full chain at Error level and returns only the HTTP error to the client.
HTTP Error Response Format¶
All API errors follow a consistent JSON structure:
{
"error": {
"code": "CONTACT_NOT_FOUND",
"message": "The requested contact does not exist.",
"request_id": "01J..."
}
}
| Field | Description |
|---|---|
code | Machine-readable error code (uppercase snake_case) |
message | Human-readable explanation safe to display to the user |
request_id | Trace ID for correlation with server logs |
Infrastructure errors always use code INTERNAL_ERROR and a generic message:
{
"error": {
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred. Please try again later.",
"request_id": "01J..."
}
}
HTTP Status Code Mapping¶
| Domain Error | HTTP Status |
|---|---|
orm.ErrNotFound | 404 Not Found |
| Validation error | 400 Bad Request |
ErrAlreadyExists | 409 Conflict |
ErrForbidden | 403 Forbidden |
ErrUnauthenticated | 401 Unauthorized |
| Infrastructure error | 500 Internal Server Error |
Panic Recovery¶
The recovery middleware wraps every handler. If a handler panics:
- The middleware recovers the panic
- Logs the stack trace at Error level with the request ID
- Returns
500 Internal Server Errorto the client
func RecoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
requestID := RequestIDFromContext(r.Context())
logger.Error("handler panic",
zap.Any("panic", v),
zap.String("request_id", requestID),
zap.Stack("stack"),
)
writeErrorResponse(w, http.StatusInternalServerError, "INTERNAL_ERROR",
"An unexpected error occurred.", requestID)
}
}()
next.ServeHTTP(w, r)
})
}
Logger¶
core/internal/common/logs.go
The global logger is a go.uber.org/zap instance initialized at startup:
debug=true→ development encoder, Debug level, human-readable console outputdebug=false→ production encoder, Info level, JSON output toapp.log
All error logging uses structured fields:
common.Logger.Error("module load failed",
zap.String("module", module.Name),
zap.String("version", module.Version),
zap.Error(err),
)
Error Flow Diagram¶
flowchart TD
PG["PostgreSQL error\n(e.g. connection lost)"] -->|wrapped by| ORM["ORM layer\nfmt.Errorf(...: %w, err)"]
ORM -->|returned to| Service["Service\nfmt.Errorf(context: %w, err)"]
Service -->|returned to| Handler["HTTP Handler"]
Handler -->|errors.Is check| DomainErr{Domain error?}
DomainErr -- Yes --> Map["Map to 4xx status + error code"]
DomainErr -- No --> Log["Log full error chain at Error level"]
Log --> Five00["Return 500 + INTERNAL_ERROR to client"]
Map --> FourXX["Return 4xx + typed error to client"] Extension Points¶
| Extension | How |
|---|---|
| Custom error codes | Define typed errors in your module package; add cases to handler error mapper |
| Structured error data | Use type MyError struct{ Field string } with Error() string |
| Error translation | Middleware that converts ORM errors to domain errors before handlers see them |
| External error tracking | Add a Sentry or Datadog hook to the logger or recovery middleware |