44 KiB
Domain & Application Refactor Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Clean up 11 architectural problems in crates/domain/ and crates/application/ — from most critical to least — applying CQRS port splits, removing AP infrastructure leakage from the domain, making invalid states unrepresentable, and eliminating pass-through noise.
Architecture: Hexagonal (ports & adapters). Domain knows nothing about HTTP, SQL, or ActivityPub. Application orchestrates domain via ports. Adapters implement ports. CQRS: every repository is split into a read-side trait and a write-side trait; the concrete adapter implements both; a combined supertrait keeps AppState unchanged.
Tech Stack: Rust, Tokio, SQLx, Axum. All changes must pass cargo check --workspace and the pre-commit hook (cargo fmt && cargo clippy).
Phase 1 — Remove AP Infrastructure from the Domain (most critical)
These four tasks are the most invasive. They must land together in one commit because they form a closed loop of changes.
Task 1: Add AP lookup accessors to ActivityPubRepository
The domain models User and Thought currently carry AP-specific fields (ap_id, inbox_url, in_reply_to_url). Before we can remove those fields, the services that currently read them need another way to get the data. This task adds that other way.
Files:
-
Modify:
crates/domain/src/ports.rs -
Modify:
crates/adapters/postgres/src/activitypub.rs -
Modify:
crates/domain/src/testing.rs -
Step 1: Add
ActorApUrlsstruct and two new methods toActivityPubRepositoryinports.rs
In crates/domain/src/ports.rs, after the OutboxEntry struct (~line 336), add:
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
#[derive(Debug, Clone)]
pub struct ActorApUrls {
pub ap_id: String,
pub inbox_url: String,
}
Then inside pub trait ActivityPubRepository, add after count_local_notes:
/// Return the ActivityPub object URL for a thought, if one is stored.
/// Returns None for local thoughts (caller constructs the URL from base_url + thought_id).
async fn get_thought_ap_id(
&self,
thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError>;
/// Return the AP actor URL and inbox URL for a user, if stored.
/// Returns None for users that have not been federated.
async fn get_actor_ap_urls(
&self,
user_id: &UserId,
) -> Result<Option<ActorApUrls>, DomainError>;
- Step 2: Implement both methods in
PgActivityPubRepository
In crates/adapters/postgres/src/activitypub.rs, add the two implementations:
async fn get_thought_ap_id(
&self,
thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError> {
sqlx::query_scalar::<_, String>(
"SELECT ap_id FROM thoughts WHERE id = $1 AND ap_id IS NOT NULL",
)
.bind(thought_id.as_uuid())
.fetch_optional(&self.pool)
.await
.into_domain()
}
async fn get_actor_ap_urls(
&self,
user_id: &UserId,
) -> Result<Option<ActorApUrls>, DomainError> {
sqlx::query_as::<_, (String, String)>(
"SELECT ap_id, inbox_url FROM users \
WHERE id = $1 AND ap_id IS NOT NULL AND inbox_url IS NOT NULL",
)
.bind(user_id.as_uuid())
.fetch_optional(&self.pool)
.await
.into_domain()
.map(|opt| {
opt.map(|(ap_id, inbox_url)| ActorApUrls { ap_id, inbox_url })
})
}
- Step 3: Add stub implementations in
TestStore
In crates/domain/src/testing.rs, add to the ActivityPubRepository impl for TestStore:
async fn get_thought_ap_id(
&self,
_thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError> {
Ok(None)
}
async fn get_actor_ap_urls(
&self,
_user_id: &UserId,
) -> Result<Option<ActorApUrls>, DomainError> {
Ok(None)
}
- Step 4: Compile check
cargo check --workspace 2>&1 | head -30
Expected: 0 errors.
Task 2: Remove AP fields from User and Thought domain models
Now that the AP lookup methods exist, remove the AP-specific fields from the domain models and update all consumers.
Files:
-
Modify:
crates/domain/src/models/user.rs -
Modify:
crates/domain/src/models/thought.rs -
Modify:
crates/adapters/postgres/src/user.rs(UserRow → User mapping) -
Modify:
crates/adapters/postgres/src/thought.rs(ThoughtRow → Thought mapping) -
Modify:
crates/adapters/postgres/src/feed.rs(FeedRow → FeedEntry mapping) -
Modify:
crates/adapters/postgres-search/src/lib.rs(FeedRow → FeedEntry mapping) -
Modify:
crates/application/src/services/federation_event.rs -
Modify:
crates/domain/src/testing.rs -
Step 1: Remove from
Usermodel
In crates/domain/src/models/user.rs, remove ap_id and inbox_url fields:
#[derive(Debug, Clone)]
pub struct User {
pub id: UserId,
pub username: Username,
pub email: Email,
pub password_hash: PasswordHash,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub header_url: Option<String>,
pub custom_css: Option<String>,
pub local: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl User {
pub fn new_local(
id: UserId,
username: Username,
email: Email,
password_hash: PasswordHash,
) -> Self {
let now = Utc::now();
Self {
id,
username,
email,
password_hash,
display_name: None,
bio: None,
avatar_url: None,
header_url: None,
custom_css: None,
local: true,
created_at: now,
updated_at: now,
}
}
}
- Step 2: Remove from
Thoughtmodel
In crates/domain/src/models/thought.rs, remove ap_id and in_reply_to_url fields:
#[derive(Debug, Clone)]
pub struct Thought {
pub id: ThoughtId,
pub user_id: UserId,
pub content: Content,
pub in_reply_to_id: Option<ThoughtId>,
pub visibility: Visibility,
pub content_warning: Option<String>,
pub sensitive: bool,
pub local: bool,
pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
}
impl Thought {
pub fn new_local(
id: ThoughtId,
user_id: UserId,
content: Content,
in_reply_to_id: Option<ThoughtId>,
visibility: Visibility,
content_warning: Option<String>,
sensitive: bool,
) -> Self {
Self {
id,
user_id,
content,
in_reply_to_id,
visibility,
content_warning,
sensitive,
local: true,
created_at: Utc::now(),
updated_at: None,
}
}
}
- Step 3: Update
UserRow → Usermapping inpostgres/src/user.rs
Remove the ap_id and inbox_url fields from UserRow and its From<UserRow> for User impl:
#[derive(sqlx::FromRow)]
pub struct UserRow {
pub id: uuid::Uuid,
pub username: String,
pub email: String,
pub password_hash: String,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub header_url: Option<String>,
pub custom_css: Option<String>,
pub local: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<UserRow> for User {
fn from(r: UserRow) -> Self {
User {
id: UserId::from_uuid(r.id),
username: Username::from_trusted(r.username),
email: Email::from_trusted(r.email),
password_hash: PasswordHash(r.password_hash),
display_name: r.display_name,
bio: r.bio,
avatar_url: r.avatar_url,
header_url: r.header_url,
custom_css: r.custom_css,
local: r.local,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}
Update USER_SELECT to remove the columns:
pub const USER_SELECT: &str =
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
custom_css,local,created_at,updated_at FROM users";
- Step 4: Update
ThoughtRow → Thoughtmapping inpostgres/src/thought.rs
Remove ap_id and in_reply_to_url from ThoughtRow and its From impl. The ap_id column stays in the DB — we just don't pull it into the domain model via this path. The SELECT query changes too:
#[derive(sqlx::FromRow)]
struct ThoughtRow {
pub id: uuid::Uuid,
pub user_id: uuid::Uuid,
pub content: String,
pub in_reply_to_id: Option<uuid::Uuid>,
pub visibility: String,
pub content_warning: Option<String>,
pub sensitive: bool,
pub local: bool,
pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
}
Update the SELECT constant and From impl to match (remove ap_id, in_reply_to_url from both). Also update the save method — remove the ap_id binding from INSERT. The ap_id column in the DB gets set to NULL for local thoughts; remote thoughts' ap_id is set by accept_note in the AP adapter.
- Step 5: Update
FeedRowmappings infeed.rsandpostgres-search/lib.rs
In postgres/src/feed.rs, remove t_ap_id and in_reply_to_url from FeedRow and from the SELECT query. In postgres-search/src/lib.rs, same removal.
The FeedEntry.thought field is now a Thought without ap_id. Adapters that need AP context use ActivityPubRepository.
- Step 6: Update
FederationEventServiceto use AP lookups
In crates/application/src/services/federation_event.rs, replace all reads of thought.ap_id and user.inbox_url with calls to self.ap_repo:
Replace the object_ap_id helper:
async fn object_ap_id(&self, thought: &Thought, thought_id: &ThoughtId) -> Result<String, DomainError> {
if !thought.local {
if let Some(ap_id) = self.ap_repo.get_thought_ap_id(thought_id).await? {
return Ok(ap_id);
}
}
Ok(format!("{}/thoughts/{}", self.base_url, thought_id))
}
Note: this method is now async. Update all callers to .await.
For ThoughtCreated — the in_reply_to_url resolution changes. Instead of reading thought.ap_id of the parent, call self.ap_repo.get_thought_ap_id(reply_id).await? and fall back to constructing the URL.
For LikeAdded / LikeRemoved — replace thought.ap_id.is_some() check and thought.ap_id.unwrap() / user.inbox_url.unwrap() with:
let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
Some(id) => id,
None => return Ok(()), // local thought — no federation needed
};
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
Some(u) => u,
None => return Ok(()),
};
self.ap.broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url).await
For BoostAdded / BoostRemoved — same pattern, use object_ap_id (now async).
- Step 7: Update
TestStorefor fields removed from models
In crates/domain/src/testing.rs, update any place that constructs User or Thought with ap_id/inbox_url/in_reply_to_url fields. These fields no longer exist on the structs.
- Step 8: Compile check
cargo check --workspace 2>&1 | head -40
Fix all errors before moving on.
- Step 9: Commit
git add -p
git commit -m "refactor(domain): remove AP fields from User and Thought; use ActivityPubRepository lookups"
Task 3: Remove AP infrastructure events from DomainEvent; add FederationSchedulerPort
FetchRemoteActorPosts and FetchActorConnections in DomainEvent carry raw AP URLs — infrastructure, not domain. Replace them with a narrow port that the application layer calls directly.
Files:
-
Modify:
crates/domain/src/events.rs -
Modify:
crates/domain/src/ports.rs -
Modify:
crates/domain/src/testing.rs -
Modify:
crates/application/src/use_cases/federation_management.rs -
Modify:
crates/application/src/services/federation_event.rs -
Modify:
crates/adapters/activitypub-base/src/service.rs(implements the new port) -
Modify:
crates/presentation/src/state.rs -
Modify:
crates/bootstrap/src/factory.rs -
Step 1: Add
FederationSchedulerPorttoports.rs
#[async_trait]
pub trait FederationSchedulerPort: Send + Sync {
async fn schedule_actor_posts_fetch(
&self,
actor_ap_url: &str,
outbox_url: &str,
) -> Result<(), DomainError>;
async fn schedule_connections_fetch(
&self,
actor_ap_url: &str,
collection_url: &str,
connection_type: &str,
page: u32,
) -> Result<(), DomainError>;
}
- Step 2: Remove the two AP events from
events.rs
In crates/domain/src/events.rs, delete the FetchRemoteActorPosts and FetchActorConnections variants entirely. The enum becomes:
#[derive(Debug, Clone)]
pub enum DomainEvent {
ThoughtCreated { thought_id: ThoughtId, user_id: UserId, in_reply_to_id: Option<ThoughtId> },
ThoughtDeleted { thought_id: ThoughtId, user_id: UserId },
ThoughtUpdated { thought_id: ThoughtId, user_id: UserId },
LikeAdded { like_id: LikeId, user_id: UserId, thought_id: ThoughtId },
LikeRemoved { user_id: UserId, thought_id: ThoughtId },
BoostAdded { boost_id: BoostId, user_id: UserId, thought_id: ThoughtId },
BoostRemoved { user_id: UserId, thought_id: ThoughtId },
FollowRequested { follower_id: UserId, following_id: UserId },
FollowAccepted { follower_id: UserId, following_id: UserId },
FollowRejected { follower_id: UserId, following_id: UserId },
Unfollowed { follower_id: UserId, following_id: UserId },
UserBlocked { blocker_id: UserId, blocked_id: UserId },
UserUnblocked { blocker_id: UserId, blocked_id: UserId },
UserRegistered { user_id: UserId },
ProfileUpdated { user_id: UserId },
MentionReceived { thought_id: ThoughtId, mentioned_user_id: UserId, author_user_id: UserId },
}
- Step 3: Update
federation_management.rsto call the scheduler port directly
In crates/application/src/use_cases/federation_management.rs, add a scheduler: &dyn FederationSchedulerPort parameter to get_remote_actor_posts and get_actor_connections_page, and call it instead of publishing an event:
pub async fn get_remote_actor_posts(
federation: &dyn FederationActionPort,
ap_repo: &dyn ActivityPubRepository,
feed: &dyn FeedRepository,
scheduler: &dyn FederationSchedulerPort, // NEW
handle: &str,
page: PageParams,
viewer_id: Option<&UserId>,
) -> Result<(Paginated<FeedEntry>, bool), DomainError> {
let actor = federation.lookup_actor(handle).await?;
let actor_url = url::Url::parse(&actor.url)
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
let author_id = match ap_repo.find_remote_actor_id(&actor_url).await? {
Some(id) => id,
None => ap_repo.intern_remote_actor(&actor_url).await?,
};
let result = get_user_feed(feed, &author_id, page, viewer_id).await?;
// Schedule background fetch if actor has outbox
if let Some(ref outbox_url) = actor.outbox_url {
let _ = scheduler
.schedule_actor_posts_fetch(&actor.url, outbox_url)
.await;
}
let has_more = !result.items.is_empty();
Ok((result, has_more))
}
pub async fn get_actor_connections_page(
federation: &dyn FederationActionPort,
connections: &dyn RemoteActorConnectionRepository,
scheduler: &dyn FederationSchedulerPort, // NEW
handle: &str,
connection_type: &str,
page: u32,
) -> Result<(Vec<ActorConnectionSummary>, bool), DomainError> {
// ... existing lookup logic ...
// Replace event publish with:
if stale {
let _ = scheduler
.schedule_connections_fetch(&actor.url, &collection_url, connection_type, page)
.await;
}
// ...
}
- Step 4: Update
FederationEventService— remove the two AP event arms
In federation_event.rs, delete the DomainEvent::FetchRemoteActorPosts and DomainEvent::FetchActorConnections match arms. The service no longer handles infrastructure fetch commands.
Also remove the federation_action and remote_actor_connections fields from FederationEventService if they're only used by those two removed arms. Check: they're also used nowhere else. Remove them from the struct definition.
- Step 5: Implement
FederationSchedulerPortinActivityPubService
In crates/adapters/activitypub-base/src/service.rs, add an impl:
#[async_trait::async_trait]
impl domain::ports::FederationSchedulerPort for ActivityPubService {
async fn schedule_actor_posts_fetch(
&self,
actor_ap_url: &str,
outbox_url: &str,
) -> Result<(), domain::errors::DomainError> {
// Re-use the existing event publisher to publish the background task,
// but now this is an adapter concern, not a domain event.
self.events
.publish(&domain::events::DomainEvent::ThoughtCreated {
// ... actually: use a separate internal channel or directly spawn
})
.await
}
}
Note: The exact implementation depends on how the worker picks up background tasks. If the worker currently subscribes to DomainEvent, and we've removed these variants, the worker needs a new subscription path. The simplest compatible approach: have ActivityPubService keep an internal sender for these tasks (the existing NATS/channel mechanism), and the worker subscribes to that same mechanism. Read crates/adapters/nats/src/lib.rs to understand the existing plumbing, then implement accordingly.
- Step 6: Add
FederationSchedulerPorttoAppStateandTestStore
AppState (crates/presentation/src/state.rs):
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
TestStore (crates/domain/src/testing.rs) — add a no-op impl:
#[async_trait]
impl FederationSchedulerPort for TestStore {
async fn schedule_actor_posts_fetch(&self, _: &str, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn schedule_connections_fetch(&self, _: &str, _: &str, _: &str, _: u32) -> Result<(), DomainError> {
Ok(())
}
}
- Step 7: Wire up in
bootstrap/src/factory.rs
Add federation_scheduler: Arc::new(infra.ap_service.clone()) to the AppState construction (assuming ActivityPubService implements the port).
- Step 8: Update handlers that call
get_remote_actor_posts/get_actor_connections_page
In crates/presentation/src/handlers/federation_actors.rs, pass &*s.federation_scheduler to the new parameter.
- Step 9: Compile check
cargo check --workspace 2>&1 | head -40
- Step 10: Commit
git commit -m "refactor(domain): remove FetchRemoteActorPosts/FetchActorConnections from DomainEvent; add FederationSchedulerPort"
Phase 2 — CQRS Port Split
Task 4: Split UserRepository into UserReader + UserWriter
Files:
-
Modify:
crates/domain/src/ports.rs -
Modify:
crates/adapters/postgres/src/user.rs -
Modify:
crates/domain/src/testing.rs -
Modify:
crates/application/src/use_cases/*.rs(update function signatures) -
Step 1: Define
UserReaderandUserWriterinports.rs
Replace the existing UserRepository trait with three traits:
#[async_trait]
pub trait UserReader: Send + Sync {
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_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 update_profile(
&self,
user_id: &UserId,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError>;
}
/// Combined supertrait — kept so `AppState` needs no change.
/// Postgres adapter implements all three; use cases declare the narrower bound they need.
pub trait UserRepository: UserReader + UserWriter {}
impl<T: UserReader + UserWriter> UserRepository for T {}
- Step 2:
PgUserRepositoryimplementsUserReaderandUserWriterseparately
In crates/adapters/postgres/src/user.rs, split the single impl UserRepository into:
#[async_trait]
impl UserReader for PgUserRepository {
// all read methods
}
#[async_trait]
impl UserWriter for PgUserRepository {
// save, update_profile
}
The blanket impl in ports.rs makes PgUserRepository: UserRepository automatically.
- Step 3: Update use case function signatures to declare the narrowest bound they need
In each use case file, change users: &dyn UserRepository to the narrowest applicable bound:
| Use case | New bound |
|---|---|
get_user, get_user_by_username, get_user_by_id_or_username, get_top_friends |
&dyn UserReader |
register_user |
&dyn UserWriter |
update_profile |
&dyn UserWriter (for the write) + &dyn UserReader (to fetch back) |
follow_actor, unfollow_actor, block_by_username |
&dyn UserReader (lookup only) |
Handlers still pass &*s.users (an Arc<dyn UserRepository>), which satisfies any narrower bound via Rust's trait coercion.
- Step 4: Update
TestStore— split its user impl to satisfy both traits
#[async_trait]
impl UserReader for TestStore { /* existing read methods */ }
#[async_trait]
impl UserWriter for TestStore { /* existing write methods */ }
- Step 5: Compile check
cargo check --workspace 2>&1 | head -30
- Step 6: Commit
git commit -m "refactor(ports): CQRS split — UserReader + UserWriter supertrait"
Task 5: Split FederationActionPort into four focused sub-ports
Files:
-
Modify:
crates/domain/src/ports.rs -
Modify:
crates/domain/src/testing.rs -
Modify:
crates/adapters/activitypub-base/src/service.rs -
Modify:
crates/application/src/use_cases/federation_management.rs -
Modify:
crates/application/src/use_cases/social.rs -
Modify:
crates/application/src/use_cases/profile.rs -
Modify:
crates/presentation/src/state.rs -
Step 1: Define the four sub-ports in
ports.rs
Replace FederationActionPort with:
#[async_trait]
pub trait FederationLookupPort: Send + Sync {
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError>;
async fn followers_collection_json(&self, user_id: &UserId, page: Option<u32>) -> Result<String, DomainError>;
async fn following_collection_json(&self, user_id: &UserId, page: Option<u32>) -> Result<String, DomainError>;
}
#[async_trait]
pub trait FederationFollowPort: Send + Sync {
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
async fn unfollow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
async fn get_remote_following(&self, user_id: &UserId) -> Result<Vec<RemoteActor>, DomainError>;
}
#[async_trait]
pub trait FederationFollowRequestPort: Send + Sync {
async fn get_pending_followers(&self, user_id: &UserId) -> Result<Vec<RemoteActor>, DomainError>;
async fn accept_follow_request(&self, user_id: &UserId, actor_url: &str) -> Result<(), DomainError>;
async fn reject_follow_request(&self, user_id: &UserId, actor_url: &str) -> Result<(), DomainError>;
async fn get_remote_followers(&self, user_id: &UserId) -> Result<Vec<RemoteActor>, DomainError>;
async fn remove_remote_follower(&self, user_id: &UserId, actor_url: &str) -> Result<(), DomainError>;
}
#[async_trait]
pub trait FederationFetchPort: Send + Sync {
async fn fetch_outbox_page(&self, outbox_url: &str, page: u32) -> Result<Vec<crate::models::remote_note::RemoteNote>, DomainError>;
async fn fetch_actor_urls_from_collection(&self, collection_url: &str) -> Result<Vec<String>, DomainError>;
async fn resolve_actor_profiles(&self, urls: Vec<String>) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary>;
}
/// Combined supertrait — `AppState.federation` stays as a single field.
pub trait FederationActionPort: FederationLookupPort + FederationFollowPort + FederationFollowRequestPort + FederationFetchPort {}
impl<T: FederationLookupPort + FederationFollowPort + FederationFollowRequestPort + FederationFetchPort> FederationActionPort for T {}
- Step 2: Split
ActivityPubServiceimpl into four sub-impls
In crates/adapters/activitypub-base/src/service.rs, replace impl FederationActionPort with four separate impls, each containing only the methods for that sub-port.
- Step 3: Update use case signatures to declare the narrowest port they need
| Use case / handler | New bound |
|---|---|
get_user (AP actor JSON path) |
&dyn FederationLookupPort |
follow_actor, unfollow_actor |
&dyn FederationFollowPort |
get_top_friends (no federation) |
remove federation param if unused |
list_pending_requests, accept/reject, list_remote_followers/following |
&dyn FederationFollowRequestPort |
get_remote_actor_posts, get_actor_connections_page |
&dyn FederationLookupPort + &dyn FederationFetchPort |
remove_remote_following (federation_management) |
&dyn FederationFollowPort |
Handlers pass &*s.federation which satisfies all bounds.
- Step 4: Update
TestStore— split impl into four
#[async_trait]
impl FederationLookupPort for TestStore { /* ... */ }
#[async_trait]
impl FederationFollowPort for TestStore { /* ... */ }
#[async_trait]
impl FederationFollowRequestPort for TestStore { /* ... */ }
#[async_trait]
impl FederationFetchPort for TestStore { /* ... */ }
- Step 5: Compile check
cargo check --workspace 2>&1 | head -40
- Step 6: Commit
git commit -m "refactor(ports): CQRS split — FederationActionPort into four focused sub-ports"
Phase 3 — Domain Model Quality
Task 6: Algebraic Notification type (make invalid states unrepresentable)
Files:
-
Modify:
crates/domain/src/models/notification.rs -
Modify:
crates/domain/src/ports.rs(NotificationRepository::saveparam type stays&Notification) -
Modify:
crates/adapters/postgres/src/notification.rs -
Modify:
crates/application/src/services/notification_event.rs -
Modify:
crates/domain/src/testing.rs -
Step 1: Redefine
Notificationas an algebraic type
Replace crates/domain/src/models/notification.rs entirely:
use crate::value_objects::{NotificationId, ThoughtId, UserId};
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, PartialEq)]
pub enum NotificationKind {
Like { thought_id: ThoughtId, from_user_id: UserId },
Boost { thought_id: ThoughtId, from_user_id: UserId },
Reply { thought_id: ThoughtId, from_user_id: UserId },
Mention { thought_id: ThoughtId, from_user_id: UserId },
Follow { from_user_id: UserId },
}
impl NotificationKind {
pub fn from_user_id(&self) -> &UserId {
match self {
Self::Like { from_user_id, .. } => from_user_id,
Self::Boost { from_user_id, .. } => from_user_id,
Self::Reply { from_user_id, .. } => from_user_id,
Self::Mention { from_user_id, .. } => from_user_id,
Self::Follow { from_user_id } => from_user_id,
}
}
pub fn thought_id(&self) -> Option<&ThoughtId> {
match self {
Self::Like { thought_id, .. } => Some(thought_id),
Self::Boost { thought_id, .. } => Some(thought_id),
Self::Reply { thought_id, .. } => Some(thought_id),
Self::Mention { thought_id, .. } => Some(thought_id),
Self::Follow { .. } => None,
}
}
pub fn kind_str(&self) -> &'static str {
match self {
Self::Like { .. } => "like",
Self::Boost { .. } => "boost",
Self::Reply { .. } => "reply",
Self::Mention { .. } => "mention",
Self::Follow { .. } => "follow",
}
}
}
#[derive(Debug, Clone)]
pub struct Notification {
pub id: NotificationId,
pub user_id: UserId,
pub kind: NotificationKind,
pub read: bool,
pub created_at: DateTime<Utc>,
}
- Step 2: Update
postgres/src/notification.rs
The DB row still has notification_type, from_user_id, thought_id. Add a NotificationRow → Notification conversion that constructs the correct NotificationKind variant:
#[derive(sqlx::FromRow)]
struct NotificationRow {
id: uuid::Uuid,
user_id: uuid::Uuid,
notification_type: String,
from_user_id: Option<uuid::Uuid>,
thought_id: Option<uuid::Uuid>,
read: bool,
created_at: DateTime<Utc>,
}
fn row_to_notification(r: NotificationRow) -> Result<Notification, DomainError> {
let from_user_id = r.from_user_id
.map(UserId::from_uuid)
.ok_or_else(|| DomainError::Internal("notification missing from_user_id".into()))?;
let kind = match r.notification_type.as_str() {
"follow" => NotificationKind::Follow { from_user_id },
other => {
let thought_id = r.thought_id
.map(ThoughtId::from_uuid)
.ok_or_else(|| DomainError::Internal(
format!("notification type '{other}' missing thought_id")
))?;
match other {
"like" => NotificationKind::Like { thought_id, from_user_id },
"boost" => NotificationKind::Boost { thought_id, from_user_id },
"reply" => NotificationKind::Reply { thought_id, from_user_id },
"mention" => NotificationKind::Mention { thought_id, from_user_id },
_ => return Err(DomainError::Internal(
format!("unknown notification type: {other}")
)),
}
}
};
Ok(Notification {
id: NotificationId::from_uuid(r.id),
user_id: UserId::from_uuid(r.user_id),
kind,
read: r.read,
created_at: r.created_at,
})
}
Update save to write the new fields from n.kind:
async fn save(&self, n: &Notification) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO notifications(id,user_id,notification_type,from_user_id,thought_id,read,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT(id) DO NOTHING"
)
.bind(n.id.as_uuid())
.bind(n.user_id.as_uuid())
.bind(n.kind.kind_str())
.bind(n.kind.from_user_id().as_uuid())
.bind(n.kind.thought_id().map(|t| t.as_uuid()))
.bind(n.read)
.bind(n.created_at)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
- Step 3: Update
notification_event.rs(the service that creates notifications)
Read crates/application/src/services/notification_event.rs and update all Notification { notification_type, from_user_id: Some(...), thought_id: Some(...), ... } constructions to use the new NotificationKind enum:
// Before
Notification {
id: NotificationId::new(),
user_id: owner_id.clone(),
notification_type: NotificationType::Like,
from_user_id: Some(liker_id.clone()),
thought_id: Some(thought_id.clone()),
read: false,
created_at: Utc::now(),
}
// After
Notification {
id: NotificationId::new(),
user_id: owner_id.clone(),
kind: NotificationKind::Like { thought_id: thought_id.clone(), from_user_id: liker_id.clone() },
read: false,
created_at: Utc::now(),
}
- Step 4: Update handlers that read
Notificationfields
In crates/presentation/src/handlers/notifications.rs and any response mapping — change .notification_type / .from_user_id / .thought_id reads to pattern match on .kind.
-
Step 5: Remove
NotificationTypeenum (the old one innotification.rs) — it is fully replaced byNotificationKind. -
Step 6: Compile check
cargo check --workspace 2>&1 | head -30
- Step 7: Commit
git commit -m "refactor(domain): algebraic Notification type — invalid states now unrepresentable"
Task 7: Make from_db_str return Result instead of silently defaulting
Files:
-
Modify:
crates/domain/src/models/thought.rs -
Modify:
crates/domain/src/models/social.rs -
Modify:
crates/adapters/postgres/src/thought.rs -
Modify:
crates/adapters/postgres/src/feed.rs -
Modify:
crates/adapters/postgres/src/follow.rs -
Modify:
crates/adapters/postgres-search/src/lib.rs -
Step 1: Change
Visibility::from_db_strto returnResult
In crates/domain/src/models/thought.rs:
impl Visibility {
pub fn as_str(&self) -> &'static str {
match self {
Self::Public => "public",
Self::Followers => "followers",
Self::Unlisted => "unlisted",
Self::Direct => "direct",
}
}
pub fn from_db_str(s: &str) -> Result<Self, crate::errors::DomainError> {
match s {
"public" => Ok(Self::Public),
"followers" => Ok(Self::Followers),
"unlisted" => Ok(Self::Unlisted),
"direct" => Ok(Self::Direct),
other => Err(crate::errors::DomainError::Internal(
format!("unknown visibility value in DB: '{other}'")
)),
}
}
}
- Step 2: Change
FollowState::from_db_strto returnResult
In crates/domain/src/models/social.rs:
impl FollowState {
pub fn as_str(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Accepted => "accepted",
Self::Rejected => "rejected",
}
}
pub fn from_db_str(s: &str) -> Result<Self, crate::errors::DomainError> {
match s {
"pending" => Ok(Self::Pending),
"accepted" => Ok(Self::Accepted),
"rejected" => Ok(Self::Rejected),
other => Err(crate::errors::DomainError::Internal(
format!("unknown follow_state value in DB: '{other}'")
)),
}
}
}
- Step 3: Update all callers
In each postgres adapter file, every call to Visibility::from_db_str(s) or FollowState::from_db_str(s) now returns a Result. Callers use ? to propagate:
// Before
visibility: Visibility::from_db_str(&r.visibility),
// After
visibility: Visibility::from_db_str(&r.visibility)?,
Do this in: thought.rs, feed.rs, follow.rs, postgres-search/lib.rs.
- Step 4: Compile check
cargo check --workspace 2>&1 | head -20
- Step 5: Commit
git commit -m "fix(domain): from_db_str returns Result — unknown DB values are now errors, not silent defaults"
Phase 4 — Architecture Cleanup
Task 8: Remove pass-through use cases
Use cases search_thoughts, search_users are single-line delegations with no business logic. Remove them and call the port directly from handlers.
Files:
-
Delete:
crates/application/src/use_cases/search.rs -
Modify:
crates/application/src/use_cases/mod.rs -
Modify:
crates/presentation/src/handlers/feed.rs -
Step 1: Delete
search.rsand remove frommod.rs
rm crates/application/src/use_cases/search.rs
In crates/application/src/use_cases/mod.rs, remove pub mod search;.
- Step 2: Update
feed.rshandler to call the port directly
In crates/presentation/src/handlers/feed.rs, replace:
use application::use_cases::search::{search_thoughts, search_users};
// ...
search_thoughts(&*s.search, &query, ...).await?
with:
s.search.search_thoughts(&query, &page, viewer.as_ref()).await?
Same for search_users:
s.search.search_users(&query, &page).await?
- Step 3: Compile check and commit
cargo check --workspace 2>&1 | head -20
git commit -m "refactor(application): remove pass-through search use cases — handlers call port directly"
Task 9: Deduplicate actor resolution logic
profile::get_user_by_id_or_username and social::follow_actor/unfollow_actor both do "UUID or username?" routing independently. The social use cases' version (@handle detection) is already correct and distinct, but the presentation layer has a duplicate resolve_user_id that was fixed earlier. Verify no remaining duplication and consolidate if any remains.
Files:
-
Modify:
crates/application/src/use_cases/profile.rs(if needed) -
Modify:
crates/presentation/src/handlers/feed.rs(verify) -
Step 1: Audit current state
Read profile.rs::get_user_by_id_or_username and feed.rs::get_following_handler / get_followers_handler. Confirm feed.rs already calls get_user_by_id_or_username (this was fixed in a prior session).
-
Step 2: Document the two distinct resolution patterns
-
get_user_by_id_or_username— UUID or local username (AP actor URL routing) -
follow_actor—@handle@domainvs local username (AP follow routing)
These are intentionally different. No merge needed. Verify there are no other copies.
grep -rn "parse_str\|parse::<uuid" crates/application/src/ crates/presentation/src/
If no other UUID-parse-then-username-fallback patterns appear outside these two, the task is complete.
- Step 3: Commit if any changes made, otherwise skip
Task 10: Clean up RemoteActor — strip AP-specific fields
RemoteActor in domain/src/models/remote_actor.rs carries AP infrastructure fields (inbox_url, shared_inbox_url, public_key, etc.). The domain layer only cares about the identity of a remote user, not AP delivery details.
Files:
-
Modify:
crates/domain/src/models/remote_actor.rs -
Modify:
crates/domain/src/ports.rs(RemoteActorRepository) -
Modify:
crates/adapters/postgres/src/remote_actor.rs -
Modify:
crates/adapters/activitypub-base/src/repository.rs(has its ownRemoteActor) -
Modify:
crates/presentation/src/handlers/federation_management.rs(response mapping) -
Step 1: Slim down domain
RemoteActor
In crates/domain/src/models/remote_actor.rs, keep only the fields meaningful at the domain level:
use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct RemoteActor {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub bio: Option<String>,
pub banner_url: Option<String>,
pub also_known_as: Option<String>,
pub outbox_url: Option<String>,
pub followers_url: Option<String>,
pub following_url: Option<String>,
pub attachment: Vec<(String, String)>,
pub last_fetched_at: DateTime<Utc>,
}
Removed: inbox_url, shared_inbox_url, public_key.
- Step 2: Update
RemoteActorRepositoryport if it uses the removed fields
The upsert and find_by_url methods accept/return the slimmed model. Verify the postgres remote_actor.rs adapter maps correctly (the DB still has inbox_url column; just don't pull it into the domain model).
- Step 3: Fix compilation errors from removed fields
The main consumer is FederationActionPort.lookup_actor() which returns RemoteActor. The AP adapter (ActivityPubService) constructs RemoteActor from fetched data — update it to not set the removed fields.
InboxUrl and shared_inbox_url are needed by the AP delivery layer (OutboundFederationPort). Those should use the activitypub-base's own RemoteActor struct (in repository.rs) instead of the domain one.
- Step 4: Compile check
cargo check --workspace 2>&1 | head -30
- Step 5: Commit
git commit -m "refactor(domain): remove AP delivery fields from RemoteActor domain model"
Task 11: Change ActivityPubRepository port params from url::Url to &str
The domain port currently requires callers to construct url::Url — an external library type — before calling methods. Use &str at the port boundary; let the adapter parse.
Files:
-
Modify:
crates/domain/src/ports.rs(ActivityPubRepositorytrait) -
Modify:
crates/adapters/postgres/src/activitypub.rs -
Modify:
crates/application/src/services/federation_event.rs -
Modify:
crates/adapters/activitypub/src/handler.rs -
Step 1: Change method signatures in
ActivityPubRepository
In ports.rs, change:
async fn find_remote_actor_id(&self, actor_ap_url: &url::Url) -> Result<Option<UserId>, DomainError>;
async fn intern_remote_actor(&self, actor_ap_url: &url::Url) -> Result<UserId, DomainError>;
async fn accept_note(&self, ap_id: &url::Url, ..., in_reply_to: Option<&url::Url>) -> Result<(), DomainError>;
async fn apply_note_update(&self, ap_id: &url::Url, ...) -> Result<(), DomainError>;
async fn retract_note(&self, ap_id: &url::Url) -> Result<(), DomainError>;
async fn retract_actor_notes(&self, actor_ap_url: &url::Url) -> Result<(), DomainError>;
async fn get_thought_ap_id(&self, thought_id: &ThoughtId) -> Result<Option<String>, DomainError>;
async fn get_actor_ap_urls(&self, user_id: &UserId) -> Result<Option<ActorApUrls>, DomainError>;
To:
async fn find_remote_actor_id(&self, actor_ap_url: &str) -> Result<Option<UserId>, DomainError>;
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError>;
async fn accept_note(&self, ap_id: &str, ..., in_reply_to: Option<&str>) -> Result<(), DomainError>;
async fn apply_note_update(&self, ap_id: &str, ...) -> Result<(), DomainError>;
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>;
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>;
// get_thought_ap_id and get_actor_ap_urls already use &ThoughtId/&UserId — no change needed
- Step 2: Update
PgActivityPubRepository— parse URL internally
In postgres/src/activitypub.rs, each method now receives &str and uses it directly in the SQL bind (URLs are stored as TEXT). Remove the .as_str() calls.
- Step 3: Update callers — remove URL construction
In federation_event.rs and handler.rs, remove url::Url::parse(...) before calling repo methods; pass the &str directly:
// Before
let actor_url = url::Url::parse(actor_ap_url)?;
let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?;
// After
let author_id = self.ap_repo.intern_remote_actor(actor_ap_url).await?;
- Step 4: Compile check
cargo check --workspace 2>&1 | head -20
- Step 5: Commit
git commit -m "refactor(ports): ActivityPubRepository takes &str instead of &url::Url — infra type stays in adapter"
Self-Review
Spec coverage check:
| Issue | Task |
|---|---|
| AP fields in domain models (User, Thought) | Task 1 (new lookup methods) + Task 2 (field removal) |
| DomainEvent contains AP events | Task 3 |
| FederationEventService mixes concerns | Task 3 (removes FetchX arms), Task 1 (removes ap_id reads) |
| Notification algebraic types | Task 6 |
| from_db_str silently defaults | Task 7 |
| CQRS UserRepository | Task 4 |
| CQRS FederationActionPort | Task 5 |
| Pass-through use cases | Task 8 |
| Duplicate actor resolution | Task 9 |
| RemoteActor AP field leakage | Task 10 |
| Port params url::Url → &str | Task 11 |
Gaps found:
FeedEntrycoupling (issue #10 from audit) — deferred. Requires FeedRepository changes and affects all feed handlers. Low risk to leave as-is; worth a separate plan.
No placeholders found. Each task has exact file paths and concrete code.
Type consistency: ActorApUrls introduced in Task 1 is used in Task 2. NotificationKind introduced in Task 6 is used throughout Task 6. All type names consistent.