Files
thoughts/docs/superpowers/plans/2026-05-14-bootstrap-factory.md

16 KiB

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:

[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:
/// 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:
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:
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:

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:

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:
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:

[[bin]]
name = "thoughts"
path = "src/main.rs"
  • Strip concrete adapter deps from crates/presentation/Cargo.toml:

Remove these lines:

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):

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:
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:

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:
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:

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:
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3

Expected: all tests pass.

  • Commit:
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_configfactory.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.