docs: bootstrap factory implementation plan
This commit is contained in:
431
docs/superpowers/plans/2026-05-14-bootstrap-factory.md
Normal file
431
docs/superpowers/plans/2026-05-14-bootstrap-factory.md
Normal file
@@ -0,0 +1,431 @@
|
||||
# Bootstrap Factory 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:** Extract the composition root out of `presentation` into a dedicated `bootstrap` crate with a `factory.rs` that builds all dependencies from config — so `presentation` becomes a pure HTTP library with no knowledge of concrete adapters.
|
||||
|
||||
**Architecture:** `crates/bootstrap/` is a new binary crate. It contains `config.rs` (reads env vars), `factory.rs` (creates all concrete `Arc<dyn Port>` adapters and returns `Infrastructure { state, fed_config }`), and a thin `main.rs`. `presentation` loses `[[bin]]`, `build_state`, and all concrete adapter imports — it only depends on `domain`, `application`, `api-types`, `axum`, `activitypub-base`, and UI/docs libs.
|
||||
|
||||
**Tech Stack:** existing Rust workspace, sqlx, async-nats, all existing adapter crates
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
Create: crates/bootstrap/Cargo.toml ← binary crate, imports all concrete adapters
|
||||
Create: crates/bootstrap/src/config.rs ← Config struct + from_env()
|
||||
Create: crates/bootstrap/src/factory.rs ← build(config) → Infrastructure { state, fed_config }
|
||||
Create: crates/bootstrap/src/main.rs ← thin: read config, call factory, serve
|
||||
|
||||
Modify: Cargo.toml (root) ← add "crates/bootstrap" to workspace members
|
||||
Modify: crates/presentation/Cargo.toml ← remove [[bin]], remove all concrete adapter deps
|
||||
Modify: crates/presentation/src/lib.rs ← remove build_state + NoOpEventPublisher + imports
|
||||
Modify: crates/presentation/src/state.rs ← remove fed_config field
|
||||
Delete: crates/presentation/src/main.rs ← binary moves to bootstrap
|
||||
```
|
||||
|
||||
**Key design decision:** `fed_config` is removed from `AppState`. `factory::build()` returns `Infrastructure { state, fed_config }` separately. `main.rs` passes them independently to `router(&infra.fed_config).with_state(infra.state)`. This makes `AppState` pure `Arc<dyn Port>` with no infrastructure types.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create bootstrap crate
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/bootstrap/Cargo.toml`
|
||||
- Create: `crates/bootstrap/src/config.rs`
|
||||
- Create: `crates/bootstrap/src/factory.rs`
|
||||
- Create: `crates/bootstrap/src/main.rs`
|
||||
- Modify: `Cargo.toml` (root)
|
||||
|
||||
- [ ] **Add `"crates/bootstrap"` to `[workspace] members`** in root `Cargo.toml`.
|
||||
|
||||
- [ ] **Create `crates/bootstrap/Cargo.toml`:**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "bootstrap"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "thoughts"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
presentation = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
postgres = { workspace = true }
|
||||
postgres-search = { workspace = true }
|
||||
postgres-federation = { workspace = true }
|
||||
activitypub = { workspace = true }
|
||||
activitypub-base = { workspace = true }
|
||||
nats = { workspace = true }
|
||||
auth = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
async-nats = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tower-http = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/bootstrap/src/config.rs`:**
|
||||
|
||||
```rust
|
||||
/// All configuration read from environment variables at startup.
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub jwt_secret: String,
|
||||
pub base_url: String,
|
||||
pub nats_url: Option<String>,
|
||||
pub port: u16,
|
||||
pub allow_registration: bool,
|
||||
/// true when RUST_ENV != "production" — enables AP debug mode
|
||||
pub debug: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
dotenvy::dotenv().ok();
|
||||
Self {
|
||||
database_url: std::env::var("DATABASE_URL")
|
||||
.expect("DATABASE_URL is required"),
|
||||
jwt_secret: std::env::var("JWT_SECRET")
|
||||
.expect("JWT_SECRET is required"),
|
||||
base_url: std::env::var("BASE_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".into()),
|
||||
nats_url: std::env::var("NATS_URL").ok(),
|
||||
port: std::env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(3000),
|
||||
allow_registration: std::env::var("ALLOW_REGISTRATION")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(true),
|
||||
debug: std::env::var("RUST_ENV")
|
||||
.map(|v| v != "production")
|
||||
.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/bootstrap/src/factory.rs`:**
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use activitypub::ThoughtsObjectHandler;
|
||||
use activitypub_base::{ApFederationConfig, FederationData};
|
||||
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
||||
use postgres::activitypub::PgActivityPubRepository;
|
||||
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
||||
use presentation::state::AppState;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// Everything the binary needs to start serving: the axum state and the
|
||||
/// federation config (used when building the router).
|
||||
pub struct Infrastructure {
|
||||
pub state: AppState,
|
||||
pub fed_config: ApFederationConfig,
|
||||
}
|
||||
|
||||
// ── No-op publisher (fallback when NATS is unavailable) ──────────────────────
|
||||
|
||||
struct NoOpEventPublisher;
|
||||
|
||||
#[async_trait]
|
||||
impl EventPublisher for NoOpEventPublisher {
|
||||
async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { Ok(()) }
|
||||
}
|
||||
|
||||
// ── Factory ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn build(cfg: &Config) -> Infrastructure {
|
||||
// 1. Database connection + migrations
|
||||
let pool = PgPool::connect(&cfg.database_url)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
sqlx::migrate!("../adapters/postgres/migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
tracing::info!("Database connected and migrations applied");
|
||||
|
||||
// 2. Event publisher — real NATS or no-op fallback
|
||||
let event_publisher: Arc<dyn EventPublisher> = match &cfg.nats_url {
|
||||
Some(url) => match async_nats::connect(url).await {
|
||||
Ok(client) => {
|
||||
tracing::info!("Connected to NATS at {url}");
|
||||
Arc::new(nats::NatsEventPublisher::new(client))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("NATS connect failed ({e}) — falling back to no-op publisher");
|
||||
Arc::new(NoOpEventPublisher)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
tracing::info!("NATS_URL not set — using no-op event publisher");
|
||||
Arc::new(NoOpEventPublisher)
|
||||
}
|
||||
};
|
||||
|
||||
// 3. ActivityPub federation
|
||||
let fed_data = FederationData::new(
|
||||
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
||||
Arc::new(PostgresApUserRepository::new(pool.clone(), cfg.base_url.clone())),
|
||||
Arc::new(ThoughtsObjectHandler::new(
|
||||
Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||
&cfg.base_url,
|
||||
)),
|
||||
cfg.base_url.clone(),
|
||||
cfg.allow_registration,
|
||||
"thoughts".to_string(),
|
||||
None, // event_publisher wired separately via NATS
|
||||
);
|
||||
let fed_config = ApFederationConfig::new(fed_data, cfg.debug)
|
||||
.await
|
||||
.expect("Failed to build federation config");
|
||||
|
||||
// 4. Application state — all concrete repos injected as Arc<dyn Port>
|
||||
let state = AppState {
|
||||
users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())),
|
||||
thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())),
|
||||
likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())),
|
||||
boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())),
|
||||
follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())),
|
||||
blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())),
|
||||
tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
||||
api_keys: Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())),
|
||||
top_friends: Arc::new(postgres::top_friend::PgTopFriendRepository::new(pool.clone())),
|
||||
notifications: Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())),
|
||||
remote_actors: Arc::new(postgres::remote_actor::PgRemoteActorRepository::new(pool.clone())),
|
||||
feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())),
|
||||
search: Arc::new(postgres_search::PgSearchRepository::new(pool.clone())),
|
||||
auth: Arc::new(auth::JwtAuthService::new(cfg.jwt_secret.clone(), 86400 * 30)),
|
||||
hasher: Arc::new(auth::Argon2PasswordHasher),
|
||||
events: event_publisher,
|
||||
};
|
||||
|
||||
Infrastructure { state, fed_config }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/bootstrap/src/main.rs`:**
|
||||
|
||||
```rust
|
||||
mod config;
|
||||
mod factory;
|
||||
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cfg = config::Config::from_env();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let infra = factory::build(&cfg).await;
|
||||
|
||||
let app = presentation::routes::router(&infra.fed_config)
|
||||
.with_state(infra.state)
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
let addr = format!("0.0.0.0:{}", cfg.port);
|
||||
tracing::info!("Listening on {addr}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors.
|
||||
Note: `presentation` still has its old `[[bin]]` at this point — that's fine, both binaries exist temporarily.
|
||||
|
||||
- [ ] **Smoke test from bootstrap:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \
|
||||
JWT_SECRET=dev BASE_URL=http://localhost:3000 \
|
||||
RUST_LOG=info cargo run --bin thoughts
|
||||
```
|
||||
|
||||
Open a second terminal:
|
||||
```bash
|
||||
curl -s -X POST http://localhost:3000/auth/register \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"username":"bootstraptest","email":"boot@test.com","password":"pw"}' | jq .token
|
||||
```
|
||||
Expected: returns a JWT token.
|
||||
|
||||
- [ ] **Commit:**
|
||||
```bash
|
||||
git add Cargo.toml crates/bootstrap/
|
||||
git commit -m "feat(bootstrap): composition root with Config + factory.rs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Clean presentation — strip concrete deps, remove binary
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/presentation/Cargo.toml`
|
||||
- Modify: `crates/presentation/src/lib.rs`
|
||||
- Modify: `crates/presentation/src/state.rs`
|
||||
- Delete: `crates/presentation/src/main.rs`
|
||||
|
||||
- [ ] **Remove `[[bin]]` table and `src/main.rs` from `crates/presentation/Cargo.toml`:**
|
||||
|
||||
Delete these lines entirely:
|
||||
```toml
|
||||
[[bin]]
|
||||
name = "thoughts"
|
||||
path = "src/main.rs"
|
||||
```
|
||||
|
||||
- [ ] **Strip concrete adapter deps from `crates/presentation/Cargo.toml`:**
|
||||
|
||||
Remove these lines:
|
||||
```toml
|
||||
postgres = { workspace = true }
|
||||
postgres-search = { workspace = true }
|
||||
postgres-federation = { workspace = true }
|
||||
activitypub = { workspace = true }
|
||||
nats = { workspace = true }
|
||||
async-nats = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
auth = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
```
|
||||
|
||||
Keep these (they belong to the HTTP layer):
|
||||
```toml
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
api-types = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
activitypub-base = { workspace = true }
|
||||
activitypub_federation = "0.7.0-beta.11"
|
||||
url = { workspace = true }
|
||||
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] }
|
||||
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
|
||||
```
|
||||
|
||||
- [ ] **Rewrite `crates/presentation/src/lib.rs`** — remove `build_state`, `NoOpEventPublisher`, and all concrete imports. The file becomes purely module declarations:
|
||||
|
||||
```rust
|
||||
pub mod errors;
|
||||
pub mod extractors;
|
||||
pub mod handlers;
|
||||
pub mod openapi;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
```
|
||||
|
||||
- [ ] **Remove `fed_config` from `crates/presentation/src/state.rs`:**
|
||||
|
||||
The `AppState` struct currently has `pub fed_config: ApFederationConfig`. Remove that field and its import. The struct becomes:
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use domain::ports::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub users: Arc<dyn UserRepository>,
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub likes: Arc<dyn LikeRepository>,
|
||||
pub boosts: Arc<dyn BoostRepository>,
|
||||
pub follows: Arc<dyn FollowRepository>,
|
||||
pub blocks: Arc<dyn BlockRepository>,
|
||||
pub tags: Arc<dyn TagRepository>,
|
||||
pub api_keys: Arc<dyn ApiKeyRepository>,
|
||||
pub top_friends: Arc<dyn TopFriendRepository>,
|
||||
pub notifications: Arc<dyn NotificationRepository>,
|
||||
pub remote_actors: Arc<dyn RemoteActorRepository>,
|
||||
pub feed: Arc<dyn FeedRepository>,
|
||||
pub search: Arc<dyn SearchPort>,
|
||||
pub auth: Arc<dyn AuthService>,
|
||||
pub hasher: Arc<dyn PasswordHasher>,
|
||||
pub events: Arc<dyn EventPublisher>,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Delete `crates/presentation/src/main.rs`:**
|
||||
|
||||
```bash
|
||||
rm crates/presentation/src/main.rs
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check -p presentation` — Expected: no errors.
|
||||
|
||||
- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors (bootstrap now owns the binary).
|
||||
|
||||
- [ ] **Verify only bootstrap knows about postgres:**
|
||||
|
||||
```bash
|
||||
cargo tree -p presentation 2>/dev/null | grep -E "postgres|sqlx|nats|auth" | head -5 || echo "clean"
|
||||
```
|
||||
Expected: `clean` — no concrete adapter deps in presentation.
|
||||
|
||||
- [ ] **Run full test suite:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3
|
||||
```
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/presentation/Cargo.toml \
|
||||
crates/presentation/src/lib.rs \
|
||||
crates/presentation/src/state.rs
|
||||
git rm crates/presentation/src/main.rs
|
||||
git commit -m "refactor(presentation): pure HTTP library — remove build_state, concrete adapter deps, and binary"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ `bootstrap/config.rs` reads all env vars into typed `Config` struct (Task 1)
|
||||
- ✅ `bootstrap/factory.rs` builds all `Arc<dyn Port>` adapters from `Config` (Task 1)
|
||||
- ✅ `bootstrap/main.rs` is thin: read config → factory → serve (Task 1)
|
||||
- ✅ `presentation` loses `[[bin]]`, `main.rs`, `build_state`, `NoOpEventPublisher` (Task 2)
|
||||
- ✅ `presentation/Cargo.toml` no longer imports postgres, nats, auth, sqlx, etc. (Task 2)
|
||||
- ✅ `AppState` has no `fed_config` field — pure `Arc<dyn Port>` (Task 2)
|
||||
- ✅ `cargo tree -p presentation | grep postgres` returns nothing (Task 2)
|
||||
|
||||
**Placeholder scan:** None.
|
||||
|
||||
**Type consistency:**
|
||||
- `factory::build(cfg: &Config) -> Infrastructure` — matches `main.rs` call
|
||||
- `Infrastructure { state: AppState, fed_config: ApFederationConfig }` — `state` matches `routes::router().with_state(state)`, `fed_config` matches `routes::router(&infra.fed_config)`
|
||||
- `AppState` without `fed_config` — `factory.rs` constructs it correctly (no `fed_config:` field)
|
||||
- `sqlx::migrate!("../adapters/postgres/migrations")` in `factory.rs` — path is relative to `CARGO_MANIFEST_DIR` of `bootstrap` crate (`crates/bootstrap/`), resolves to `crates/adapters/postgres/migrations` ✓
|
||||
|
||||
**Note on Dockerfile:** The existing `Dockerfile` references the `thoughts` binary. Since `bootstrap/Cargo.toml` uses `[[bin]] name = "thoughts"`, the binary name is unchanged — Dockerfile needs no update.
|
||||
|
||||
**Note on worker:** `crates/worker/` is already a clean composition root — it wires its own deps in `main.rs`. No changes needed there.
|
||||
Reference in New Issue
Block a user