valobj 0.1.2

A Rust library for defining value objects using procedural macros.
Documentation

valobj (Value Objects)

build

Minimal improvements on the newtype pattern.

Goal

Value objects are a common design pattern across programming languages, and Rust's newtype pattern provides a straightforward way to implement them. However, enforcing validity constraints on these objects typically requires significant boilerplate code.

This crate aims to reduce this boilerplate by providing a lightweight macro attribute that extends the newtype pattern with validation and normalization capabilities. The macro automatically enforces domain invariants at construction time, enabling you to guarantee that only valid values can exist within your domain.

use valobj::{value_object, Validate};

#[value_object(Normalize, Validate)]
pub struct Email(String);

impl Normalize<String> for Email {
    fn normalize(value: String) -> String {
        value.trim().to_string()
    }
}

impl Validate<String> for Email {
    fn validate(value: &String) -> Result<(), valobj::Error> {
        if value.contains('@') && value.contains('.') {
            Ok(())
        } else {
            Err(valobj::Error::InvalidValue(
                "Email must contain '@' and '.' characters".to_string(),
            ))
        }
    }
}

fn main() {
    // try_from will normalize the input value and validate it, ensuring that only valid emails can be created
    if let Ok(email) = Email::try_from("USER@example.com".to_string()) {
        assert_eq!(email.as_ref(), "user@example.com");
    }
}

Primitive obsession

Creating new types that wrap primitives helps you avoid Primitive obsession, a code smell where primitive types like String or u64 are used directly to represent domain concepts without meaningful constraints or semantics.

When to use value objects?

If you already use newtype pattern, you can consider using value_object attribute when you need to normalize or validate the inner value, or when you want to define invariants easily (e.g. ensure a UserId is always positive or that a Username is not empty...).

Reference

Construction

Value objects can be constructed with either from or try_from methods, depending on whether validation is enabled or not.

Getter

To maintain consistency, the tuple is immutable and you cannot access the .0 field directly. To get the inner value, a get method is generated, which returns a copy (or a &str in case of string) to the inner value:

#[valobj::value_object]
pub struct UserId(u64);

fn main() {
    let user_id = UserId::from(1);
    let value = user_id.0; // This will not compile
    let value = user_id.get(); // This will work
}

Some other traits are also implemented to allow easy access:

  • AsRef<T> (AsRef<str> in case of a String type)
  • Deref<Target=T>
#[valobj::value_object]
pub struct UserId(u64);

fn main() {
    let user_id = UserId::from(1);
    let value = *user_id;
}

Validation

You can define a validation function that checks if the input value meets some requirements. If the Validate trait is implemented, a TryFrom implementation will be generated, allowing you to create value objects from the inner type while ensuring that the value is valid.

To enable validation, you need to :

  • Add Validate attribute to the macro: #[valobj::value_object(Validate)]
  • Implement the Validate trait for the inner type of your value object.
pub trait Validate<T> {
    fn validate(value: &T) -> std::result::Result<(), Error>;
} 

Normalization

You can define a normalization function that transforms the input value into a canonical form (e.g. trim whitespaces from a string, change it to lowercase...).

To enable normalization, you need to :

  • Add Normalize attribute to the macro: #[valobj::value_object(Normalize)]
  • Implement the Normalize trait for the inner type of your value object.

The normalize method should return the normalized value.

pub trait Normalize<T> {
    fn normalize(value: T) -> T;
}