refactor (v2): better arch

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-06-07 21:19:54 +02:00
parent 0753f3d256
commit 839308ec19
166 changed files with 8553 additions and 884 deletions

View File

@@ -0,0 +1,19 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DomainError {
#[error("not found: {0}")]
NotFound(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("forbidden: {0}")]
Forbidden(String),
#[error("validation: {0}")]
Validation(String),
#[error("repository: {0}")]
Repository(String),
#[error("infrastructure: {0}")]
Infrastructure(String),
}
pub type DomainResult<T> = Result<T, DomainError>;

View File

@@ -0,0 +1,65 @@
use futures::{future::BoxFuture, stream::BoxStream};
use crate::{errors::DomainError, note::entity::NoteId, user::entity::UserId};
#[derive(Debug, Clone)]
pub enum DomainEvent {
NoteCreated { note_id: NoteId, user_id: UserId },
NoteUpdated { note_id: NoteId, user_id: UserId },
NoteDeleted { note_id: NoteId, user_id: UserId },
}
type AckFn = Box<dyn FnOnce() -> BoxFuture<'static, Result<(), DomainError>> + Send>;
pub struct EventEnvelope {
pub event: DomainEvent,
ack_fn: AckFn,
nack_fn: AckFn,
}
impl EventEnvelope {
pub fn new(
event: DomainEvent,
ack_fn: impl FnOnce() -> BoxFuture<'static, Result<(), DomainError>> + Send + 'static,
nack_fn: impl FnOnce() -> BoxFuture<'static, Result<(), DomainError>> + Send + 'static,
) -> Self {
Self {
event,
ack_fn: Box::new(ack_fn),
nack_fn: Box::new(nack_fn),
}
}
/// Both ack and nack are no-ops. For in-memory and test consumers.
pub fn noop(event: DomainEvent) -> Self {
Self::new(
event,
|| Box::pin(async { Ok(()) }),
|| Box::pin(async { Ok(()) }),
)
}
pub async fn ack(self) -> Result<(), DomainError> {
(self.ack_fn)().await
}
/// Signal that processing failed. The transport decides whether to redeliver
/// (JetStream: redeliver up to max_deliver times; in-memory: no-op).
pub async fn nack(self) -> Result<(), DomainError> {
(self.nack_fn)().await
}
}
#[async_trait::async_trait]
pub trait EventPublisher: Send + Sync {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
}
pub trait EventConsumer: Send + Sync {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>>;
}
#[async_trait::async_trait]
pub trait EventHandler: Send + Sync {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>;
}

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

@@ -0,0 +1,6 @@
pub mod errors;
pub mod events;
pub mod note;
pub mod smart;
pub mod tag;
pub mod user;

View File

