11 KiB
Thoughts v2 — Architecture Rewrite Design
Context
Thoughts is a federated social web service currently running on a monolithic axum + Sea-ORM backend with no domain layer, no traits, and tightly coupled persistence. v2 is a full rewrite targeting:
- Hexagonal architecture (ports & adapters, zero leakage between layers)
- Full bidirectional ActivityPub federation (Mastodon-compatible Fediverse citizen)
- sqlx with raw SQL — no ORM
- Postgres only (for now), but no coupling to any concrete adapter
- Crate structure mirroring movies-diary (the reference implementation)
- Production data must survive cutover via additive migrations
Crate Structure
crates/
domain/ # entities, value objects, ports (traits), domain events
application/ # use cases (commands + queries), no framework deps
api-types/ # request/response DTOs, shared serializable types
presentation/ # axum handlers, routes, extractors, state, openapi — JSON REST only, no HTML rendering (client is Next.js)
worker/ # event consumer loop, dispatches to event handlers
adapters/
postgres/ # sqlx impls of all repos + migrations/
postgres-search/ # SearchPort via pg_trgm / tsvector
postgres-federation/ # federation-specific queries (known actors, etc.)
activitypub-base/ # copied from movies-diary — signing, WebFinger, NodeInfo
activitypub/ # thoughts-specific AP objects (Note, Person) + activity handlers
auth/ # JWT AuthService impl
nats/ # EventPublisher + EventConsumer via NATS
event-payload/ # serializable event envelope types (NATS wire format)
event-publisher/ # event routing — domain events → NATS subjects
Dependency rule: domain has zero external deps. application depends only on domain. All adapters depend on domain traits only — never on each other. presentation and worker wire concrete adapters into Arc<dyn Port> and inject via state. presentation never imports from postgres directly.
Domain Model
Entities & Value Objects
User — UserId, Username, Email, PasswordHash, DisplayName, Bio,
AvatarUrl, HeaderUrl, local: bool, ap_id: Url,
public_key: String, private_key: Option<String> (None for remote)
Thought — ThoughtId, UserId, Content (≤128 chars local / unlimited remote),
in_reply_to: Option<ThoughtId | RemoteUrl>, ap_id: Url,
visibility: Public|Followers|Unlisted|Direct,
content_warning: Option<String>, sensitive: bool, local: bool
Like — LikeId, UserId, ThoughtId, ap_id: Url
Boost — BoostId, UserId, ThoughtId, ap_id: Url
Follow — FollowerId, FollowingId, state: Pending|Accepted|Rejected, ap_id: Url
Block — BlockerId, BlockedId
Tag — TagId, name
ApiKey — ApiKeyId, UserId, key_hash, name
TopFriend — UserId, FriendId, position (1–8)
RemoteActor — url, handle, display_name, inbox_url, shared_inbox_url, public_key
Ports (traits in domain, implemented by adapters)
UserRepository, ThoughtRepository, LikeRepository, BoostRepository,
FollowRepository, BlockRepository, TagRepository, ApiKeyRepository,
TopFriendRepository, RemoteActorRepository, AuthService, PasswordHasher,
EventPublisher, EventConsumer, SearchPort, SearchCommand
Domain Events
Published after mutations, consumed by worker for federation and side-effects:
ThoughtCreated, ThoughtDeleted, ThoughtUpdated,
LikeAdded, LikeRemoved,
BoostAdded, BoostRemoved,
FollowRequested, FollowAccepted, FollowRejected, Unfollowed,
UserBlocked
Application Layer (Use Cases)
Each use case lives in application/src/use_cases/ and receives only &dyn Port references — no framework types, no sqlx, no axum. Fully testable with mock impls.
Commands (mutate state, publish domain event):
register, login
create_thought, delete_thought, edit_thought
create_reply, delete_reply
like_thought, unlike_thought
boost_thought, unboost_thought
follow_user, unfollow_user, accept_follow, reject_follow
block_user, unblock_user
update_profile, update_top_friends
create_api_key, delete_api_key
handle_inbox ← processes incoming AP activities from remote instances
Queries (read-only, no events):
get_thought, get_thread ← thought + its reply tree
get_home_feed ← thoughts from followed users (local + remote)
get_public_feed ← all local public thoughts
get_user_feed ← one user's public thoughts
get_profile, get_top_friends
get_followers, get_following
list_api_keys
search
get_by_tag
get_notifications
Federation & ActivityPub
activitypub-base/ (copied verbatim from movies-diary) handles: HTTP signatures, WebFinger, NodeInfo, generic actor/inbox/outbox/followers HTTP handlers, remote actor fetching.
activitypub/ wires activitypub-base to the thoughts domain.
Outbound (worker: domain event → AP activity → remote inboxes)
| Domain Event | AP Activity | Destination |
|---|---|---|
| ThoughtCreated | Create(Note) | followers' inboxes |
| ThoughtDeleted | Delete(Note) | followers' inboxes |
| ThoughtUpdated | Update(Note) | followers' inboxes |
| LikeAdded | Like | thought author's inbox |
| LikeRemoved | Undo(Like) | thought author's inbox |
| BoostAdded | Announce | followers' inboxes |
| BoostRemoved | Undo(Announce) | followers' inboxes |
| FollowRequested | Follow | target's inbox |
| FollowAccepted | Accept(Follow) | requester's inbox |
| FollowRejected | Reject(Follow) | requester's inbox |
| Unfollowed | Undo(Follow) | target's inbox |
| UserBlocked | Block | blocked user's inbox |
Inbound (handle_inbox use case)
| Incoming Activity | Use Case invoked |
|---|---|
| Create(Note) | create_thought (remote) |
| Delete | delete_thought (remote) |
| Update(Note) | edit_thought (remote) |
| Like | like_thought (remote) |
| Undo(Like) | unlike_thought (remote) |
| Announce | boost_thought (remote) |
| Undo(Announce) | unboost_thought (remote) |
| Follow | follow_user → auto-accept (public accounts) / pending (locked accounts) |
| Accept(Follow) | accept_follow |
| Reject(Follow) | reject_follow |
| Undo(Follow) | unfollow_user |
| Block | block_user (remote) |
AP Endpoints (in presentation/)
GET /.well-known/webfinger
GET /.well-known/nodeinfo
GET /nodeinfo/2.0
GET /users/:username ← Actor object
GET /users/:username/inbox
POST /users/:username/inbox ← receives remote activities
GET /users/:username/outbox
GET /users/:username/followers
GET /users/:username/following
Database Schema & Migration Strategy
Remote thought caching
likes and boosts reference thought_id UUID REFERENCES thoughts(id). When a local user likes or boosts a remote thought, the remote Note is first fetched and cached as a row in thoughts with local = false. This keeps referential integrity and allows rendering liked/boosted remote content without additional AP lookups.
Migration approach
sqlx migrations/ in adapters/postgres/. First migration recreates existing schema in sqlx format (matching production exactly, preserving all UUIDs). Subsequent migrations are additive only — no destructive changes.
Additive changes to existing tables
-- users: federation
ALTER TABLE users ADD COLUMN ap_id TEXT UNIQUE;
ALTER TABLE users ADD COLUMN inbox_url TEXT;
ALTER TABLE users ADD COLUMN public_key TEXT;
ALTER TABLE users ADD COLUMN private_key TEXT; -- NULL for remote users
ALTER TABLE users ADD COLUMN local BOOLEAN NOT NULL DEFAULT true;
-- thoughts: replies + AP + visibility
ALTER TABLE thoughts ADD COLUMN in_reply_to_id UUID REFERENCES thoughts(id);
ALTER TABLE thoughts ADD COLUMN in_reply_to_url TEXT; -- remote parent
ALTER TABLE thoughts ADD COLUMN ap_id TEXT UNIQUE;
ALTER TABLE thoughts ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public';
ALTER TABLE thoughts ADD COLUMN content_warning TEXT;
ALTER TABLE thoughts ADD COLUMN sensitive BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE thoughts ADD COLUMN local BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE thoughts ADD COLUMN updated_at TIMESTAMPTZ;
-- follows: pending state + AP id
ALTER TABLE follows ADD COLUMN state TEXT NOT NULL DEFAULT 'accepted';
ALTER TABLE follows ADD COLUMN ap_id TEXT;
ALTER TABLE follows ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
New tables
CREATE TABLE likes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
thought_id UUID NOT NULL REFERENCES thoughts(id),
ap_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, thought_id)
);
CREATE TABLE boosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
thought_id UUID NOT NULL REFERENCES thoughts(id),
ap_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, thought_id)
);
CREATE TABLE blocks (
blocker_id UUID NOT NULL REFERENCES users(id),
blocked_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (blocker_id, blocked_id)
);
CREATE TABLE remote_actors (
url TEXT PRIMARY KEY,
handle TEXT NOT NULL,
display_name TEXT,
inbox_url TEXT NOT NULL,
shared_inbox_url TEXT,
public_key TEXT NOT NULL,
last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
type TEXT NOT NULL, -- 'like','boost','follow','mention','reply'
from_user_id UUID REFERENCES users(id),
thought_id UUID REFERENCES thoughts(id),
read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Event System & Worker
event-payload/
Serializable wire types for NATS. Mirror of domain events with all fields as primitives (UUIDs as strings). serde::Serialize/Deserialize. No domain dependency.
event-publisher/
Receives DomainEvent, serializes to event-payload, routes to NATS subject (e.g. thoughts.created, likes.added). Implements domain's EventPublisher trait.
nats/
Wraps async-nats. Implements EventPublisher (publish to subject) and EventConsumer (subscribe, yields EventEnvelope stream with ack/nack handles).
worker/ (binary)
EventConsumer::consume()
→ deserialize EventEnvelope
→ match event type → dispatch to EventHandler impl
→ ack on success, nack on failure (NATS redelivers)
Handlers:
FederationHandler ← domain events → AP activities → remote inboxes
NotificationHandler ← writes notifications on like/boost/follow/mention/reply
SearchIndexHandler ← indexes/removes documents on create/delete
Handlers are plain structs taking Arc<dyn Port> — no NATS coupling inside them. Worker main.rs wires everything together.