Use parse-don't-validate for type safety
Context
Without a consistent validation approach, validation logic scatters throughout the codebase:
- Functions repeatedly validate the same inputs
- Validation failures occur deep in business logic rather than at boundaries
- Types don't encode their invariants, requiring defensive checks everywhere
- API signatures using primitives don't communicate requirements
Traditional validation approaches check data wherever it's used, creating maintenance burden and unclear APIs:
fn process_user(email: String) -> Result<()> {
if !is_valid_email(&email) { return Err(...) }
// ...
}
fn send_notification(email: String) -> Result<()> {
if !is_valid_email(&email) { return Err(...) } // Duplicated
// ...
}
Decision
Validate inputs once at system boundaries by parsing into types that encode invariants, then work exclusively with those validated types.
Core Approach
Parse at Boundaries:
Validation happens at system edges (API endpoints, database reads, external services) through parsing that returns Result<ValidatedType, Error>.
Private Fields + Smart Constructors: Types use private fields preventing direct construction, exposing only validated constructors that enforce invariants.
Type-Encoded Guarantees: Once parsed, functions accepting validated types don't need defensive checks. The type system guarantees invariants hold.
Examples from Codebase
Newtype for simple invariants:
// src/domain/accommodation/types.rs:52-78
pub struct PlaceId(String); // Guarantees non-empty, trimmed string
impl PlaceId {
pub fn parse(s: &str) -> Result<Self, AccommodationError> {
let trimmed = s.trim();
if trimmed.is_empty() { return Err(AccommodationError::InvalidPlaceId); }
Ok(PlaceId(trimmed.to_string()))
}
}
Business rule enforcement:
// src/domain/accommodation/types.rs:7-50
pub struct AccommodationDates {
check_in: NaiveDate,
check_out: NaiveDate,
}
impl AccommodationDates {
pub fn new(check_in: NaiveDate, check_out: NaiveDate) -> Result<Self, Error> {
if check_out <= check_in { return Err(Error::InvalidDateRange); }
Ok(Self { check_in, check_out }) // Guarantees check_out > check_in
}
}
Boundary parsing in GraphQL:
// src/domain/accommodation/graphql.rs:75-120
async fn apply_accommodation(...) -> Result<Trip> {
let dates = AccommodationDates::parse(&data.check_in, &data.check_out)
.into_graphql_result()?; // Parse at API boundary
service.add_accommodation(dates, ...) // Work with validated type
}
Additional examples: ValidatedToken, AuthUser, Effect enum with serde validation.
Consequences
Positive
Type Safety: Function signatures communicate requirements. fn send_email(to: Email) vs fn send_email(to: String).
Single Source of Truth: Each type's validation exists in one place (its constructor), eliminating duplication.
Early Error Detection: Validation failures happen at boundaries before flowing into business logic.
Alignment with DDD: Supports ubiquitous language (ADR-0009) by encoding domain rules in types.
Negative
Upfront Design: Requires identifying invariants and designing constructors carefully.
Verbosity: Accessing validated data requires unwrapping (e.g., place_id.as_str() vs direct String).
Learning Curve: Developers unfamiliar with type-driven design may find it initially verbose.
Potential Over-Engineering: Not every string needs a newtype. Apply judiciously at boundaries and for meaningful invariants.