Skip to main content
Entities and Value Objects form the core of your domain model in Ironclad. They encapsulate business rules and ensure data integrity through the smart constructor pattern.

Domain-Driven Design Principles

Ironclad follows DDD principles to prevent invalid state:
Core Principle: Never use primitive types (String, i32) directly for domain concepts. Always wrap them in Value Objects with validation.

Separation of Concerns

Validations are divided into three strict layers:
  1. HTTP Layer (DTOs) - Pure syntactic validation (“string must not be empty”)
  2. Application Layer (Services) - Contextual rules requiring I/O (“email already exists”)
  3. Domain Layer (Value Objects) - Immutable business rules (“username must be alphanumeric”)

Value Objects

Value Objects encapsulate primitive values with domain-specific validation.

Smart Constructor Pattern

The smart constructor pattern ensures only valid Value Objects can exist:
src/domain/value_objects/email_address.rs
use serde::{Deserialize, Serialize};
use crate::errors::DomainError;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailAddress(String);

impl EmailAddress {
    /// Smart Constructor: Validates domain rules
    pub fn new(value: String) -> Result<Self, DomainError> {
        if value.trim().is_empty() || !value.contains('@') {
            return Err(DomainError::Validation(
                "Invalid email format at domain level".into()
            ));
        }
        Ok(Self(value))
    }

    /// Hydration: Used EXCLUSIVELY by persistence layer
    /// Assumes data is already valid from database
    pub fn from_trusted(value: String) -> Self {
        Self(value)
    }

    /// Extract primitive value for DTOs or database
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

Key Methods

new
fn(String) -> Result<Self, DomainError>
The smart constructor that validates input and returns Result. This is the ONLY way to create a Value Object from untrusted input.
from_trusted
fn(String) -> Self
Used EXCLUSIVELY by the repository layer when hydrating from database. Skips validation because database data is assumed valid.
as_str
fn(&self) -> &str
Extracts the inner primitive value for use in queries, DTOs, or serialization.

Value Object with Complex Validation

src/domain/value_objects/username.rs
use serde::{Deserialize, Serialize};
use crate::errors::DomainError;
use crate::shared::validator::validate_username;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Username(String);

impl Username {
    /// Smart Constructor: Fails if domain rules aren't met
    pub fn new(value: String) -> Result<Self, DomainError> {
        validate_username(&value)
            .map_err(|_| DomainError::Validation("Invalid username format".into()))?;
        Ok(Self(value))
    }

    /// Hydration from trusted source (database)
    pub fn from_trusted(value: String) -> Self {
        Self(value)
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}
The validate_username function can contain complex logic:
  • Alphanumeric characters only
  • Length constraints
  • Reserved word checks
  • Character restrictions

Enum Value Objects

Value Objects can also be enums for constrained sets:
src/domain/value_objects/role.rs
use serde::{Deserialize, Serialize};
use std::fmt;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum Role {
    Admin,
    User,
    Moderator,
    Premium,
}

impl Role {
    pub fn as_str(&self) -> &str {
        match self {
            Role::Admin => "admin",
            Role::User => "user",
            Role::Moderator => "moderator",
            Role::Premium => "premium",
        }
    }

    /// Parse role from string - returns Option for safety
    pub fn from_str(s: &str) -> Option<Self> {
        match s.to_lowercase().as_str() {
            "admin" => Some(Role::Admin),
            "user" => Some(Role::User),
            "moderator" => Some(Role::Moderator),
            "premium" => Some(Role::Premium),
            _ => None,
        }
    }

    pub fn is_admin(&self) -> bool {
        matches!(self, Role::Admin)
    }

    pub fn can_moderate(&self) -> bool {
        matches!(self, Role::Admin | Role::Moderator)
    }
}

impl Default for Role {
    fn default() -> Self {
        Role::User
    }
}

impl fmt::Display for Role {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

Entities

Entities are domain objects with identity that compose Value Objects.

Entity with Smart Constructor

src/domain/entities/user.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::domain::value_objects::{Role, Username, EmailAddress};
use crate::errors::DomainError;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
    pub id: String,
    pub email: EmailAddress,
    pub username: Username,
    pub password_hash: String,
    pub role: Role,
    pub is_active: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

impl User {
    /// Smart Constructor: Returns Result guaranteeing valid state
    pub fn new(
        email: EmailAddress,
        username: Username,
        password_hash: String
    ) -> Result<Self, DomainError> {
        let now = Utc::now();
        Ok(Self {
            id: Uuid::new_v4().to_string(),
            email,
            username,
            password_hash,
            role: Role::default(),
            is_active: true,
            created_at: now,
            updated_at: now,
        })
    }

    /// Smart Constructor with specific role
    pub fn new_with_role(
        email: EmailAddress,
        username: Username,
        password_hash: String,
        role: Role,
    ) -> Result<Self, DomainError> {
        let now = Utc::now();
        Ok(Self {
            id: Uuid::new_v4().to_string(),
            email,
            username,
            password_hash,
            role,
            is_active: true,
            created_at: now,
            updated_at: now,
        })
    }
}
Notice that entity constructors accept Value Objects, not primitives. This guarantees the entity is composed of already-validated components.

Entity Business Logic

Entities contain behavior related to their state:
src/domain/entities/user.rs
impl User {
    // Query methods
    pub fn is_admin(&self) -> bool {
        self.role.is_admin()
    }

