Elegant Data Mapping in Rust: Leveraging the From Trait
Written on
Chapter 1: Understanding the Importance of Clean Code
In the pursuit of maintainable code, you often find yourself designing components that accept input and generate output. This can involve using models, value objects, or entities, depending on your terminology preference.
For those with a Java background, you've likely encountered mapping functions like mapToDTO, which may leave you questioning their optimal location. Take a look at the following example for clarity:
// rest.rs
struct UserRequest {
username: String,
email: String,
}
struct UserResponse {
username: String,
email: String,
}
fn map_user_request_to_domain_user(user: UserRequest) -> DomainUser {
DomainUser {
username: user.username,
email: user.email,
id: uuid::Uuid::new_v4(),
}
}
pub(crate) fn map_domain_user_to_user_response(user: domain::DomainUser) -> UserResponse {
UserResponse {
username: user.username,
email: user.email,
}
}
pub(crate) fn create_user(user: UserRequest) -> Result<()> {
let domain_user = map_user_request_to_domain_user(user);
let domain_user = domain::upsert_user(domain_user);
let response_user = map_domain_user_to_user_response(domain_user);
send_response(response_user);
Ok(())
}
// domain.rs
struct DomainUser {
username: String,
email: String,
id: uuid::Uuid,
}
pub(crate) fn upsert_user(user: DomainUser) -> DomainUser {
user
}
Some developers propose that mapping functions should reside in the same package as the invoking component, like domain.rs. However, this can lead to circular dependencies, especially when needing to import from the domain in the rest package.
To address this, a common suggestion is to separate all models, structs, and value objects into a distinct package. Personally, I believe that it makes more sense for the structs required by a component to be located within the same package as that component.
Section 1.1: The Ideal Location for Mapping Functions
Thus, the most suitable place for data mapping functions is within the package from which the component originates. This is where Rust's design truly shines. Unlike other languages that might necessitate explicit mapping methods, Rust enables us to utilize the standard From trait. Let’s examine a refactored version of the code:
// user.rs
impl From<UserRequest> for domain::DomainUser {
fn from(user: UserRequest) -> Self {
domain::DomainUser {
username: user.username,
email: user.email,
id: uuid::Uuid::new_v4(),
}
}
}
impl From<domain::DomainUser> for UserResponse {
fn from(user: domain::DomainUser) -> Self {
UserResponse {
username: user.username,
email: user.email,
}
}
}
pub(crate) fn create_user(user: UserRequest) -> Result<()> {
let domain_user = domain::upsert_user(user.into());
send_response(domain_user.into());
Ok(())
}
This method simplifies data management by incorporating the transformation logic directly into the type system, fostering cleaner and more maintainable code.
The first video titled "Understanding Traits in Rust: A Comprehensive Guide" offers a thorough introduction to Rust's traits, helping beginners grasp the concept effectively.
The second video, "Rust Linz, July 2021 - Rainer Stropek - Traits, not your grandparents' interfaces," delves into modern trait usage in Rust, distinguishing them from traditional interfaces.