Rust DDD Practice: Attribute Macros Eliminate ID Value Object Boilerplate Code

Introduction: The Pain of ID Value Object Boilerplate Code and the Value of Macros

In the previous article, we explored how to utilize the pattern in Domain-Driven Design (DDD) to implement Value Objects, particularly aggregate roots. We confirmed the core values brought by this pattern: strong type safety, compile-time checks, and zero-cost abstractions.

However, when we implement these features for each type (such as <span>UserId</span>, <span>OrderId</span>, <span>ProductId</span>), we find a lot of repetitive boilerplate code:

  1. Constructor Validation: Implement the <span>new</span> method or <span>TryFrom</span>, enforcing checks on whether the value is valid (e.g., <span>ID > 0</span>).

  2. Basics:: Derive <span>Clone</span>, <span>Debug</span>, <span>PartialEq</span>, <span>Eq</span>, <span>Hash</span>.

  3. Interoperability: Implement <span>serde::Serialize/Deserialize</span> for conversions.

  4. Integration: Implement for frameworks that require <span>DeriveValueType</span> or <span>TryFromU64</span>.

  5. Integration: Implement or as required.

This repetitive work is not only inefficient but also prone to errors. In , the best tool to solve such problems is the procedural macro.

This article will demonstrate how to simplify the definition of Value Objects to the extreme through a custom attribute macro <span>#[aggregate_root_id]</span>.

1. The Value of Macros: From Manual Implementation to Declarative Definition

Our goal is to transform such manual implementations (using <span>OrderId</span> as an example):

// Verbose manual implementation
#[derive(/* a bunch of derives */)]
pub struct OrderId(u64);

impl TryFrom<u64> for OrderId { /* ... validation logic */ }
impl From<OrderId> for u64 { /* ... */ }
// ... and a dozen other impl blocks for SeaORM, Axum, validator, Display, etc.

Into a declarative definition that only requires a few lines of code:

// Zero boilerplate declarative definition
#[aggregate_root_id]
#[serde(transparent)]
pub struct OrderId(u64);

With simple attribute annotations, the macro will automatically generate all necessary implementations and helper methods at compile time.

2. The Implementation Principles and Core Capabilities of the Macro

The macro we use, <span>#[aggregate_root_id]</span> (based on and implementations), is a powerful code generator. It automatically generates the following functionalities for Value Objects:

1. Enforced Safe Construction and Domain Validation

This is the core of Value Objects: they must be valid upon creation.

The macro will automatically generate a safe constructor <span>new</span>, relying on a user-defined or macro-generated <span>AggregateRootIdValidator::is_valid_id()</span> method.

Example: Code generated by the macro for <span>OrderId(u64)</span>

Assuming we have implemented <span>impl AggregateRootIdValidator for u64 { fn is_valid_id(&self) -> bool { *self > 0 } }</span>.

// Macro-generated AggregateRootIdValidator delegate implementation
impl AggregateRootIdValidator for OrderId {
    // Newtype struct delegates to the inner type u64's validation
    fn is_valid_id(&amp;self) -> bool {
        self.0.is_valid_id()
    }
}

// Macro-generated new method
impl OrderId {
    pub fn new(id: u64) -> Result<Self, IdGenError> {
        let result = Self(id); 
        
        // Enforced validation
        if !result.is_valid_id() {
            Err(IdGenError::invalid_id(
                format!("Invalid OrderId value: {:?}", result)
            ))
        } else {
            Ok(result)
        }
    }
}

In this way, any attempt to create a using an invalid value (such as ) will be caught during the <span>new</span> or <span>TryFrom</span> phase.

2. Ecosystem Integration: ORM and Web Frameworks

To allow Value Objects to be seamlessly used in modern applications, the macro implements integration with mainstream ecosystems:

A. SeaORM Database Integration

  • NewType Support: Automatically adds <span>#[derive(sea_orm::DeriveValueType)]</span>, allowing to know how to map to database fields.

  • Safe Deserialization: When the inner type is <span>u64</span>, automatically implements <span>sea_orm::TryFromU64</span>. This ensures that when reading values from the database, they are still validated through our defined <span>new</span> method, ensuring that values retrieved from the database are also valid.

Example: GeneratedMapping Code

// 1. Automatically derive DerivceValueType
#[derive(sea_orm::DeriveValueType)]
pub struct OrderId(u64);

// 2. Automatically implement TryFromU64 (safely create ID from database u64 field)
impl sea_orm::TryFromU64 for OrderId {
    fn try_from_u64(value: u64) -> Result<Self, sea_orm::DbErr> {
        match Self::new(value) {
            Ok(id) => Ok(id),
            Err(e) => Err(sea_orm::DbErr::Custom(
                format!("Invalid OrderId value: {}", e)
            )),
        }
    }
}

