name: dto-domain description: Separate Row, Dto, and Domain types in Leptos+SQLx projects; enforce newtype wrappers, conversion direction, and validation placement. Trigger keywords: DTO, domain, Row, struct, serialize, deserialize, newtype, UserId, validation, FromRow, wire type, From, TryFrom, serde_json::Value, sensitive field, password_hash. Auto-triggers on ANY new database-backed entity; on any FromRow struct being exposed as a server fn return type; on any bare Uuid/i64/String used as an ID field; on any serde_json::Value appearing on a wire boundary.
DTO / Domain Separation
When to Use
- When generating any new database-backed entity (user, product, order, etc.).
- When a
FromRowstruct is being exposed directly as a server fn return type. - When a raw
StringorUuidis used as a field type where a domain newtype belongs. - When adding validation logic to a struct that is also a wire type.
- NOT for read-only projection structs returned only within the same module — those can stay informal until they cross a module boundary.
Core Patterns
1. Three-Layer Architecture
Every entity has exactly three struct shapes. Keep them in separate submodules or files to prevent accidental mixing.
Book anchor — ch05-00 Using Structs to Structure Related Data: Rust structs are the primary unit of data encapsulation. The three-layer pattern here is a direct application of §5: each layer (Row, Dto, Domain) is a distinct struct with its own field set and visibility, not a single shared shape reused across contexts. https://doc.rust-lang.org/book/ch05-00-structs.html
src/
users/
mod.rs ← re-exports Dto + Domain only
row.rs ← Row (SQLx only, private outside module)
dto.rs ← Dto (wire shape, serde)
domain.rs ← Domain value objects + newtypes
repo.rs ← Row ↔ Dto conversions live here
Row — SQLx FromRow, internal only:
// users/row.rs (pub(crate) or pub(super))
#[derive(sqlx::FromRow)]
pub(crate) struct UserRow {
pub id: uuid::Uuid,
pub email: String,
pub password_hash: String, // never in Dto
pub created_at: chrono::DateTime<chrono::Utc>,
}
Dto — wire shape, Serialize + Deserialize + Clone:
// users/dto.rs
use serde::{Deserialize, Serialize};
use crate::users::domain::UserId;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserDto {
pub id: UserId,
pub email: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
// Conversion from Row (in repo.rs, not in dto.rs itself):
// impl From<UserRow> for UserDto { ... }
Domain — invariant-carrying value objects:
// users/domain.rs
use serde::{Deserialize, Serialize};
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct UserId(pub uuid::Uuid);
#[derive(Clone, Debug)]
pub struct EmailAddress(String);
impl TryFrom<String> for EmailAddress {
type Error = crate::error::AppError;
fn try_from(raw: String) -> Result<Self, Self::Error> {
if raw.contains('@') && raw.len() > 3 {
Ok(EmailAddress(raw))
} else {
Err(crate::error::AppError::Validation(
format!("invalid email: {raw}")
))
}
}
}
impl EmailAddress {
pub fn as_str(&self) -> &str { &self.0 }
}
2. Conversion Direction
Conversions always flow in one direction:
Row →(From<Row> for Dto)→ Dto →(TryFrom<Dto> for Domain)→ Domain
std::convert —
From<T>andTryFrom<T>:From<T>is an infallible conversion;TryFrom<T>is the fallible variant that returnsResult. Using them here keeps conversions discoverable, composable, and compatible with?(which desugars toFrom::from). See alsorust-traits-genericsfor the full trait implementation rules.
Never reverse: do not implement From<Domain> for Row for reading. For writes
(INSERT/UPDATE), convert Dto → SQL params inline in the repo function, not via
a Row constructor.
// users/repo.rs — Row → Dto (From, always infallible)
use super::{dto::UserDto, row::UserRow};
impl From<UserRow> for UserDto {
fn from(r: UserRow) -> Self {
UserDto {
id: UserId(r.id),
email: r.email,
created_at: r.created_at,
}
}
}
pub async fn find_by_id(
pool: &sqlx::PgPool,
id: UserId,
) -> Result<Option<UserDto>, crate::error::AppError> {
let row = sqlx::query_as!(UserRow,
"SELECT id, email, password_hash, created_at FROM users WHERE id = $1",
id.0
)
.fetch_optional(pool)
.await?; // sqlx::Error → AppError::Db via #[from]
Ok(row.map(UserDto::from))
}
The second leg of the chain — Dto → Domain — uses TryFrom because domain validation
can fail:
// users/domain.rs — Dto → Domain (TryFrom, fallible)
use crate::users::dto::CreateUserDto;
pub struct ValidatedUser {
pub email: EmailAddress,
pub display_name: String,
}
impl TryFrom<CreateUserDto> for ValidatedUser {
type Error = crate::error::AppError;
fn try_from(dto: CreateUserDto) -> Result<Self, Self::Error> {
let email = EmailAddress::try_from(dto.email)?;
if dto.display_name.trim().is_empty() {
return Err(crate::error::AppError::Validation(
"display_name must not be blank".into(),
));
}
Ok(ValidatedUser { email, display_name: dto.display_name })
}
}
3. Newtype Pattern for Scalars
Every domain ID and constrained scalar gets a newtype. Bare Uuid/i64 in function
signatures hide which domain concept is meant.
// Minimum derive set for a DB-mapped ID newtype:
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash,
Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct OrderId(pub uuid::Uuid);
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash,
Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct UserId(pub uuid::Uuid);
#[sqlx(transparent)] forwards encode/decode to the inner type without a custom codec.
4. Validation with validator Crate
Use #[derive(Validate)] on Dto types for server fn input validation. Validate at the
server fn boundary before touching the DB.
use validator::Validate;
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
#[serde(rename_all = "camelCase")]
pub struct CreateUserDto {
#[validate(email)]
pub email: String,
#[validate(length(min = 8, max = 72))]
pub password: String,
#[validate(length(min = 1, max = 100))]
pub display_name: String,
}
Server fn usage:
#[server]
pub async fn create_user(input: CreateUserDto) -> Result<UserDto, ServerFnError> {
input.validate()
.map_err(|e| ServerFnError::new(e.to_string()))?;
// ... proceed to repo
}
Leptos 0.7 server fns require the error type to be
ServerFnError(or a custom error that implementsInto<ServerFnError>). If you use a projectAppError, addimpl From<AppError> for ServerFnError { ... }— seerust-error-handlingfor the impl pattern.
5. serde Naming Convention
Every wire-crossing DTO must carry #[serde(rename_all = "camelCase")] regardless
of whether the consumer is a JavaScript REST client or a Leptos server fn (CP-1). There
is no Leptos-only carve-out.
// ❌ Missing camelCase on a wire DTO — violates CP-1:
#[derive(Serialize, Deserialize)]
pub struct ProductDto {
pub product_name: String, // serializes as "product_name" — WRONG for wire types
pub unit_price: f64,
}
// ✅ All wire DTOs (server fns, REST, events):
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProductDto {
pub product_name: String, // serializes as "productName"
pub unit_price: f64,
}
Internal structs that never leave the server (pure FromRow rows, advisory structs
scoped to a single module) may omit the attribute — but the moment a struct crosses a
module boundary via a server fn or REST endpoint, camelCase is mandatory.
6. Password / Sensitive Fields
Sensitive fields (password_hash, secret_token) live only in Row. They must
never appear in Dto. Use a marker type if you need to pass hashed passwords within
the repo layer:
pub(crate) struct PasswordHash(pub(crate) String);
Anti-Patterns to Block
Exposing
Rowas server fn return type:#[server] async fn get_user() -> Result<UserRow, _>— couples DB schema to wire API. UseUserDto.pubfields on Domain types with invariants: ifEmailAddress(pub String)is reachable, callers bypassTryFromvalidation. Usepub(crate)or nopub.Raw
Stringfor emails, UUIDs, money: every constrained scalar must be a newtype. BareString emailin a function signature compiles but loses domain meaning.Option<T>for required fields in Dto: represents absence at the wire layer; if a field is required, make it non-optional and let validation reject missing values.From<Domain>forRow: reverse conversion creates tight coupling. Write SQL params inline in repo functions.Validation logic in
Row: Row structs are DB projections, not domain objects. Validation belongs inTryFrom<Dto>for Domain or at the server fn boundary.Putting
password_hashinUserDto: leaks credentials to the client. Audit everyFrom<Row> for Dtoimpl for sensitive fields.
Forbidden Patterns
Forbidden 1 — Single Struct Deriving Both FromRow and Serialize/Deserialize
Forbidden: One struct that simultaneously derives sqlx::FromRow AND
serde::Serialize + serde::Deserialize, used as both a DB projection and a wire type.
Why: Collapses the Row↔Dto boundary. Any column added to the table (even an internal
or sensitive one like password_hash) automatically flows to the wire. Breaking the
two shapes apart later requires a migration of every server fn signature.
Fix: Split into a Row (only FromRow) and a Dto (only Serialize + Deserialize).
Implement From<Row> for Dto in the repo module.
// ❌ Collapsed — sensitive DB columns leak to wire
#[derive(sqlx::FromRow, serde::Serialize, serde::Deserialize)]
pub struct UserRow {
pub id: uuid::Uuid,
pub email: String,
pub password_hash: String, // leaks to JSON response
}
// ✅ Split — Row stays internal, Dto is the wire shape
#[derive(sqlx::FromRow)]
pub(crate) struct UserRow {
pub id: uuid::Uuid,
pub email: String,
pub password_hash: String, // never reaches wire
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserDto {
pub id: uuid::Uuid,
pub email: String,
// password_hash intentionally absent
}
# Detector — flag files where FromRow and Serialize appear on the same struct
for f in $(grep -rln 'FromRow' src/); do
if grep -q 'Serialize' "$f"; then
echo "$f: struct may be merging Row and Dto — verify separation"
fi
done
Forbidden 2 — serde_json::Value on a Wire/Server-Fn Boundary
Forbidden: serde_json::Value appearing as a field type in a Dto, or as a server fn
argument or return type.
Why: Loses compile-time type guarantees. The Leptos server fn codec cannot verify
the shape; client stubs deserialize to an opaque blob; Specta cannot emit typed bindings.
Every Value field is a future maintenance liability and a latent runtime type mismatch.
Fix: Define a concrete struct or enum for the data and derive Serialize + Deserialize.
// ❌ Opaque blob — no compile-time guarantees, Specta cannot emit bindings
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventDto {
pub event_type: String,
pub payload: serde_json::Value, // forbidden on wire boundary
}
// ✅ Typed — Specta emits correct bindings; server fn codec can verify shape
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventDto {
pub event_type: EventType,
pub payload: EventPayload,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EventPayload {
CallStarted { call_id: uuid::Uuid },
CallEnded { call_id: uuid::Uuid, duration_secs: u32 },
}
# Detector (heuristic — review each hit; test-only usage is intentional)
grep -rn 'serde_json::Value' src/ | grep -v '//' | grep -v '_test\.rs\|#\[cfg(test'
Forbidden 3 — pub Inner Field on a Domain Newtype With Invariants
Forbidden: pub struct EmailAddress(pub String) — or any domain value object whose
inner field is pub, bypassing the TryFrom constructor.
Why: Any caller can construct an invalid EmailAddress("not-an-email".to_string())
without going through validation. The invariant is fictional.
Fix: Remove the pub from the inner field; expose only as_str() / as_inner() accessors.
// ❌ Public inner — any caller bypasses TryFrom validation
pub struct EmailAddress(pub String);
// ✅ Private inner — construction goes through TryFrom
pub struct EmailAddress(String);
impl EmailAddress {
pub fn as_str(&self) -> &str { &self.0 }
}
impl TryFrom<String> for EmailAddress {
type Error = crate::error::AppError;
fn try_from(raw: String) -> Result<Self, Self::Error> {
if raw.contains('@') && raw.len() > 3 {
Ok(EmailAddress(raw))
} else {
Err(crate::error::AppError::Validation(format!("invalid email: {raw}")))
}
}
}
# Detector — newtypes with pub inner AND no TryFrom guard comment
grep -rn 'pub struct.*pub ' src/ | grep -v '//' | grep -v 'Row\|Id\|#\[sqlx'
Heuristic — high false-positive rate. Tuple-struct newtypes without domain invariants (e.g.,
pub struct Price(pub i64),pub struct Cents(pub Decimal)) are legitimately public; the exclusion list above does not cover every valid case. Confirm each hit actually has aTryFrom-validated invariant before treating it as a violation. Alternatively search for the struct name in the same file to check whether aTryFromimpl is present.
Forbidden 4 — Sensitive Field Leak Beyond Row
Forbidden: password_hash, secret_token, totp_secret, or any credential field
appearing in a Dto struct or server fn return type.
Why: Serializes credentials into the HTTP response body. Even if the field is not rendered in the UI, it is accessible in the browser's network tab.
Fix: Keep sensitive fields exclusively in the Row struct and the repo's INSERT/UPDATE
SQL param binding. Never include them in From<Row> for Dto.
// ❌ password_hash leaks to wire via Dto
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserDto {
pub id: uuid::Uuid,
pub email: String,
pub password_hash: String, // serializes into HTTP response — credential leak
}
// ✅ Sensitive field stays in Row only; Dto has no credential fields
#[derive(sqlx::FromRow)]
pub(crate) struct UserRow {
pub id: uuid::Uuid,
pub email: String,
pub password_hash: String, // stays in row.rs; never crosses wire boundary
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserDto {
pub id: uuid::Uuid,
pub email: String,
// password_hash intentionally absent
}
# Detector
grep -rn 'password_hash\|totp_secret\|secret_token' src/ \
| grep -v '//' | grep -v 'row\.rs\|repo\.rs\|migration'
Verification Hooks
Hook 1 — Row types not exposed in server fn signatures
grep -rn 'fn .*->.*Row' src/ | grep -v '//' | grep -v 'test'
Fails when: a Row type appears in a public function return type.
Remediation: create a Dto conversion and return that instead.
Hook 2 — Raw Uuid/i64/String IDs in function signatures
grep -rn 'fn .*id.*:.*Uuid\|fn .*id.*:.*i64\|fn .*id.*:.*String' src/ \
| grep -v '//' | grep -v 'test'
Fails when: a bare scalar is used where a newtype ID belongs.
Remediation: introduce the appropriate newtype (UserId, OrderId, etc.).
Hook 3 — password_hash not leaking into Dto
grep -rn 'password_hash' src/ | grep -v '//' | grep -v 'row\.rs\|repo\.rs\|migration'
Fails when: password_hash appears outside row and repo files.
Remediation: remove from Dto; keep only in Row and the repo's INSERT logic.
Hook 4 — Validation fires before DB calls
Manual check — grep-based line-ordering detection is unreliable across multi-line contexts. Instead, read each server fn body that accepts user input and confirm:
input.validate()?(or equivalent) appears before anysqlx::query!,fetch_one,fetch_optional, orexecutecall.- No DB call is reachable if validation returns
Err.
# Find server fns that take a Dto argument — review each manually
grep -rn '#\[server\]' src/ -A3 | grep 'Dto'
Related Skills
rust-traits-generics— full rules for implementingFrom,TryFrom, and generic trait bounds; required reading when the conversion chain becomes non-trivial (multiple generic parameters, associated types, trait objects).rust-ownership-borrowing— governs field visibility and move semantics inside domain newtypes; important when a Domain type wraps a non-Copyinner value.
References
rust-error-handling—AppError::Validationis the error type forTryFromfailures.quality-gates— Step 8 (patterns compliance) checks for Row types in public surfaces.auth-session—AuthUsertrait must be implemented on a Domain type, not a Row.- sqlx
FromRowdocs - validator crate docs
- The Rust Book ch05-00 Using Structs to Structure Related Data
- The Rust Book ch06-00 Enums and Pattern Matching — use
enumfor domain state/status values that replace stringly-typed fields in Dtos - The Rust Book ch10-02 Defining Shared Behavior with Traits
- std::convert::From
- std::convert::TryFrom