ADR-009: Sentinel Errors with Typed Wrappers
| Status | Date | Deciders |
|---|---|---|
| Accepted | 2024-01-20 | Core Team |
Context
Error handling in Go requires deliberate design. go-mink needs to:
- Identify Error Types: Let callers check for specific errors
- Provide Context: Include relevant details (stream ID, version)
- Support Wrapping: Work with
errors.Isanderrors.As - Be Idiomatic: Follow Go conventions
Common approaches:
- Sentinel errors (
var ErrNotFound = errors.New("not found")) - Typed errors (
type NotFoundError struct{}) - Error codes
- Multiple return values
Decision
We will use Sentinel Errors for error type identification combined with Typed Error Wrappers for additional context.
Sentinel Errors
Define package-level sentinel errors for each error category:
// errors.go
package mink
import "errors"
// Sentinel errors for error type identification
var (
// ErrConcurrencyConflict indicates version mismatch during append
ErrConcurrencyConflict = errors.New("mink: concurrency conflict")
// ErrStreamNotFound indicates the stream does not exist
ErrStreamNotFound = errors.New("mink: stream not found")
// ErrEventNotFound indicates the event does not exist
ErrEventNotFound = errors.New("mink: event not found")
// ErrAggregateNotFound indicates the aggregate does not exist
ErrAggregateNotFound = errors.New("mink: aggregate not found")
// ErrValidation indicates command validation failed
ErrValidation = errors.New("mink: validation error")
// ErrHandlerNotFound indicates no handler registered for command
ErrHandlerNotFound = errors.New("mink: handler not found")
// ErrProjectionFailed indicates projection processing failed
ErrProjectionFailed = errors.New("mink: projection failed")
// ErrSerializationFailed indicates event serialization failed
ErrSerializationFailed = errors.New("mink: serialization failed")
)
Typed Error Wrappers
Provide typed errors with context that wrap sentinels:
// ConcurrencyError provides details about a concurrency conflict
type ConcurrencyError struct {
StreamID string
ExpectedVersion int64
ActualVersion int64
}
func (e *ConcurrencyError) Error() string {
return fmt.Sprintf("mink: concurrency conflict on stream %s: expected version %d, got %d",
e.StreamID, e.ExpectedVersion, e.ActualVersion)
}
// Is implements errors.Is support
func (e *ConcurrencyError) Is(target error) bool {
return target == ErrConcurrencyConflict
}
// ValidationError provides details about validation failures
type ValidationError struct {
Field string
Message string
Value interface{}
}
func (e *ValidationError) Error() string {
if e.Field != "" {
return fmt.Sprintf("mink: validation error on field %s: %s", e.Field, e.Message)
}
return fmt.Sprintf("mink: validation error: %s", e.Message)
}
func (e *ValidationError) Is(target error) bool {
return target == ErrValidation
}
// NotFoundError provides details about missing resources
type NotFoundError struct {
ResourceType string
ResourceID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("mink: %s not found: %s", e.ResourceType, e.ResourceID)
}
func (e *NotFoundError) Is(target error) bool {
switch e.ResourceType {
case "stream":
return target == ErrStreamNotFound
case "event":
return target == ErrEventNotFound
case "aggregate":
return target == ErrAggregateNotFound
default:
return false
}
}
Error Checking
// Using errors.Is for type checking
if errors.Is(err, mink.ErrConcurrencyConflict) {
// Handle retry logic
}
// Using errors.As for accessing details
var concErr *mink.ConcurrencyError
if errors.As(err, &concErr) {
log.Printf("Conflict on stream %s: expected %d, got %d",
concErr.StreamID, concErr.ExpectedVersion, concErr.ActualVersion)
}
// Convenience functions
if mink.IsConcurrencyError(err) {
// Same as errors.Is(err, ErrConcurrencyConflict)
}
Convenience Functions
// Helper functions for common checks
func IsConcurrencyError(err error) bool {
return errors.Is(err, ErrConcurrencyConflict)
}
func IsNotFoundError(err error) bool {
return errors.Is(err, ErrStreamNotFound) ||
errors.Is(err, ErrEventNotFound) ||
errors.Is(err, ErrAggregateNotFound)
}
func IsValidationError(err error) bool {
return errors.Is(err, ErrValidation)
}
func IsRetryable(err error) bool {
return IsConcurrencyError(err)
}
Creating Errors
// Factory functions for creating errors
func NewConcurrencyError(streamID string, expected, actual int64) error {
return &ConcurrencyError{
StreamID: streamID,
ExpectedVersion: expected,
ActualVersion: actual,
}
}
func NewValidationError(field, message string) error {
return &ValidationError{
Field: field,
Message: message,
}
}
func NewStreamNotFoundError(streamID string) error {
return &NotFoundError{
ResourceType: "stream",
ResourceID: streamID,
}
}
Consequences
Positive
- Simple Checks:
errors.Is(err, ErrConcurrencyConflict)is easy - Rich Details: Typed errors provide context
- Idiomatic: Follows Go 1.13+ error handling
- Wrapping Support: Works with error chains
- Discoverability: Sentinels are easy to find in docs
Negative
- Dual System: Both sentinels and types to maintain
- Is() Implementation: Must remember to implement
Is() - Documentation: Need to document both forms
Neutral
- Migration: Old code checking error strings still works via
Error() - Testing: Can test both sentinel and typed error
Error Handling Patterns
In Handlers
func (h *Handler) Handle(ctx context.Context, cmd Command) (CommandResult, error) {
// Validation errors - return typed error
if cmd.CustomerID == "" {
return CommandResult{}, mink.NewValidationError("customerID", "required")
}
// Domain errors - wrap with context
if err := aggregate.DoSomething(); err != nil {
return CommandResult{}, fmt.Errorf("failed to process: %w", err)
}
// Infrastructure errors - wrap and return
if err := store.SaveAggregate(ctx, aggregate); err != nil {
return CommandResult{}, fmt.Errorf("failed to save: %w", err)
}
return mink.NewSuccessResult(aggregate.ID(), aggregate.Version()), nil
}
In API Layer
func handleError(w http.ResponseWriter, err error) {
switch {
case mink.IsConcurrencyError(err):
http.Error(w, "Resource was modified, please retry", http.StatusConflict)
case mink.IsNotFoundError(err):
http.Error(w, "Resource not found", http.StatusNotFound)
case mink.IsValidationError(err):
http.Error(w, err.Error(), http.StatusBadRequest)
default:
log.Printf("Internal error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
Alternatives Considered
Alternative 1: Sentinel Errors Only
Description: Only use var ErrX = errors.New("x").
Rejected because:
- No way to include context (stream ID, version)
- Callers can’t get details without parsing message
Alternative 2: Typed Errors Only
Description: Only use type XError struct{}.
Rejected because:
- Requires
errors.Asfor checking (more verbose) - Less discoverable in documentation
- Harder to remember struct names
Alternative 3: Error Codes
Description: Use numeric or string codes.
Rejected because:
- Not idiomatic Go
- Requires lookup tables
- Less type safety
Alternative 4: Multiple Return Values
Description: Return (result, errType, errDetails).
Rejected because:
- Not idiomatic Go
- Complicates function signatures
- Doesn’t compose well