You can implement the ValueObject and PrimitiveValue traits for your own domain types. Use the existing types in src/ as reference implementations.
A simple VO wraps one raw primitive. Implement both ValueObject (construction + deconstruction) and PrimitiveValue (typed accessor).
use arvo::errors::ValidationError;
use arvo::traits::{PrimitiveValue, ValueObject};
pub type PercentageInput = f64;
pub struct Percentage(f64);
impl ValueObject for Percentage {
type Input = f64;
type Error = ValidationError;
fn new(value: f64) -> Result<Self, ValidationError> {
if !(0.0..=100.0).contains(&value) {
return Err(ValidationError::OutOfRange {
type_name: "Percentage",
min: "0".into(),
max: "100".into(),
actual: value.to_string(),
});
}
Ok(Self(value))
}
fn into_inner(self) -> f64 { self.0 }
}
impl PrimitiveValue for Percentage {
type Primitive = f64;
fn value(&self) -> &f64 { &self.0 }
}
impl TryFrom<f64> for Percentage {
type Error = ValidationError;
fn try_from(v: f64) -> Result<Self, Self::Error> { Self::new(v) }
}
impl std::fmt::Display for Percentage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}%", self.0)
}
}A composite VO accepts multiple typed inputs. Implement only ValueObject. Expose data through dedicated accessor methods and Display. Provide value() as an inherent method returning the canonical string if useful.
use arvo::errors::ValidationError;
use arvo::traits::ValueObject;
pub struct CoordinateInput {
pub latitude: f64,
pub longitude: f64,
}
pub struct Coordinate {
input: CoordinateInput,
canonical: String,
}
impl ValueObject for Coordinate {
type Input = CoordinateInput;
type Error = ValidationError;
fn new(value: CoordinateInput) -> Result<Self, ValidationError> {
if !(-90.0..=90.0).contains(&value.latitude) {
return Err(ValidationError::invalid("Coordinate.latitude", &value.latitude.to_string()));
}
if !(-180.0..=180.0).contains(&value.longitude) {
return Err(ValidationError::invalid("Coordinate.longitude", &value.longitude.to_string()));
}
let canonical = format!("{}, {}", value.latitude, value.longitude);
Ok(Self { input: value, canonical })
}
fn into_inner(self) -> CoordinateInput { self.input }
}
impl Coordinate {
pub fn value(&self) -> &str { &self.canonical }
pub fn latitude(&self) -> f64 { self.input.latitude }
pub fn longitude(&self) -> f64 { self.input.longitude }
}
impl TryFrom<&str> for Coordinate {
type Error = ValidationError;
fn try_from(s: &str) -> Result<Self, Self::Error> { /* parse canonical */ todo!() }
}
impl std::fmt::Display for Coordinate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.canonical)
}
}-
type Inputtype alias defined and exported -
#[derive(Debug, Clone, PartialEq, Eq, Hash)]on the struct - Serde:
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]+serde(try_from = "T", into = "T")so deserialisation validates vianew()+impl TryFrom<T>delegating tonew()and#[cfg(feature = "serde")] impl From<Type> for T -
impl ValueObjectwithnewandinto_inner - For simple types:
impl PrimitiveValuewithtype Primitiveandvalue() - For composite types: inherent
pub fn value(&self) -> &str(if canonical string exists) -
impl TryFrom<&str>(for string-input types and composite types with reversible canonical format) -
impl Display - Extra accessors for composite types
- Unit tests: valid input, empty/invalid input, normalisation,
try_from,serde_roundtrip,serde_deserialize_validates - Doc comment with
# Exampleblock - Registered in
mod.rsandprelude - Status updated in
ROADMAP.md
arvo does not bundle database integration — this keeps dependencies minimal and lets you use any framework. Integrate using the accessors arvo already provides:
Raw sqlx:
// Bind — extract the primitive
query.bind(email.value())
query.bind(addr.street())
// Read back — construct via new()
let s: String = row.get("email");
let email = EmailAddress::new(s)?;SeaORM / Diesel — composite types as multiple columns:
// Define your own entity with individual columns
impl From<PostalAddress> for AddressModel {
fn from(addr: PostalAddress) -> Self {
let i = addr.into_inner();
AddressModel { street: i.street, city: i.city, zip: i.zip,
country: i.country.into_inner() }
}
}See CONTRIBUTING.md for the full development workflow.