Domain Vs Transport
When designing error handling in Goa, it’s important to understand the distinction between domain errors and their transport representation. This separation allows you to maintain clean domain logic while ensuring proper error communication across different protocols.
Domain Errors
Domain errors represent business logic failures in your application. They are
protocol-agnostic and focus on what went wrong from a business logic
perspective. Goa’s default ErrorResult type is often sufficient for expressing
domain errors - custom error types are optional and only needed for specialized
cases.
Using Default Error Type
The default ErrorResult type combined with meaningful names, descriptions, and
error properties can effectively express most domain errors:
var _ = Service("payment", func() {
// Define domain errors using default ErrorResult type
Error("insufficient_funds", ErrorResult, func() {
Description("Account has insufficient funds for the transaction")
// Error properties help define error characteristics
Temporary() // Error may resolve if user adds funds
})
Error("card_expired", ErrorResult, func() {
Description("Payment card has expired")
// This is a permanent error until card is updated
})
Error("processing_failed", ErrorResult, func() {
Description("Payment processing system temporarily unavailable")
Temporary() // Can retry later
Fault() // Server-side issue
})
Method("process", func() {
// ... method definition
})
})
Domain errors should:
- Have clear, descriptive names that reflect the business scenario
- Include meaningful descriptions for documentation and debugging
- Use error properties to indicate error characteristics
- Be independent of how they will be transmitted
Custom Error Types (Optional)
For cases where additional structured error data is needed, you can define custom error types. See the
main error handling documentation for detailed information about
custom error types, including important requirements for the name field and the struct:error:name
metadata.
// Custom type for when extra error context is required
var PaymentError = Type("PaymentError", func() {
Description("PaymentError represents a failure in payment processing")
Field(1, "message", String, "Human-readable error message")
Field(2, "code", String, "Internal error code")
Field(3, "transaction_id", String, "Failed transaction ID")
Field(4, "name", String, "Error name for transport mapping", func() {
Meta("struct:error:name")
})
Required("message", "code", "name")
})
Transport Mapping
Transport mappings define how domain errors are represented in specific protocols. This includes status codes, headers, and response formats.
HTTP Transport
var _ = Service("payment", func() {
// Define domain errors
Error("insufficient_funds", PaymentError)
Error("card_expired", PaymentError)
Error("processing_failed", PaymentError)
HTTP(func() {
// Map domain errors to HTTP status codes
Response("insufficient_funds", StatusPaymentRequired, func() {
// Add payment-specific headers
Header("Retry-After")
// Customize error response format
Body(func() {
Attribute("error_code")
Attribute("message")
})
})
Response("card_expired", StatusUnprocessableEntity)
Response("processing_failed", StatusServiceUnavailable)
})
})
gRPC Transport
var _ = Service("payment", func() {
// Same domain errors
Error("insufficient_funds", PaymentError)
Error("card_expired", PaymentError)
Error("processing_failed", PaymentError)
GRPC(func() {
// Map to gRPC status codes
Response("insufficient_funds", CodeFailedPrecondition)
Response("card_expired", CodeInvalidArgument)
Response("processing_failed", CodeUnavailable)
})
})
Benefits of Separation
This separation of concerns provides several advantages:
Protocol Independence
- Domain errors remain focused on business logic
- Same error can be mapped differently for different protocols
- Easy to add new transport protocols
Consistent Error Handling
- Centralized error definitions
- Uniform error handling across services
- Clear mapping between domain and transport errors
Better Documentation
- Domain errors document business rules
- Transport mappings document API behavior
- Clear separation helps API consumers
Implementation Example
Here’s how this separation works in practice:
Using Default ErrorResult
func (s *paymentService) Process(ctx context.Context, p *payment.ProcessPayload) (*payment.ProcessResult, error) {
// Domain logic
if !hasEnoughFunds(p.Amount) {
// Return error using generated helper function
return nil, payment.MakeInsufficientFunds(
fmt.Errorf("account balance %d below required amount %d", balance, p.Amount))
}
if isSystemOverloaded() {
// Return error for temporary system issue
return nil, payment.MakeProcessingFailed(
fmt.Errorf("payment system temporarily unavailable"))
}
// More processing...
}
Using Custom Error Type (When Additional Context Needed)
func (s *paymentService) Process(ctx context.Context, p *payment.ProcessPayload) (*payment.ProcessResult, error) {
// Domain logic
if !hasEnoughFunds(p.Amount) {
// Return domain error with additional context
return nil, &payment.PaymentError{
Name: "insufficient_funds",
Message: "Account balance too low for transaction",
Code: "FUNDS_001",
TransactionID: txID,
}
}
// More processing...
}
The transport layer automatically:
- Maps the domain error to the appropriate status code
- Formats the error response according to the protocol
- Includes any protocol-specific headers or metadata
Best Practices
Domain First
- Design errors based on business requirements
- Use domain terminology in error messages
- Include relevant context for debugging
Consistent Mapping
- Use appropriate status codes for each protocol
- Maintain consistent mappings across services
- Document the mapping rationale
Error Properties
- Use error properties (
Temporary(),Timeout(),Fault()) to indicate error characteristics - Consider implementing similar properties in custom error types
- Document how properties affect client behavior
- Use error properties (
Documentation
- Document both domain meaning and transport behavior
- Include examples of error responses
- Explain retry strategies and client handling
Conclusion
By separating domain errors from their transport representation, Goa enables you to:
- Maintain clean domain logic
- Provide protocol-appropriate error responses
- Support multiple protocols consistently
- Scale your error handling as your API grows