@@ -0,0 +1,176 @@
use std::fmt;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use super::value_objects::{NoteColor, NoteTitle};
use crate::{tag::entity::Tag, user::entity::UserId};
pub const MAX_TAGS_PER_NOTE: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NoteId(Uuid);
impl NoteId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(id: Uuid) -> Self {
Self(id)
}
pub fn as_uuid(self) -> Uuid {
self.0
}
}
impl Default for NoteId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for NoteId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Note {
pub id: NoteId,
pub user_id: UserId,
pub title: Option<NoteTitle>,
pub content: String,
pub color: NoteColor,
pub is_pinned: bool,
pub is_archived: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// Hydrated by the repository on read. Not managed by Note itself.
pub tags: Vec<Tag>,
}
impl Note {
pub fn new(user_id: UserId, title: Option<NoteTitle>, content: impl Into<String>) -> Self {
let now = Utc::now();
Self {
id: NoteId::new(),
user_id,
title,
content: content.into(),
color: NoteColor::default(),
is_pinned: false,
is_archived: false,
created_at: now,
updated_at: now,
tags: Vec::new(),
}
}
pub fn set_title(&mut self, title: Option<NoteTitle>) {
self.title = title;
self.updated_at = Utc::now();
}
pub fn set_content(&mut self, content: impl Into<String>) {
self.content = content.into();
self.updated_at = Utc::now();
}
pub fn set_color(&mut self, color: NoteColor) {
self.color = color;
self.updated_at = Utc::now();
}
pub fn set_pinned(&mut self, pinned: bool) {
self.is_pinned = pinned;
self.updated_at = Utc::now();
}
pub fn set_archived(&mut self, archived: bool) {
self.is_archived = archived;
self.updated_at = Utc::now();
}
pub fn can_add_tag(&self) -> bool {
self.tags.len() < MAX_TAGS_PER_NOTE
}
}
/// Snapshot of a note's content at a point in time.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoteVersion {
pub id: Uuid,
pub note_id: NoteId,
pub title: Option<String>,
pub content: String,
pub created_at: DateTime<Utc>,
}
impl NoteVersion {
pub fn snapshot(note: &Note) -> Self {
Self {
id: Uuid::new_v4(),
note_id: note.id,
title: note.title.as_ref().map(|t| t.as_ref().to_string()),
content: note.content.clone(),
created_at: Utc::now(),
}
}
}
/// Semantic similarity edge between two notes, produced by smart features.
#[derive(Debug, Clone, PartialEq)]
pub struct NoteLink {
pub source_id: NoteId,
pub target_id: NoteId,
/// Cosine similarity score in [0.0, 1.0].
pub score: f32,
pub created_at: DateTime<Utc>,
}
impl NoteLink {
pub fn new(source_id: NoteId, target_id: NoteId, score: f32) -> Self {
Self {
source_id,
target_id,
score,
created_at: Utc::now(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct NoteFilter {
pub is_pinned: Option<bool>,
pub is_archived: Option<bool>,
pub tag_id: Option<crate::tag::entity::TagId>,
}
impl NoteFilter {
pub fn pinned(mut self) -> Self {
self.is_pinned = Some(true);
self
}
pub fn archived(mut self) -> Self {
self.is_archived = Some(true);
self
}
pub fn not_archived(mut self) -> Self {
self.is_archived = Some(false);
self
}
pub fn with_tag(mut self, tag_id: crate::tag::entity::TagId) -> Self {
self.tag_id = Some(tag_id);
self
}
}
#[cfg(test)]
#[path = "tests/entity.rs"]
mod tests;

View File

@@ -0,0 +1,6 @@
pub mod entity;
pub mod ports;
pub mod value_objects;
pub use entity::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteId, NoteLink, NoteVersion};
pub use value_objects::{NoteColor, NoteTitle};

View File

@@ -0,0 +1,22 @@
use async_trait::async_trait;
use super::entity::{Note, NoteFilter, NoteId, NoteLink, NoteVersion};
use crate::{errors::DomainResult, user::entity::UserId};
#[async_trait]
pub trait NoteRepository: Send + Sync {
async fn find_by_id(&self, id: &NoteId) -> DomainResult<Option<Note>>;
async fn find_by_user(&self, user_id: &UserId, filter: NoteFilter) -> DomainResult<Vec<Note>>;
async fn search(&self, user_id: &UserId, query: &str) -> DomainResult<Vec<Note>>;
async fn save(&self, note: &Note) -> DomainResult<()>;
async fn delete(&self, id: &NoteId) -> DomainResult<()>;
async fn save_version(&self, version: &NoteVersion) -> DomainResult<()>;
async fn find_versions(&self, note_id: &NoteId) -> DomainResult<Vec<NoteVersion>>;
}
#[async_trait]
pub trait LinkRepository: Send + Sync {
async fn save_links(&self, links: &[NoteLink]) -> DomainResult<()>;
async fn delete_for_source(&self, source_id: &NoteId) -> DomainResult<()>;
async fn find_for_note(&self, note_id: &NoteId) -> DomainResult<Vec<NoteLink>>;
}

View File

@@ -0,0 +1,47 @@
use crate::{
note::entity::{MAX_TAGS_PER_NOTE, Note, NoteVersion},
tag::{entity::Tag, value_objects::TagName},
user::entity::UserId,
};
fn uid() -> UserId {
UserId::new()
}
#[test]
fn new_note_defaults() {
let note = Note::new(uid(), None, "content");
assert!(!note.is_pinned);
assert!(!note.is_archived);
assert_eq!(note.color.as_str(), "DEFAULT");
assert!(note.tags.is_empty());
}
#[test]
fn set_pinned_updates_timestamp() {
let mut note = Note::new(uid(), None, "content");
let before = note.updated_at;
std::thread::sleep(std::time::Duration::from_millis(5));
note.set_pinned(true);
assert!(note.is_pinned);
assert!(note.updated_at > before);
}
#[test]
fn can_add_tag_respects_limit() {
let user_id = uid();
let mut note = Note::new(user_id, None, "content");
assert!(note.can_add_tag());
note.tags = (0..MAX_TAGS_PER_NOTE)
.map(|_| Tag::new(TagName::new("x").unwrap(), user_id))
.collect();
assert!(!note.can_add_tag());
}
#[test]
fn note_version_snapshots_content() {
let note = Note::new(uid(), None, "hello");
let v = NoteVersion::snapshot(&note);
assert_eq!(v.note_id, note.id);
assert_eq!(v.content, "hello");
}

View File

@@ -0,0 +1,34 @@
use super::*;
#[test]
fn title_trims_whitespace() {
let t = NoteTitle::new(" My Note ").unwrap();
assert_eq!(t.as_ref(), "My Note");
}
#[test]
fn title_rejects_too_long() {
assert!(NoteTitle::new("a".repeat(MAX_NOTE_TITLE_LENGTH + 1)).is_err());
assert!(NoteTitle::new("a".repeat(MAX_NOTE_TITLE_LENGTH)).is_ok());
}
#[test]
fn title_from_optional_empty_is_none() {
assert!(NoteTitle::from_optional(None).unwrap().is_none());
assert!(
NoteTitle::from_optional(Some(" ".into()))
.unwrap()
.is_none()
);
}
#[test]
fn color_uppercases() {
let c = NoteColor::new("default");
assert_eq!(c.as_str(), "DEFAULT");
}
#[test]
fn color_default() {
assert_eq!(NoteColor::default().as_str(), "DEFAULT");
}

View File

@@ -0,0 +1,94 @@
use std::fmt;
use crate::errors::DomainError;
pub const MAX_NOTE_TITLE_LENGTH: usize = 200;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct NoteTitle(String);
impl NoteTitle {
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
let s = value.into();
let trimmed = s.trim();
if trimmed.len() > MAX_NOTE_TITLE_LENGTH {
return Err(DomainError::Validation(format!(
"note title exceeds {MAX_NOTE_TITLE_LENGTH} characters"
)));
}
Ok(Self(trimmed.to_string()))
}
/// Returns `None` for empty/whitespace input, `Some` otherwise.
pub fn from_optional(value: Option<String>) -> Result<Option<Self>, DomainError> {
match value {
None => Ok(None),
Some(s) if s.trim().is_empty() => Ok(None),
Some(s) => Self::new(s).map(Some),
}
}
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for NoteTitle {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for NoteTitle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<String> for NoteTitle {
type Error = DomainError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl TryFrom<&str> for NoteTitle {
type Error = DomainError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
/// Background color of a note. Stored as an uppercase string (e.g. "DEFAULT", "#FF5733").
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoteColor(String);
impl NoteColor {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into().trim().to_uppercase())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl Default for NoteColor {
fn default() -> Self {
Self::new("DEFAULT")
}
}
impl fmt::Display for NoteColor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
#[path = "tests/value_objects.rs"]
mod tests;

View File

@@ -0,0 +1 @@
pub mod ports;

View File

@@ -0,0 +1,15 @@
use async_trait::async_trait;
use crate::{errors::DomainResult, note::entity::NoteId};
#[async_trait]
pub trait EmbeddingGenerator: Send + Sync {
async fn generate(&self, text: &str) -> DomainResult<Vec<f32>>;
}
#[async_trait]
pub trait VectorStore: Send + Sync {
async fn upsert(&self, id: &NoteId, vector: &[f32]) -> DomainResult<()>;
async fn find_similar(&self, vector: &[f32], limit: usize) -> DomainResult<Vec<(NoteId, f32)>>;
async fn delete(&self, id: &NoteId) -> DomainResult<()>;
}

View File

@@ -0,0 +1,56 @@
use std::fmt;
use uuid::Uuid;
use super::value_objects::TagName;
use crate::user::entity::UserId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TagId(Uuid);
impl TagId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(id: Uuid) -> Self {
Self(id)
}
pub fn as_uuid(self) -> Uuid {
self.0
}
}
impl Default for TagId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for TagId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tag {
pub id: TagId,
pub name: TagName,
pub user_id: UserId,
}
impl Tag {
pub fn new(name: TagName, user_id: UserId) -> Self {
Self {
id: TagId::new(),
name,
user_id,
}
}
pub fn from_row(id: TagId, name: TagName, user_id: UserId) -> Self {
Self { id, name, user_id }
}
}

View File

@@ -0,0 +1,6 @@
pub mod entity;
pub mod ports;
pub mod value_objects;
pub use entity::{Tag, TagId};
pub use value_objects::TagName;

View File

@@ -0,0 +1,19 @@
use async_trait::async_trait;
use super::{
entity::{Tag, TagId},
value_objects::TagName,
};
use crate::{errors::DomainResult, note::entity::NoteId, user::entity::UserId};
#[async_trait]
pub trait TagRepository: Send + Sync {
async fn find_by_id(&self, id: &TagId) -> DomainResult<Option<Tag>>;
async fn find_by_user(&self, user_id: &UserId) -> DomainResult<Vec<Tag>>;
async fn find_by_name(&self, user_id: &UserId, name: &TagName) -> DomainResult<Option<Tag>>;
async fn find_by_note(&self, note_id: &NoteId) -> DomainResult<Vec<Tag>>;
async fn save(&self, tag: &Tag) -> DomainResult<()>;
async fn delete(&self, id: &TagId) -> DomainResult<()>;
async fn add_to_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()>;
async fn remove_from_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()>;
}

View File

@@ -0,0 +1,19 @@
use super::*;
#[test]
fn normalises_to_lowercase_trimmed() {
let t = TagName::new(" Important ").unwrap();
assert_eq!(t.as_ref(), "important");
}
#[test]
fn rejects_empty() {
assert!(TagName::new("").is_err());
assert!(TagName::new(" ").is_err());
}
#[test]
fn rejects_too_long() {
assert!(TagName::new("a".repeat(MAX_TAG_NAME_LENGTH + 1)).is_err());
assert!(TagName::new("a".repeat(MAX_TAG_NAME_LENGTH)).is_ok());
}

View File

@@ -0,0 +1,57 @@
use std::fmt;
use crate::errors::DomainError;
pub const MAX_TAG_NAME_LENGTH: usize = 50;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TagName(String);
impl TagName {
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
let s = value.into().trim().to_lowercase();
if s.is_empty() {
return Err(DomainError::Validation("tag name cannot be empty".into()));
}
if s.len() > MAX_TAG_NAME_LENGTH {
return Err(DomainError::Validation(format!(
"tag name exceeds {MAX_TAG_NAME_LENGTH} characters"
)));
}
Ok(Self(s))
}
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for TagName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for TagName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<String> for TagName {
type Error = DomainError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl TryFrom<&str> for TagName {
type Error = DomainError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
#[cfg(test)]
#[path = "tests/value_objects.rs"]
mod tests;

View File

@@ -0,0 +1,85 @@
use std::fmt;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use super::value_objects::{Email, PasswordHash};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(Uuid);
impl UserId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(id: Uuid) -> Self {
Self(id)
}
pub fn as_uuid(self) -> Uuid {
self.0
}
}
impl Default for UserId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for UserId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
pub id: UserId,
/// OIDC subject claim; equals email string for local-auth users.
pub subject: String,
pub email: Email,
pub password_hash: Option<PasswordHash>,
pub created_at: DateTime<Utc>,
}
impl User {
pub fn new_oidc(subject: impl Into<String>, email: Email) -> Self {
Self {
id: UserId::new(),
subject: subject.into(),
email,
password_hash: None,
created_at: Utc::now(),
}
}
pub fn new_local(email: Email, password_hash: PasswordHash) -> Self {
let subject = email.as_ref().to_string();
Self {
id: UserId::new(),
subject,
email,
password_hash: Some(password_hash),
created_at: Utc::now(),
}
}
/// Reconstruct from storage. Does not validate business rules.
pub fn from_row(
id: UserId,
subject: String,
email: Email,
password_hash: Option<PasswordHash>,
created_at: DateTime<Utc>,
) -> Self {
Self {
id,
subject,
email,
password_hash,
created_at,
}
}
}

View File

@@ -0,0 +1,6 @@
pub mod entity;
pub mod ports;
pub mod value_objects;
pub use entity::{User, UserId};
pub use value_objects::{Email, Password, PasswordHash};

View File

@@ -0,0 +1,22 @@
use async_trait::async_trait;
use super::{
entity::{User, UserId},
value_objects::{Email, Password, PasswordHash},
};
use crate::errors::DomainResult;
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: &UserId) -> DomainResult<Option<User>>;
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>>;
async fn find_by_email(&self, email: &Email) -> DomainResult<Option<User>>;
async fn save(&self, user: &User) -> DomainResult<()>;
async fn delete(&self, id: &UserId) -> DomainResult<()>;
}
#[async_trait]
pub trait PasswordHasher: Send + Sync {
async fn hash(&self, password: &Password) -> DomainResult<PasswordHash>;
async fn verify(&self, password: &Password, hash: &PasswordHash) -> DomainResult<bool>;
}

View File

@@ -0,0 +1,25 @@
use super::*;
#[test]
fn email_normalises() {
let e = Email::new(" USER@EXAMPLE.COM ").unwrap();
assert_eq!(e.as_ref(), "user@example.com");
}
#[test]
fn email_rejects_invalid() {
assert!(Email::new("not-an-email").is_err());
assert!(Email::new("@example.com").is_err());
}
#[test]
fn password_enforces_minimum_length() {
assert!(Password::new("short").is_err());
assert!(Password::new("longenough").is_ok());
}
#[test]
fn password_hides_in_debug() {
let p = Password::new("supersecret").unwrap();
assert!(!format!("{p:?}").contains("supersecret"));
}

View File

@@ -0,0 +1,101 @@
use std::fmt;
use crate::errors::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Email(email_address::EmailAddress);
impl Email {
pub fn new(value: impl AsRef<str>) -> Result<Self, DomainError> {
let s = value.as_ref().trim().to_lowercase();
s.parse::<email_address::EmailAddress>()
.map(Self)
.map_err(|e| DomainError::Validation(format!("invalid email: {e}")))
}
pub fn into_inner(self) -> String {
self.0.to_string()
}
}
impl AsRef<str> for Email {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl fmt::Display for Email {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<String> for Email {
type Error = DomainError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl TryFrom<&str> for Email {
type Error = DomainError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
/// Unverified plaintext password. Not stored — only used for auth operations.
#[derive(Clone, PartialEq, Eq)]
pub struct Password(String);
pub const MIN_PASSWORD_LENGTH: usize = 8;
impl Password {
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
let v = value.into();
if v.len() < MIN_PASSWORD_LENGTH {
return Err(DomainError::Validation(format!(
"password must be at least {MIN_PASSWORD_LENGTH} characters"
)));
}
Ok(Self(v))
}
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for Password {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Password {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Password(***)")
}
}
/// Stored password hash — opaque to the domain, managed by PasswordHasher port.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PasswordHash(String);
impl PasswordHash {
pub fn new(hash: impl Into<String>) -> Self {
Self(hash.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
#[cfg(test)]
#[path = "tests/value_objects.rs"]
mod tests;