rust 109 lines · 3 tabs

Bidirectional From Conversions Between Domain and API DTO Types in Rust

Shared by codesnips Aug 2026
3 tabs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmailAddress(String);

impl EmailAddress {
    pub fn parse(raw: &str) -> Result<Self, String> {
        if raw.contains('@') && !raw.starts_with('@') {
            Ok(EmailAddress(raw.to_string()))
        } else {
            Err(format!("invalid email address: {raw}"))
        }
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Role {
    Admin,
    Member,
    Guest,
}

#[derive(Debug, Clone)]
pub struct User {
    pub id: u64,
    pub email: EmailAddress,
    pub display_name: String,
    pub role: Role,
}
3 files · rust Explain with highlit

This snippet shows a common layering technique in Rust web services: keeping the internal domain model separate from the wire-facing DTO, and bridging the two exclusively through the standard From trait. The idea is that the domain type in domain.rsUser with a strongly-typed EmailAddress newtype and a Role enum — should never be serialized directly. Serialization concerns (field naming, string encodings, optional fields) belong to the DTO, so the two can evolve independently without leaking database or business details onto the API surface.

In domain.rs, EmailAddress is a newtype wrapper whose only constructor, parse, enforces an invariant (a present @). This makes an invalid email unrepresentable inside the domain, which is why conversions in one direction can be infallible while the other must be fallible. The Role enum captures the closed set of allowed roles.

In dto.rs, UserDto derives Serialize/Deserialize and uses serde attributes like rename_all = "camelCase" to shape the JSON contract. The outbound direction implements From<User> for UserDto: since a valid User always holds valid data, this conversion cannot fail, so From (not TryFrom) is the right trait. Roles map through a small impl From<Role> for String. The inbound direction is different: raw JSON can contain a malformed email or unknown role, so it implements TryFrom<UserDto> for User with a ConversionError, because parsing untrusted input is inherently fallible. Choosing From versus TryFrom deliberately encodes which conversions can fail.

In handler.rs, an axum handler consumes these conversions. create_user accepts a Json<UserDto>, calls User::try_from to validate and lift into the domain, and returns errors as 400 responses. On the way out it relies on .into() — the free Into impl that Rust derives from From — to convert the domain result back to a UserDto for the JSON response. The payoff is that handlers stay thin and every boundary crossing is a single, type-checked .into() or try_into(), with validation centralized in one place and the compiler guaranteeing no domain type is accidentally exposed.


Related snips

Share this code

Here's the card — post it anywhere.

Bidirectional From Conversions Between Domain and API DTO Types in Rust — share card
Link copied