refactor(ports): CQRS split — UserRepository = UserReader + UserWriter supertrait

This commit is contained in:
2026-05-15 13:43:43 +02:00
parent a902154777
commit 8ed7f3d5bc
17 changed files with 113 additions and 97 deletions

View File

@@ -189,7 +189,7 @@ mod tests {
thought::{Thought, Visibility}, thought::{Thought, Visibility},
user::User, user::User,
}, },
ports::{SearchPort, ThoughtRepository, UserRepository}, ports::{SearchPort, ThoughtRepository, UserWriter},
value_objects::*, value_objects::*,
}; };

View File

@@ -93,7 +93,7 @@ mod tests {
use super::*; use super::*;
use crate::user::PgUserRepository; use crate::user::PgUserRepository;
use chrono::Utc; use chrono::Utc;
use domain::ports::UserRepository; use domain::ports::UserWriter;
use domain::{models::user::User, value_objects::*}; use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool) -> User { async fn seed_user(pool: &sqlx::PgPool) -> User {

View File

@@ -338,7 +338,7 @@ mod tests {
thought::{Thought, Visibility}, thought::{Thought, Visibility},
user::User, user::User,
}, },
ports::{ThoughtRepository, UserRepository}, ports::{ThoughtRepository, UserWriter},
value_objects::*, value_objects::*,
}; };

View File

