Validation boundaries
Where and why input validation happens at each layer of the game service.
Two-layer validation model
The game service validates inputs at two distinct boundaries with different responsibilities:
| Layer | Responsibility | Rejects with |
|---|---|---|
| Transport (gRPC handlers) | Input shape, bounds, and well-formedness | gRPC InvalidArgument status |
| Domain (deciders) | Semantic invariants, state-dependent rules | Domain rejection codes |
These boundaries are intentionally separate. Transport validation protects the domain from malformed inputs. Domain validation enforces business rules that depend on aggregate state.
Transport validation
Transport handlers validate syntactic properties before constructing a domain command:
- Required fields are present and non-empty (after trimming whitespace)
- String lengths and numeric ranges are within protocol-defined bounds
- Enum values are recognized members of their type
- ID formats are structurally valid
- Collection sizes do not exceed transport limits
Transport validation never reads aggregate state. It answers “is this a well-formed request?” without considering whether the request makes semantic sense.
// Transport: validate input shape before building a command.
if strings.TrimSpace(req.GetCampaignId()) == "" {
return status.Error(codes.InvalidArgument, "campaign_id is required")
}
Domain validation
Domain deciders validate semantic properties using current aggregate state:
- The referenced entity exists in the aggregate
- The requested state transition is valid (e.g., session must be active)
- Business invariants hold (e.g., character cannot equip incompatible items)
- Authorization-dependent rules (e.g., only the character owner can transfer)
Domain validation produces rejection codes that are stable, machine-readable identifiers. The transport layer maps these codes to gRPC statuses and user-facing messages.
// Domain: validate semantic invariant using aggregate state.
if _, ok := state.Characters[cmd.CharacterID]; !ok {
return command.Reject(RejectionCharacterNotFound)
}
What belongs where
| Check | Layer | Reason |
|---|---|---|
| “campaign_id is required” | Transport | Syntactic: missing field |
| “name must be <= 200 chars” | Transport | Syntactic: bounds check |
| “campaign does not exist” | Domain | Requires state lookup |
| “session is not active” | Domain | State-dependent invariant |
| “character already equipped” | Domain | Business rule |
| “invalid enum value” | Transport | Syntactic: unrecognized input |
Daggerheart system validation
The Daggerheart game system adds a third validation point within the domain layer. System validators run after transport validation but before the system decider, validating payload structure specific to the game system:
transport validate -> build command -> system validate payload -> system decide
System validators use ValidatePayload[P]() to unmarshal and check system-specific payload fields. This keeps game-system concerns out of the core domain decider while maintaining the transport/domain boundary.
Error type conventions
Each layer uses a different error mechanism matched to its boundary:
| Layer | Error type | When to use |
|---|---|---|
| Transport (input validation) | status.Error(codes.InvalidArgument, msg) | Syntactic validation failures (missing fields, bounds, bad enums) |
| Transport (domain error mapping) | grpcerror.HandleDomainError(err) | Converting domain apperrors to gRPC status |
| Transport (infrastructure) | grpcerror.Internal(msg, err) | Store errors, marshalling failures (logs full error, returns sanitized status) |
| Transport (catch-all) | grpcerror.EnsureStatus(err) | Final boundary guard — ensures every error has a gRPC status |
| Domain (semantic rejection) | command.Reject(Rejection{Code, Message}) | State-dependent invariant violations with stable machine-readable codes |
| Domain (structured error) | apperrors.New(code, msg) | Errors that need machine-readable codes outside the command/rejection path |
| Domain (internal) | fmt.Errorf("context: %w", err) | Error wrapping within domain internals (never crosses transport boundary raw) |
| System validators | fmt.Errorf(msg) / errors.New(msg) | Payload structure validation (caught by deciders, wrapped as rejection) |
Key rule: Every error that reaches the gRPC transport boundary must be a status.Error or pass through grpcerror.EnsureStatus. Plain fmt.Errorf errors that escape to the boundary become Internal status — acceptable for unexpected failures, but intentional validation failures should use the appropriate mechanism.
String normalization convention
Use the normalize package (domain/normalize/) at input boundaries — transport handlers and event fold functions — where raw user or proto input first enters the domain. The package provides String(), ID[T](), and RequireID[T]().
Downstream domain code may use strings.TrimSpace defensively when combining values from multiple sources (e.g., projection builders merging event payloads with stored state). This multi-layer trimming is intentional: normalization at the boundary is the contract, but projection code cannot assume all historical events were written by current-version transports.
Do not add strings.TrimSpace to purely internal domain functions that only receive values already normalized at the boundary.
Anti-patterns
- Domain checks in transport: Do not query aggregate state in handlers. Transport should only validate what it can see in the request message.
- Transport checks in domain: Do not re-validate field presence or bounds in deciders. Trust that transport has already enforced shape constraints.
- Duplicated validation: If both layers check the same condition, one is unnecessary. Determine which layer owns the check and remove the duplicate.