valobj 0.1.1

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

valobj (Value Objects)

build

Minimal improvements on the newtype pattern, which is a common way to create value objects to wrap primitive types in Rust.

Goal

Creating value objects is a common practice in any language, and the newtype pattern is often used for this purpose in Rust. However, you sometimes need to ensure validity of your objects, which can lead to a lot of boilerplate code.

The goal of this crate is just to minimize this boilerplate code. It tries to be as simple as possible, without adding complexity. It provides a simple macro attribute to enhance the newtype pattern. This macro enforces invariants and allows to normalize / validate values at construction time, ensuring that only valid values can be created in your domain.

use valobj::{value_object, Validate};

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

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

impl Validate<String> for ValidEmail {
    fn validate(value: &String) -> Result<(), valobj::Error> {
        if value.contains('@') && value.contains('.') {
            Ok(())
        } else {
            Err(valobj::Error::InvalidValue(
                "ValidEmail 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) = ValidEmail::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;
}