feat(domain): value objects, entities, port traits with tests

This commit is contained in:
2026-05-17 23:52:46 +02:00
parent fa867a837f
commit ed5e238a9c
14 changed files with 194 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
mod user;
pub use user::User;

View File

@@ -0,0 +1,17 @@
use chrono::{DateTime, Utc};
use crate::value_objects::{Email, PasswordHash, Role, UserId};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct User {
pub id: UserId,
pub email: Email,
pub password_hash: PasswordHash,
pub role: Role,
pub created_at: DateTime<Utc>,
}
impl User {
pub fn new(id: UserId, email: Email, password_hash: PasswordHash) -> Self {
Self { id, email, password_hash, role: Role::User, created_at: Utc::now() }
}
}

View File

@@ -0,0 +1,13 @@
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Internal error: {0}")]
Internal(String),
}

View File

@@ -0,0 +1,7 @@
use uuid::Uuid;
#[derive(Debug, Clone)]
pub enum DomainEvent {
UserRegistered { user_id: Uuid },
UserLoggedIn { user_id: Uuid },
}

5
crates/domain/src/lib.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod entities;
pub mod errors;
pub mod events;
pub mod ports;
pub mod value_objects;

View File

@@ -0,0 +1,14 @@
use async_trait::async_trait;
use crate::{errors::DomainError, value_objects::{PasswordHash, Role, UserId}};
#[async_trait]
pub trait PasswordHasher: Send + Sync {
async fn hash(&self, password: &str) -> Result<PasswordHash, DomainError>;
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
}
#[async_trait]
pub trait TokenIssuer: Send + Sync {
async fn issue(&self, user_id: &UserId, role: &Role) -> Result<String, DomainError>;
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError>;
}

View File

@@ -0,0 +1,5 @@
mod auth;
mod user_repo;
pub use auth::{PasswordHasher, TokenIssuer};
pub use user_repo::UserRepository;

View File

@@ -0,0 +1,10 @@
use async_trait::async_trait;
use crate::{entities::User, errors::DomainError, value_objects::{Email, UserId}};
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
async fn delete(&self, id: &UserId) -> Result<(), DomainError>;
}

View File

@@ -0,0 +1,42 @@
use crate::errors::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Email(String);
impl Email {
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
let value = value.into().trim().to_lowercase();
if value.is_empty() || !value.contains('@') {
return Err(DomainError::Validation("Invalid email address".to_string()));
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str { &self.0 }
}
impl std::fmt::Display for Email {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty() { assert!(Email::new("").is_err()); }
#[test]
fn rejects_no_at() { assert!(Email::new("notanemail").is_err()); }
#[test]
fn accepts_valid() { assert!(Email::new("user@example.com").is_ok()); }
#[test]
fn lowercases_and_trims() {
let email = Email::new(" User@Example.Com ").unwrap();
assert_eq!(email.as_str(), "user@example.com");
}
}

View File

@@ -0,0 +1,9 @@
mod email;
mod password;
mod role;
mod user_id;
pub use email::Email;
pub use password::PasswordHash;
pub use role::Role;
pub use user_id::UserId;

View File

@@ -0,0 +1,14 @@
// Manual Debug — redacts hash to prevent it appearing in logs
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct PasswordHash(String);
impl std::fmt::Debug for PasswordHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("PasswordHash").field(&"[redacted]").finish()
}
}
impl PasswordHash {
pub fn from_hash(hash: String) -> Self { Self(hash) }
pub fn as_str(&self) -> &str { &self.0 }
}

View File

@@ -0,0 +1,23 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role { User, Admin }
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Role::User => write!(f, "user"),
Role::Admin => write!(f, "admin"),
}
}
}
impl std::str::FromStr for Role {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"user" => Ok(Role::User),
"admin" => Ok(Role::Admin),
other => Err(format!("Unknown role: {other}")),
}
}
}

View File

@@ -0,0 +1,22 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct UserId(uuid::Uuid);
impl UserId {
pub fn new() -> Self { Self(uuid::Uuid::new_v4()) }
pub fn from_uuid(id: uuid::Uuid) -> Self { Self(id) }
pub fn as_uuid(&self) -> &uuid::Uuid { &self.0 }
}
impl Default for UserId {
fn default() -> Self { Self::new() }
}
impl std::fmt::Display for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<uuid::Uuid> for UserId {
fn from(id: uuid::Uuid) -> Self { Self(id) }
}