From 8ed7f3d5bc7425c13dbcd8609651234c633656e4 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 15 May 2026 13:43:43 +0200 Subject: [PATCH] =?UTF-8?q?refactor(ports):=20CQRS=20split=20=E2=80=94=20U?= =?UTF-8?q?serRepository=20=3D=20UserReader=20+=20UserWriter=20supertrait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/adapters/postgres-search/src/lib.rs | 2 +- crates/adapters/postgres/src/api_key.rs | 2 +- crates/adapters/postgres/src/feed.rs | 2 +- crates/adapters/postgres/src/notification.rs | 2 +- crates/adapters/postgres/src/tag.rs | 2 +- crates/adapters/postgres/src/test_helpers.rs | 2 +- crates/adapters/postgres/src/top_friend.rs | 2 +- crates/adapters/postgres/src/user.rs | 109 +++++++++--------- .../src/services/federation_event.rs | 4 +- crates/application/src/use_cases/auth.rs | 4 +- .../src/use_cases/federation_management.rs | 4 +- crates/application/src/use_cases/feed.rs | 6 +- crates/application/src/use_cases/profile.rs | 10 +- crates/application/src/use_cases/social.rs | 10 +- crates/application/src/use_cases/thoughts.rs | 4 +- crates/domain/src/ports.rs | 15 ++- crates/domain/src/testing.rs | 30 ++--- 17 files changed, 113 insertions(+), 97 deletions(-) diff --git a/crates/adapters/postgres-search/src/lib.rs b/crates/adapters/postgres-search/src/lib.rs index 0faec17..22983fe 100644 --- a/crates/adapters/postgres-search/src/lib.rs +++ b/crates/adapters/postgres-search/src/lib.rs @@ -189,7 +189,7 @@ mod tests { thought::{Thought, Visibility}, user::User, }, - ports::{SearchPort, ThoughtRepository, UserRepository}, + ports::{SearchPort, ThoughtRepository, UserWriter}, value_objects::*, }; diff --git a/crates/adapters/postgres/src/api_key.rs b/crates/adapters/postgres/src/api_key.rs index 4d6cca8..18eadc5 100644 --- a/crates/adapters/postgres/src/api_key.rs +++ b/crates/adapters/postgres/src/api_key.rs @@ -93,7 +93,7 @@ mod tests { use super::*; use crate::user::PgUserRepository; use chrono::Utc; - use domain::ports::UserRepository; + use domain::ports::UserWriter; use domain::{models::user::User, value_objects::*}; async fn seed_user(pool: &sqlx::PgPool) -> User { diff --git a/crates/adapters/postgres/src/feed.rs b/crates/adapters/postgres/src/feed.rs index 9519194..871f82b 100644 --- a/crates/adapters/postgres/src/feed.rs +++ b/crates/adapters/postgres/src/feed.rs @@ -338,7 +338,7 @@ mod tests { thought::{Thought, Visibility}, user::User, }, - ports::{ThoughtRepository, UserRepository}, + ports::{ThoughtRepository, UserWriter}, value_objects::*, }; diff --git a/crates/adapters/postgres/src/notification.rs b/crates/adapters/postgres/src/notification.rs index bf4ed6d..337e18d 100644 --- a/crates/adapters/postgres/src/notification.rs +++ b/crates/adapters/postgres/src/notification.rs @@ -137,7 +137,7 @@ mod tests { use super::*; use crate::user::PgUserRepository; use chrono::Utc; - use domain::ports::UserRepository; + use domain::ports::UserWriter; use domain::{ models::{notification::NotificationType, user::User}, value_objects::*, diff --git a/crates/adapters/postgres/src/tag.rs b/crates/adapters/postgres/src/tag.rs index 8fa7451..a39c027 100644 --- a/crates/adapters/postgres/src/tag.rs +++ b/crates/adapters/postgres/src/tag.rs @@ -132,7 +132,7 @@ impl TagRepository for PgTagRepository { mod tests { use super::*; use crate::{thought::PgThoughtRepository, user::PgUserRepository}; - use domain::ports::{ThoughtRepository, UserRepository}; + use domain::ports::{ThoughtRepository, UserWriter}; use domain::{ models::{ thought::{Thought, Visibility}, diff --git a/crates/adapters/postgres/src/test_helpers.rs b/crates/adapters/postgres/src/test_helpers.rs index 712ee5a..ea0262d 100644 --- a/crates/adapters/postgres/src/test_helpers.rs +++ b/crates/adapters/postgres/src/test_helpers.rs @@ -4,7 +4,7 @@ use domain::{ thought::{Thought, Visibility}, user::User, }, - ports::{ThoughtRepository, UserRepository}, + ports::{ThoughtRepository, UserWriter}, value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, }; diff --git a/crates/adapters/postgres/src/top_friend.rs b/crates/adapters/postgres/src/top_friend.rs index a580c9e..0528e18 100644 --- a/crates/adapters/postgres/src/top_friend.rs +++ b/crates/adapters/postgres/src/top_friend.rs @@ -107,7 +107,7 @@ impl TopFriendRepository for PgTopFriendRepository { mod tests { use super::*; use crate::user::PgUserRepository; - use domain::ports::UserRepository; + use domain::ports::UserWriter; use domain::{models::user::User, value_objects::*}; async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { diff --git a/crates/adapters/postgres/src/user.rs b/crates/adapters/postgres/src/user.rs index 3d5bf93..395150e 100644 --- a/crates/adapters/postgres/src/user.rs +++ b/crates/adapters/postgres/src/user.rs @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; use domain::{ errors::DomainError, models::{feed::UserSummary, user::User}, - ports::UserRepository, + ports::{UserReader, UserWriter}, value_objects::{Email, PasswordHash, UserId, Username}, }; use sqlx::PgPool; @@ -58,7 +58,7 @@ pub const USER_SELECT: &str = custom_css,local,created_at,updated_at FROM users"; #[async_trait] -impl UserRepository for PgUserRepository { +impl UserReader for PgUserRepository { async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id=$1")) .bind(id.as_uuid()) @@ -86,6 +86,60 @@ impl UserRepository for PgUserRepository { .map(|o| o.map(User::from)) } + async fn list_with_stats(&self) -> Result, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + username: String, + display_name: Option, + avatar_url: Option, + bio: Option, + 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 { + 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> { sqlx::query( "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() .map(|_| ()) } - - async fn list_with_stats(&self) -> Result, DomainError> { - #[derive(sqlx::FromRow)] - struct Row { - id: uuid::Uuid, - username: String, - display_name: Option, - avatar_url: Option, - bio: Option, - 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 { - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE local = true") - .fetch_one(&self.pool) - .await - .into_domain() - } } #[cfg(test)] diff --git a/crates/application/src/services/federation_event.rs b/crates/application/src/services/federation_event.rs index 9cf4c76..3424068 100644 --- a/crates/application/src/services/federation_event.rs +++ b/crates/application/src/services/federation_event.rs @@ -2,14 +2,14 @@ use domain::{ errors::DomainError, events::DomainEvent, models::thought::Visibility, - ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserRepository}, + ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserReader}, value_objects::ThoughtId, }; use std::sync::Arc; pub struct FederationEventService { pub thoughts: Arc, - pub users: Arc, + pub users: Arc, pub ap: Arc, pub base_url: String, pub ap_repo: Arc, diff --git a/crates/application/src/use_cases/auth.rs b/crates/application/src/use_cases/auth.rs index 2b74517..5697a3a 100644 --- a/crates/application/src/use_cases/auth.rs +++ b/crates/application/src/use_cases/auth.rs @@ -2,7 +2,7 @@ use domain::{ errors::DomainError, events::DomainEvent, models::user::User, - ports::{AuthService, EventPublisher, PasswordHasher, UserRepository}, + ports::{AuthService, EventPublisher, PasswordHasher, UserReader, UserRepository}, value_objects::{Email, UserId, Username}, }; @@ -58,7 +58,7 @@ pub struct LoginOutput { } pub async fn login( - users: &dyn UserRepository, + users: &dyn UserReader, hasher: &dyn PasswordHasher, auth: &dyn AuthService, input: LoginInput, diff --git a/crates/application/src/use_cases/federation_management.rs b/crates/application/src/use_cases/federation_management.rs index e60f659..fb4193b 100644 --- a/crates/application/src/use_cases/federation_management.rs +++ b/crates/application/src/use_cases/federation_management.rs @@ -7,7 +7,7 @@ use domain::{ }, ports::{ ActivityPubRepository, EventPublisher, FederationActionPort, FederationSchedulerPort, - FeedRepository, FollowRepository, RemoteActorConnectionRepository, UserRepository, + FeedRepository, FollowRepository, RemoteActorConnectionRepository, UserReader, }, value_objects::UserId, }; @@ -61,7 +61,7 @@ pub async fn list_remote_following( pub async fn remove_remote_following( follows: &dyn FollowRepository, - users: &dyn UserRepository, + users: &dyn UserReader, federation: &dyn FederationActionPort, events: &dyn EventPublisher, user_id: &UserId, diff --git a/crates/application/src/use_cases/feed.rs b/crates/application/src/use_cases/feed.rs index c032de0..3275a1a 100644 --- a/crates/application/src/use_cases/feed.rs +++ b/crates/application/src/use_cases/feed.rs @@ -4,7 +4,7 @@ use domain::{ feed::{FeedEntry, PageParams, Paginated, UserSummary}, user::User, }, - ports::{FeedRepository, FollowRepository, TagRepository, UserRepository}, + ports::{FeedRepository, FollowRepository, TagRepository, UserReader}, value_objects::UserId, }; @@ -70,12 +70,12 @@ pub async fn search( feed.search(query, &page, viewer_id).await } -pub async fn list_users(users: &dyn UserRepository) -> Result, DomainError> { +pub async fn list_users(users: &dyn UserReader) -> Result, DomainError> { users.list_with_stats().await } pub async fn list_users_paginated( - users: &dyn UserRepository, + users: &dyn UserReader, page: PageParams, ) -> Result, DomainError> { let all = users.list_with_stats().await?; diff --git a/crates/application/src/use_cases/profile.rs b/crates/application/src/use_cases/profile.rs index 5ed9057..dbf2262 100644 --- a/crates/application/src/use_cases/profile.rs +++ b/crates/application/src/use_cases/profile.rs @@ -4,11 +4,11 @@ use domain::{ errors::DomainError, events::DomainEvent, models::{top_friend::TopFriend, user::User}, - ports::{EventPublisher, TopFriendRepository, UserRepository}, + ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter}, value_objects::{UserId, Username}, }; -pub async fn get_user(users: &dyn UserRepository, user_id: &UserId) -> Result { +pub async fn get_user(users: &dyn UserReader, user_id: &UserId) -> Result { users .find_by_id(user_id) .await? @@ -16,7 +16,7 @@ pub async fn get_user(users: &dyn UserRepository, user_id: &UserId) -> Result Result { 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. pub async fn get_user_by_id_or_username( - users: &dyn UserRepository, + users: &dyn UserReader, id_or_username: &str, ) -> Result { 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)] pub async fn update_profile( - users: &dyn UserRepository, + users: &dyn UserWriter, events: &dyn EventPublisher, user_id: &UserId, display_name: Option, diff --git a/crates/application/src/use_cases/social.rs b/crates/application/src/use_cases/social.rs index b594bef..27663f7 100644 --- a/crates/application/src/use_cases/social.rs +++ b/crates/application/src/use_cases/social.rs @@ -5,7 +5,7 @@ use domain::{ models::social::{Block, Boost, Follow, FollowState, Like}, ports::{ BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository, - LikeRepository, UserRepository, + LikeRepository, UserReader, }, value_objects::{BoostId, LikeId, ThoughtId, UserId, Username}, }; @@ -92,7 +92,7 @@ pub async fn unboost_thought( pub async fn follow_actor( follows: &dyn FollowRepository, - users: &dyn UserRepository, + users: &dyn UserReader, federation: &dyn FederationActionPort, events: &dyn EventPublisher, follower_id: &UserId, @@ -139,7 +139,7 @@ pub async fn follow_user( pub async fn unfollow_actor( follows: &dyn FollowRepository, - users: &dyn UserRepository, + users: &dyn UserReader, federation: &dyn FederationActionPort, events: &dyn EventPublisher, follower_id: &UserId, @@ -212,7 +212,7 @@ pub async fn reject_follow( pub async fn block_by_username( blocks: &dyn BlockRepository, - users: &dyn UserRepository, + users: &dyn UserReader, events: &dyn EventPublisher, blocker_id: &UserId, username: &str, @@ -227,7 +227,7 @@ pub async fn block_by_username( pub async fn unblock_by_username( blocks: &dyn BlockRepository, - users: &dyn UserRepository, + users: &dyn UserReader, events: &dyn EventPublisher, blocker_id: &UserId, username: &str, diff --git a/crates/application/src/use_cases/thoughts.rs b/crates/application/src/use_cases/thoughts.rs index 406fc4d..f9838b9 100644 --- a/crates/application/src/use_cases/thoughts.rs +++ b/crates/application/src/use_cases/thoughts.rs @@ -2,7 +2,7 @@ use domain::{ errors::DomainError, events::DomainEvent, models::thought::{Thought, Visibility}, - ports::{EventPublisher, TagRepository, ThoughtRepository, UserRepository}, + ports::{EventPublisher, TagRepository, ThoughtRepository, UserReader}, value_objects::{Content, ThoughtId, UserId}, }; @@ -51,7 +51,7 @@ pub struct CreateThoughtOutput { pub async fn create_thought( thoughts: &dyn ThoughtRepository, - _users: &dyn UserRepository, + _users: &dyn UserReader, tags: &dyn TagRepository, events: &dyn EventPublisher, input: CreateThoughtInput, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 707517a..03b48c6 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -45,10 +45,16 @@ pub trait EventConsumer: Send + Sync { } #[async_trait] -pub trait UserRepository: Send + Sync { +pub trait UserReader: Send + Sync { async fn find_by_id(&self, id: &UserId) -> Result, DomainError>; async fn find_by_username(&self, username: &Username) -> Result, DomainError>; async fn find_by_email(&self, email: &Email) -> Result, DomainError>; + async fn list_with_stats(&self) -> Result, DomainError>; + async fn count(&self) -> Result; +} + +#[async_trait] +pub trait UserWriter: Send + Sync { async fn save(&self, user: &User) -> Result<(), DomainError>; async fn update_profile( &self, @@ -59,10 +65,13 @@ pub trait UserRepository: Send + Sync { header_url: Option, custom_css: Option, ) -> Result<(), DomainError>; - async fn list_with_stats(&self) -> Result, DomainError>; - async fn count(&self) -> Result; } +/// Combined supertrait — `AppState.users` stays `Arc`. +/// Blanket impl: any type implementing both sub-traits gets `UserRepository` for free. +pub trait UserRepository: UserReader + UserWriter {} +impl UserRepository for T {} + #[async_trait] pub trait ThoughtRepository: Send + Sync { async fn save(&self, thought: &Thought) -> Result<(), DomainError>; diff --git a/crates/domain/src/testing.rs b/crates/domain/src/testing.rs index 442bda2..4aa7e3b 100644 --- a/crates/domain/src/testing.rs +++ b/crates/domain/src/testing.rs @@ -45,7 +45,7 @@ pub struct TestStore { } #[async_trait] -impl UserRepository for TestStore { +impl UserReader for TestStore { async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { Ok(self .users @@ -73,6 +73,22 @@ impl UserRepository for TestStore { .find(|u| u.email.as_str() == email.as_str()) .cloned()) } + async fn list_with_stats(&self) -> Result, DomainError> { + Ok(vec![]) + } + async fn count(&self) -> Result { + 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> { let mut g = self.users.lock().unwrap(); g.retain(|u| u.id != user.id); @@ -103,18 +119,6 @@ impl UserRepository for TestStore { } Ok(()) } - async fn list_with_stats(&self) -> Result, DomainError> { - Ok(vec![]) - } - async fn count(&self) -> Result { - Ok(self - .users - .lock() - .unwrap() - .iter() - .filter(|u| u.local) - .count() as i64) - } } #[async_trait]