    pub fn can_moderate(&self) -> bool {
        self.role.can_moderate()
    }

    pub fn is_active(&self) -> bool {
        self.is_active
    }

    // State mutation methods
    pub fn update_email(&mut self, email: EmailAddress) {
        self.email = email;
        self.updated_at = Utc::now();
    }

    pub fn update_username(&mut self, username: Username) {
        self.username = username;
        self.updated_at = Utc::now();
    }

    pub fn change_role(&mut self, role: Role) {
        self.role = role;
        self.updated_at = Utc::now();
    }

    pub fn activate(&mut self) {
        self.is_active = true;
        self.updated_at = Utc::now();
    }

    pub fn deactivate(&mut self) {
        self.is_active = false;
        self.updated_at = Utc::now();
    }
}
Mutation methods should accept Value Objects, not primitives, to maintain validation guarantees.

Simpler Entity Example

src/domain/entities/test_item.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestItem {
    pub id: String,
    pub subject: String,
    pub optional_field: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

impl TestItem {
    pub fn new(subject: String, optional_field: Option<String>) -> Self {
        let now = Utc::now();
        Self {
            id: Uuid::new_v4().to_string(),
            subject,
            optional_field,
            created_at: now,
            updated_at: now,
        }
    }

    pub fn update_subject(&mut self, subject: String) {
        self.subject = subject;
        self.updated_at = Utc::now();
    }

    pub fn update_optional_field(&mut self, optional_field: Option<String>) {
        self.optional_field = optional_field;
        self.updated_at = Utc::now();
    }
}

Database Hydration

Entities implement SQLx’s FromRow trait for database mapping:
src/domain/entities/user.rs
use sqlx::postgres::PgRow;
use sqlx::Row;

impl sqlx::FromRow<'_, PgRow> for User {
    fn from_row(row: &PgRow) -> Result<Self, sqlx::Error> {
        let role_str: String = row.try_get("role")?;
        let role = Role::from_str(&role_str)
            .ok_or_else(|| sqlx::Error::Decode(
                format!("Invalid role: {}", role_str).into()
            ))?;

        Ok(User {
            id: row.try_get("id")?,
            // Use from_trusted - we trust database integrity
            email: EmailAddress::from_trusted(row.try_get("email")?),
            username: Username::from_trusted(row.try_get("username")?),
            password_hash: row.try_get("password_hash")?,
            role,
            is_active: row.try_get("is_active")?,
            created_at: row.try_get("created_at")?,
            updated_at: row.try_get("updated_at")?,
        })
    }
}
Use from_trusted() when hydrating from the database because we assume data integrity. Use new() when creating from user input.

DTO Conversion

Entities provide methods to convert to response DTOs:
src/domain/entities/user.rs
impl User {
    pub fn to_response(&self) -> crate::application::dtos::UserResponse {
        use crate::application::dtos::UserResponse;

        UserResponse {
            id: self.id.clone(),
            email: self.email.as_str().to_string(),
            username: self.username.as_str().to_string(),
            role: self.role.to_string(),
            is_active: self.is_active,
            created_at: self.created_at.to_rfc3339(),
            updated_at: self.updated_at.to_rfc3339(),
        }
    }
}

Complete Data Flow

1
Client sends JSON
2
Raw request data arrives at the controller.
3
DTO validation (HTTP Layer)
4
#[derive(Validate)]
pub struct RegisterUserRequest {
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 3, max = 50))]
    pub username: String,
}
5
Service receives validated DTO
6
pub async fn register(&self, request: RegisterUserRequest) -> Result<AuthResponse, ApiError>
7
Create Value Objects (Domain Layer)
8
let email_vo = EmailAddress::new(request.email)?;  // Domain validation
let username_vo = Username::new(request.username)?;  // Domain validation
9
Create Entity
10
let user = User::new(email_vo, username_vo, password_hash)?;
11
Persist to Repository
12
let created_user = self.user_repository.create(&user).await?;
13
Repository extracts primitives
14
.bind(&user.email.as_str())  // Extract String from Value Object
.bind(&user.username.as_str())

Benefits of Smart Constructors

Type Safety:
  • Compiler enforces use of Value Objects
  • Impossible to accidentally use raw String where EmailAddress is required
Validation Guarantee:
  • If a User exists in memory, it’s 100% valid
  • No need to re-validate domain objects
Centralized Logic:
  • Validation rules defined once in Value Object
  • Changes propagate automatically throughout codebase
Testability:
  • Easy to test validation logic in isolation
  • Mock entities with known-valid state

Best Practices

DO:
  • Use Value Objects for all domain concepts
  • Accept Value Objects in entity constructors
  • Use new() for user input, from_trusted() for database
  • Keep validation logic in Value Objects
DON’T:
  • Use primitives (String, i32) for domain concepts
  • Validate in multiple places
  • Create “anemic” entities with only getters/setters
  • Skip validation because “it’s already checked”

Next Steps

Build docs developers (and LLMs) love