From ed5e238a9c778772bf0a03a026a1639fa90adf69 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 17 May 2026 23:52:46 +0200 Subject: [PATCH] feat(domain): value objects, entities, port traits with tests --- crates/domain/Cargo.toml | 11 ++++++ crates/domain/src/entities/mod.rs | 2 + crates/domain/src/entities/user.rs | 17 +++++++++ crates/domain/src/errors.rs | 13 +++++++ crates/domain/src/events.rs | 7 ++++ crates/domain/src/lib.rs | 5 +++ crates/domain/src/ports/auth.rs | 14 +++++++ crates/domain/src/ports/mod.rs | 5 +++ crates/domain/src/ports/user_repo.rs | 10 +++++ crates/domain/src/value_objects/email.rs | 42 +++++++++++++++++++++ crates/domain/src/value_objects/mod.rs | 9 +++++ crates/domain/src/value_objects/password.rs | 14 +++++++ crates/domain/src/value_objects/role.rs | 23 +++++++++++ crates/domain/src/value_objects/user_id.rs | 22 +++++++++++ 14 files changed, 194 insertions(+) create mode 100644 crates/domain/Cargo.toml create mode 100644 crates/domain/src/entities/mod.rs create mode 100644 crates/domain/src/entities/user.rs create mode 100644 crates/domain/src/errors.rs create mode 100644 crates/domain/src/events.rs create mode 100644 crates/domain/src/lib.rs create mode 100644 crates/domain/src/ports/auth.rs create mode 100644 crates/domain/src/ports/mod.rs create mode 100644 crates/domain/src/ports/user_repo.rs create mode 100644 crates/domain/src/value_objects/email.rs create mode 100644 crates/domain/src/value_objects/mod.rs create mode 100644 crates/domain/src/value_objects/password.rs create mode 100644 crates/domain/src/value_objects/role.rs create mode 100644 crates/domain/src/value_objects/user_id.rs diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml new file mode 100644 index 0000000..77b1fd0 --- /dev/null +++ b/crates/domain/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "domain" +version = "0.1.0" +edition = "2024" + +[dependencies] +uuid = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } diff --git a/crates/domain/src/entities/mod.rs b/crates/domain/src/entities/mod.rs new file mode 100644 index 0000000..7068ef2 --- /dev/null +++ b/crates/domain/src/entities/mod.rs @@ -0,0 +1,2 @@ +mod user; +pub use user::User; diff --git a/crates/domain/src/entities/user.rs b/crates/domain/src/entities/user.rs new file mode 100644 index 0000000..3eae318 --- /dev/null +++ b/crates/domain/src/entities/user.rs @@ -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, +} + +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() } + } +} diff --git a/crates/domain/src/errors.rs b/crates/domain/src/errors.rs new file mode 100644 index 0000000..17d04ed --- /dev/null +++ b/crates/domain/src/errors.rs @@ -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), +} diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs new file mode 100644 index 0000000..63bf242 --- /dev/null +++ b/crates/domain/src/events.rs @@ -0,0 +1,7 @@ +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub enum DomainEvent { + UserRegistered { user_id: Uuid }, + UserLoggedIn { user_id: Uuid }, +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..3df2e20 --- /dev/null +++ b/crates/domain/src/lib.rs @@ -0,0 +1,5 @@ +pub mod entities; +pub mod errors; +pub mod events; +pub mod ports; +pub mod value_objects; diff --git a/crates/domain/src/ports/auth.rs b/crates/domain/src/ports/auth.rs new file mode 100644 index 0000000..c93f0e9 --- /dev/null +++ b/crates/domain/src/ports/auth.rs @@ -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; + async fn verify(&self, password: &str, hash: &PasswordHash) -> Result; +} + +#[async_trait] +pub trait TokenIssuer: Send + Sync { + async fn issue(&self, user_id: &UserId, role: &Role) -> Result; + async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError>; +} diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs new file mode 100644 index 0000000..aa20e49 --- /dev/null +++ b/crates/domain/src/ports/mod.rs @@ -0,0 +1,5 @@ +mod auth; +mod user_repo; + +pub use auth::{PasswordHasher, TokenIssuer}; +pub use user_repo::UserRepository; diff --git a/crates/domain/src/ports/user_repo.rs b/crates/domain/src/ports/user_repo.rs new file mode 100644 index 0000000..07dc7b8 --- /dev/null +++ b/crates/domain/src/ports/user_repo.rs @@ -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, DomainError>; + async fn find_by_email(&self, email: &Email) -> Result, DomainError>; + async fn save(&self, user: &User) -> Result<(), DomainError>; + async fn delete(&self, id: &UserId) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/value_objects/email.rs b/crates/domain/src/value_objects/email.rs new file mode 100644 index 0000000..87898a3 --- /dev/null +++ b/crates/domain/src/value_objects/email.rs @@ -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) -> Result { + 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"); + } +} diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs new file mode 100644 index 0000000..4624615 --- /dev/null +++ b/crates/domain/src/value_objects/mod.rs @@ -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; diff --git a/crates/domain/src/value_objects/password.rs b/crates/domain/src/value_objects/password.rs new file mode 100644 index 0000000..072a3c7 --- /dev/null +++ b/crates/domain/src/value_objects/password.rs @@ -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 } +} diff --git a/crates/domain/src/value_objects/role.rs b/crates/domain/src/value_objects/role.rs new file mode 100644 index 0000000..c4efe7b --- /dev/null +++ b/crates/domain/src/value_objects/role.rs @@ -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 { + match s { + "user" => Ok(Role::User), + "admin" => Ok(Role::Admin), + other => Err(format!("Unknown role: {other}")), + } + } +} diff --git a/crates/domain/src/value_objects/user_id.rs b/crates/domain/src/value_objects/user_id.rs new file mode 100644 index 0000000..0d30683 --- /dev/null +++ b/crates/domain/src/value_objects/user_id.rs @@ -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 for UserId { + fn from(id: uuid::Uuid) -> Self { Self(id) } +}