dto-domain

star 0

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.

adelabdelgawad By adelabdelgawad schedule Updated 5/29/2026

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 FromRow struct is being exposed directly as a server fn return type.
  • When a raw String or Uuid is 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> and TryFrom<T>: From<T> is an infallible conversion; TryFrom<T> is the fallible variant that returns Result. Using them here keeps conversions discoverable, composable, and compatible with ? (which desugars to From::from). See also rust-traits-generics for 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 implements Into<ServerFnError>). If you use a project AppError, add impl From<AppError> for ServerFnError { ... } — see rust-error-handling for 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 Row as server fn return type: #[server] async fn get_user() -> Result<UserRow, _> — couples DB schema to wire API. Use UserDto.

  • pub fields on Domain types with invariants: if EmailAddress(pub String) is reachable, callers bypass TryFrom validation. Use pub(crate) or no pub.

  • Raw String for emails, UUIDs, money: every constrained scalar must be a newtype. Bare String email in 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> for Row: 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 in TryFrom<Dto> for Domain or at the server fn boundary.

  • Putting password_hash in UserDto: leaks credentials to the client. Audit every From<Row> for Dto impl 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 a TryFrom-validated invariant before treating it as a violation. Alternatively search for the struct name in the same file to check whether a TryFrom impl 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:

  1. input.validate()? (or equivalent) appears before any sqlx::query!, fetch_one, fetch_optional, or execute call.
  2. 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 implementing From, 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-Copy inner value.

References

Install via CLI
npx skills add https://github.com/adelabdelgawad/rust-fullstack-agents --skill dto-domain
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
adelabdelgawad
adelabdelgawad Explore all skills →