B. Axum Request Body Extraction (Macro-Generated)

The macro implements for <span>extract::FromRequest</span>. This means that Value Objects can be used directly as request parameters without manually writing deserialization and validation code.

This macro generates a custom that returns domain validation errors (such as ) as the rejection type of , integrating domain rules into the framework’s error handling process.

Example: Logic Automatically Generated by the Macro

// Macro-generated FromRequest implementation structure for OrderId (concept simplified)
impl<S> axum::extract::FromRequest<S> for OrderId
where
    S: Send + Sync,
{
    // Set custom domain error type as Rejection
    type Rejection = IdGenError; 

    async fn from_request(
        req: axum::extract::Request,
        state: &amp;S
    ) -> Result<Self, Self::Rejection> {
        use axum::body::Bytes;
        use serde_json;

        // 1. Attempt to extract the request body as raw bytes
        let bytes = Bytes::from_request(req, state)
            .await
            .map_err(|_| IdGenError::invalid_id("Failed to read request body".to_string()))?;

        // 2. Attempt to parse the raw u64 value from bytes
        let raw_value: u64 = serde_json::from_slice(&amp;bytes)
            .map_err(|_| IdGenError::invalid_id("Invalid JSON format for ID".to_string()))?;

        // 3. Enforce domain validation (call OrderId::new, the safe constructor entry)
        let value = OrderId::new(raw_value)?; 

        Ok(value)
    }
}

3. Smart Accessors (Getters) and Trait Implementations

The macro generates the following useful and methods by analyzing the fields of the struct:

Functionality Implementation Details Purpose
Basics Automatically derive <span>Clone, Debug, PartialEq, Eq, Hash</span> and <span>serde::Serialize, Deserialize</span>. Meet basic value object characteristics and serialization needs.
Smart Automatically generate <span>id()</span> and <span>id_ref()</span> methods. The macro checks if the inner type is <span>Copy</span>. If it is <span>u64</span>, <span>id()</span> directly returns the value; if it is <span>String</span>, it returns <span>.clone()</span>. Provide convenient and performance-optimized value access, avoiding unnecessary cloning.
<span>TryFrom</span> / <span>From</span> Implement <span>TryFrom<Inner Type></span> (validated through <span>new</span>) and <span>From<ID></span><span><Inner Type></span>. Simplify conversions between and primitive types.
<span>std::fmt::Display</span> Implement <span>Display</span>, allowing direct use of <span>{}</span> formatting, especially convenient for logging output. Improve readability and usability.

Example: Smart and Implementations

// Macro-generated for OrderId(u64):

// Smart Getter (u64 is Copy type, directly returns value)
impl OrderId {
    pub fn id(&amp;self) -> u64 {
        self.0
    }
    pub fn id_ref(&amp;self) -> &amp;u64 {
        &amp;self.0
    }
}

// TryFrom (for safely creating OrderId from u64)
impl TryFrom<u64> for OrderId {
    type Error = IdGenError;

    fn try_from(value: u64) -> Result<Self, Self::Error> {
        Self::new(value) // Delegate to safe new method
    }
}

// From (for converting to primitive type)
impl From<OrderId> for u64 {
    fn from(id: OrderId) -> Self {
        id.0
    }
}

4. Composite ID Support

The macro supports not only single-field but also multi-field composites (e.g., multi-tenant scenarios).

#[aggregate_root_id]
pub struct TenantUserId {
    pub tenant_id: TenantId,
    pub user_id: UserId,
}

For such composite , the macro will automatically:

  1. Joint Validation:<span>TenantUserId::is_valid_id()</span><span> will automatically implement as </span><code><span>self.tenant_id.is_valid_id() && self.user_id.is_valid_id()</span>.

  2. Generate Patterns: Automatically add derives to named structs and embed validation logic.

Conclusion

In practice, procedural macros are a powerful weapon for achieving robustness in domain models. Through attribute macros like <span>#[aggregate_root_id]</span>, we have successfully:

  1. Separated Concerns: Completely separated the definition of domain rules (in <span>is_valid_id()</span>) from the integration with technical frameworks (automatically generated by the macro).

  2. Eliminated Boilerplate: Significantly reduced the repetitive code required for each type.

  3. Increased Reliability: Ensured that regardless of how a Value Object is created (via <span>new</span>, <span>TryFrom</span>, deserialization, or request body), it must undergo domain validation.

This approach allows developers to focus more on core business logic while leaving tedious and error-prone low-level integration work to the compiler.

Leave a Comment