# 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` 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` 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, 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 = 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 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, pub thoughts: Arc, pub likes: Arc, pub boosts: Arc, pub follows: Arc, pub blocks: Arc, pub tags: Arc, pub api_keys: Arc, pub top_friends: Arc, pub notifications: Arc, pub remote_actors: Arc, pub feed: Arc, pub search: Arc, pub auth: Arc, pub hasher: Arc, pub events: Arc, } ``` - [ ] **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` 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` (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.