@@ -137,7 +137,7 @@ mod tests {
use super::*; use super::*;
use crate::user::PgUserRepository; use crate::user::PgUserRepository;
use chrono::Utc; use chrono::Utc;
use domain::ports::UserRepository; use domain::ports::UserWriter;
use domain::{ use domain::{
models::{notification::NotificationType, user::User}, models::{notification::NotificationType, user::User},
value_objects::*, value_objects::*,

View File

@@ -132,7 +132,7 @@ impl TagRepository for PgTagRepository {
mod tests { mod tests {
use super::*; use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository}; use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::ports::{ThoughtRepository, UserRepository}; use domain::ports::{ThoughtRepository, UserWriter};
use domain::{ use domain::{
models::{ models::{
thought::{Thought, Visibility}, thought::{Thought, Visibility},

View File

@@ -4,7 +4,7 @@ use domain::{
thought::{Thought, Visibility}, thought::{Thought, Visibility},
user::User, user::User,
}, },
ports::{ThoughtRepository, UserRepository}, ports::{ThoughtRepository, UserWriter},
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
}; };

View File

@@ -107,7 +107,7 @@ impl TopFriendRepository for PgTopFriendRepository {
mod tests { mod tests {
use super::*; use super::*;
use crate::user::PgUserRepository; use crate::user::PgUserRepository;
use domain::ports::UserRepository; use domain::ports::UserWriter;
use domain::{models::user::User, value_objects::*}; use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {

View File

@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{feed::UserSummary, user::User}, models::{feed::UserSummary, user::User},
ports::UserRepository, ports::{UserReader, UserWriter},
value_objects::{Email, PasswordHash, UserId, Username}, value_objects::{Email, PasswordHash, UserId, Username},
}; };
use sqlx::PgPool; use sqlx::PgPool;
@@ -58,7 +58,7 @@ pub const USER_SELECT: &str =
custom_css,local,created_at,updated_at FROM users"; custom_css,local,created_at,updated_at FROM users";
#[async_trait] #[async_trait]
impl UserRepository for PgUserRepository { impl UserReader for PgUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id=$1")) sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id=$1"))
.bind(id.as_uuid()) .bind(id.as_uuid())
@@ -86,6 +86,60 @@ impl UserRepository for PgUserRepository {
.map(|o| o.map(User::from)) .map(|o| o.map(User::from))
} }
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
username: String,
display_name: Option<String>,
avatar_url: Option<String>,
bio: Option<String>,
thought_count: i64,
follower_count: i64,
following_count: i64,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio,
COUNT(DISTINCT t.id) AS thought_count,
COUNT(DISTINCT f1.follower_id) AS follower_count,
COUNT(DISTINCT f2.following_id) AS following_count
FROM users u
LEFT JOIN thoughts t ON t.user_id=u.id AND t.local=true
LEFT JOIN follows f1 ON f1.following_id=u.id AND f1.state='accepted'
LEFT JOIN follows f2 ON f2.follower_id=u.id AND f2.state='accepted'
WHERE u.local=true
GROUP BY u.id
ORDER BY u.username",
)
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(rows
.into_iter()
.map(|r| UserSummary {
id: UserId::from_uuid(r.id),
username: r.username,
display_name: r.display_name,
avatar_url: r.avatar_url,
bio: r.bio,
thought_count: r.thought_count,
follower_count: r.follower_count,
following_count: r.following_count,
})
.collect())
}
async fn count(&self) -> Result<i64, DomainError> {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE local = true")
.fetch_one(&self.pool)
.await
.into_domain()
}
}
#[async_trait]
impl UserWriter for PgUserRepository {
async fn save(&self, user: &User) -> Result<(), DomainError> { async fn save(&self, user: &User) -> Result<(), DomainError> {
sqlx::query( sqlx::query(
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,created_at,updated_at) "INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,created_at,updated_at)
@@ -139,57 +193,6 @@ impl UserRepository for PgUserRepository {
.into_domain() .into_domain()
.map(|_| ()) .map(|_| ())
} }
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
username: String,
display_name: Option<String>,
avatar_url: Option<String>,
bio: Option<String>,
thought_count: i64,
follower_count: i64,
following_count: i64,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio,
COUNT(DISTINCT t.id) AS thought_count,
COUNT(DISTINCT f1.follower_id) AS follower_count,
COUNT(DISTINCT f2.following_id) AS following_count
FROM users u
LEFT JOIN thoughts t ON t.user_id=u.id AND t.local=true
LEFT JOIN follows f1 ON f1.following_id=u.id AND f1.state='accepted'
LEFT JOIN follows f2 ON f2.follower_id=u.id AND f2.state='accepted'
WHERE u.local=true
GROUP BY u.id
ORDER BY u.username",
)
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(rows
.into_iter()
.map(|r| UserSummary {
id: UserId::from_uuid(r.id),
username: r.username,
display_name: r.display_name,
avatar_url: r.avatar_url,
bio: r.bio,
thought_count: r.thought_count,
follower_count: r.follower_count,
following_count: r.following_count,
})
.collect())
}
async fn count(&self) -> Result<i64, DomainError> {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE local = true")
.fetch_one(&self.pool)
.await
.into_domain()
}
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -2,14 +2,14 @@ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::thought::Visibility, models::thought::Visibility,
ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserRepository}, ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserReader},
value_objects::ThoughtId, value_objects::ThoughtId,
}; };
use std::sync::Arc; use std::sync::Arc;
pub struct FederationEventService { pub struct FederationEventService {
pub thoughts: Arc<dyn ThoughtRepository>, pub thoughts: Arc<dyn ThoughtRepository>,
pub users: Arc<dyn UserRepository>, pub users: Arc<dyn UserReader>,
pub ap: Arc<dyn OutboundFederationPort>, pub ap: Arc<dyn OutboundFederationPort>,
pub base_url: String, pub base_url: String,
pub ap_repo: Arc<dyn ActivityPubRepository>, pub ap_repo: Arc<dyn ActivityPubRepository>,

View File

@@ -2,7 +2,7 @@ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::user::User, models::user::User,
ports::{AuthService, EventPublisher, PasswordHasher, UserRepository}, ports::{AuthService, EventPublisher, PasswordHasher, UserReader, UserRepository},
value_objects::{Email, UserId, Username}, value_objects::{Email, UserId, Username},
}; };
@@ -58,7 +58,7 @@ pub struct LoginOutput {
} }
pub async fn login( pub async fn login(
users: &dyn UserRepository, users: &dyn UserReader,
hasher: &dyn PasswordHasher, hasher: &dyn PasswordHasher,
auth: &dyn AuthService, auth: &dyn AuthService,
input: LoginInput, input: LoginInput,

View File

@@ -7,7 +7,7 @@ use domain::{
}, },
ports::{ ports::{
ActivityPubRepository, EventPublisher, FederationActionPort, FederationSchedulerPort, ActivityPubRepository, EventPublisher, FederationActionPort, FederationSchedulerPort,
FeedRepository, FollowRepository, RemoteActorConnectionRepository, UserRepository, FeedRepository, FollowRepository, RemoteActorConnectionRepository, UserReader,
}, },
value_objects::UserId, value_objects::UserId,
}; };
@@ -61,7 +61,7 @@ pub async fn list_remote_following(
pub async fn remove_remote_following( pub async fn remove_remote_following(
follows: &dyn FollowRepository, follows: &dyn FollowRepository,
users: &dyn UserRepository, users: &dyn UserReader,
federation: &dyn FederationActionPort, federation: &dyn FederationActionPort,
events: &dyn EventPublisher, events: &dyn EventPublisher,
user_id: &UserId, user_id: &UserId,

View File

@@ -4,7 +4,7 @@ use domain::{
feed::{FeedEntry, PageParams, Paginated, UserSummary}, feed::{FeedEntry, PageParams, Paginated, UserSummary},
user::User, user::User,
}, },
ports::{FeedRepository, FollowRepository, TagRepository, UserRepository}, ports::{FeedRepository, FollowRepository, TagRepository, UserReader},
value_objects::UserId, value_objects::UserId,
}; };
@@ -70,12 +70,12 @@ pub async fn search(
feed.search(query, &page, viewer_id).await feed.search(query, &page, viewer_id).await
} }
pub async fn list_users(users: &dyn UserRepository) -> Result<Vec<UserSummary>, DomainError> { pub async fn list_users(users: &dyn UserReader) -> Result<Vec<UserSummary>, DomainError> {
users.list_with_stats().await users.list_with_stats().await
} }
pub async fn list_users_paginated( pub async fn list_users_paginated(
users: &dyn UserRepository, users: &dyn UserReader,
page: PageParams, page: PageParams,
) -> Result<Paginated<UserSummary>, DomainError> { ) -> Result<Paginated<UserSummary>, DomainError> {
let all = users.list_with_stats().await?; let all = users.list_with_stats().await?;

View File

@@ -4,11 +4,11 @@ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{top_friend::TopFriend, user::User}, models::{top_friend::TopFriend, user::User},
ports::{EventPublisher, TopFriendRepository, UserRepository}, ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter},
value_objects::{UserId, Username}, value_objects::{UserId, Username},
}; };
pub async fn get_user(users: &dyn UserRepository, user_id: &UserId) -> Result<User, DomainError> { pub async fn get_user(users: &dyn UserReader, user_id: &UserId) -> Result<User, DomainError> {
users users
.find_by_id(user_id) .find_by_id(user_id)
.await? .await?
@@ -16,7 +16,7 @@ pub async fn get_user(users: &dyn UserRepository, user_id: &UserId) -> Result<Us
} }
pub async fn get_user_by_username( pub async fn get_user_by_username(
users: &dyn UserRepository, users: &dyn UserReader,
username: &str, username: &str,
) -> Result<User, DomainError> { ) -> Result<User, DomainError> {
let username = Username::new(username).map_err(|_| DomainError::NotFound)?; let username = Username::new(username).map_err(|_| DomainError::NotFound)?;
@@ -28,7 +28,7 @@ pub async fn get_user_by_username(
/// Resolve a path segment that is either a UUID (AP actor URL) or a username. /// Resolve a path segment that is either a UUID (AP actor URL) or a username.
pub async fn get_user_by_id_or_username( pub async fn get_user_by_id_or_username(
users: &dyn UserRepository, users: &dyn UserReader,
id_or_username: &str, id_or_username: &str,
) -> Result<User, DomainError> { ) -> Result<User, DomainError> {
if let Ok(uuid) = uuid::Uuid::parse_str(id_or_username) { if let Ok(uuid) = uuid::Uuid::parse_str(id_or_username) {
@@ -43,7 +43,7 @@ pub async fn get_user_by_id_or_username(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn update_profile( pub async fn update_profile(
users: &dyn UserRepository, users: &dyn UserWriter,
events: &dyn EventPublisher, events: &dyn EventPublisher,
user_id: &UserId, user_id: &UserId,
display_name: Option<String>, display_name: Option<String>,

View File

@@ -5,7 +5,7 @@ use domain::{
models::social::{Block, Boost, Follow, FollowState, Like}, models::social::{Block, Boost, Follow, FollowState, Like},
ports::{ ports::{
BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository, BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository,
LikeRepository, UserRepository, LikeRepository, UserReader,
}, },
value_objects::{BoostId, LikeId, ThoughtId, UserId, Username}, value_objects::{BoostId, LikeId, ThoughtId, UserId, Username},
}; };
@@ -92,7 +92,7 @@ pub async fn unboost_thought(
pub async fn follow_actor( pub async fn follow_actor(
follows: &dyn FollowRepository, follows: &dyn FollowRepository,
users: &dyn UserRepository, users: &dyn UserReader,
federation: &dyn FederationActionPort, federation: &dyn FederationActionPort,
events: &dyn EventPublisher, events: &dyn EventPublisher,
follower_id: &UserId, follower_id: &UserId,
@@ -139,7 +139,7 @@ pub async fn follow_user(
pub async fn unfollow_actor( pub async fn unfollow_actor(
follows: &dyn FollowRepository, follows: &dyn FollowRepository,
users: &dyn UserRepository, users: &dyn UserReader,
federation: &dyn FederationActionPort, federation: &dyn FederationActionPort,
events: &dyn EventPublisher, events: &dyn EventPublisher,
follower_id: &UserId, follower_id: &UserId,
@@ -212,7 +212,7 @@ pub async fn reject_follow(
pub async fn block_by_username( pub async fn block_by_username(
blocks: &dyn BlockRepository, blocks: &dyn BlockRepository,
users: &dyn UserRepository, users: &dyn UserReader,
events: &dyn EventPublisher, events: &dyn EventPublisher,
blocker_id: &UserId, blocker_id: &UserId,
username: &str, username: &str,
@@ -227,7 +227,7 @@ pub async fn block_by_username(
pub async fn unblock_by_username( pub async fn unblock_by_username(
blocks: &dyn BlockRepository, blocks: &dyn BlockRepository,
users: &dyn UserRepository, users: &dyn UserReader,
events: &dyn EventPublisher, events: &dyn EventPublisher,
blocker_id: &UserId, blocker_id: &UserId,
username: &str, username: &str,

View File

@@ -2,7 +2,7 @@ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::thought::{Thought, Visibility}, models::thought::{Thought, Visibility},
ports::{EventPublisher, TagRepository, ThoughtRepository, UserRepository}, ports::{EventPublisher, TagRepository, ThoughtRepository, UserReader},
value_objects::{Content, ThoughtId, UserId}, value_objects::{Content, ThoughtId, UserId},
}; };
@@ -51,7 +51,7 @@ pub struct CreateThoughtOutput {
pub async fn create_thought( pub async fn create_thought(
thoughts: &dyn ThoughtRepository, thoughts: &dyn ThoughtRepository,
_users: &dyn UserRepository, _users: &dyn UserReader,
tags: &dyn TagRepository, tags: &dyn TagRepository,
events: &dyn EventPublisher, events: &dyn EventPublisher,
input: CreateThoughtInput, input: CreateThoughtInput,

View File

@@ -45,10 +45,16 @@ pub trait EventConsumer: Send + Sync {
} }
#[async_trait] #[async_trait]
pub trait UserRepository: Send + Sync { pub trait UserReader: Send + Sync {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>; async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>; async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>; async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
async fn count(&self) -> Result<i64, DomainError>;
}
#[async_trait]
pub trait UserWriter: Send + Sync {
async fn save(&self, user: &User) -> Result<(), DomainError>; async fn save(&self, user: &User) -> Result<(), DomainError>;
async fn update_profile( async fn update_profile(
&self, &self,
@@ -59,10 +65,13 @@ pub trait UserRepository: Send + Sync {
header_url: Option<String>, header_url: Option<String>,
custom_css: Option<String>, custom_css: Option<String>,
) -> Result<(), DomainError>; ) -> Result<(), DomainError>;
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
async fn count(&self) -> Result<i64, DomainError>;
} }
/// Combined supertrait — `AppState.users` stays `Arc<dyn UserRepository>`.
/// Blanket impl: any type implementing both sub-traits gets `UserRepository` for free.
pub trait UserRepository: UserReader + UserWriter {}
impl<T: UserReader + UserWriter> UserRepository for T {}
#[async_trait] #[async_trait]
pub trait ThoughtRepository: Send + Sync { pub trait ThoughtRepository: Send + Sync {
async fn save(&self, thought: &Thought) -> Result<(), DomainError>; async fn save(&self, thought: &Thought) -> Result<(), DomainError>;

View File

@@ -45,7 +45,7 @@ pub struct TestStore {
} }
#[async_trait] #[async_trait]
impl UserRepository for TestStore { impl UserReader for TestStore {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
Ok(self Ok(self
.users .users
@@ -73,6 +73,22 @@ impl UserRepository for TestStore {
.find(|u| u.email.as_str() == email.as_str()) .find(|u| u.email.as_str() == email.as_str())
.cloned()) .cloned())
} }
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
Ok(vec![])
}
async fn count(&self) -> Result<i64, DomainError> {
Ok(self
.users
.lock()
.unwrap()
.iter()
.filter(|u| u.local)
.count() as i64)
}
}
#[async_trait]
impl UserWriter for TestStore {
async fn save(&self, user: &User) -> Result<(), DomainError> { async fn save(&self, user: &User) -> Result<(), DomainError> {
let mut g = self.users.lock().unwrap(); let mut g = self.users.lock().unwrap();
g.retain(|u| u.id != user.id); g.retain(|u| u.id != user.id);
@@ -103,18 +119,6 @@ impl UserRepository for TestStore {
} }
Ok(()) Ok(())
} }
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
Ok(vec![])
}
async fn count(&self) -> Result<i64, DomainError> {
Ok(self
.users
.lock()
.unwrap()
.iter()
.filter(|u| u.local)
.count() as i64)
}
} }
#[async_trait] #[async_trait]