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] membersin rootCargo.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:presentationstill 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 andsrc/main.rsfromcrates/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— removebuild_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_configfromcrates/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.rsreads all env vars into typedConfigstruct (Task 1) - ✅
bootstrap/factory.rsbuilds allArc<dyn Port>adapters fromConfig(Task 1) - ✅
bootstrap/main.rsis thin: read config → factory → serve (Task 1) - ✅
presentationloses[[bin]],main.rs,build_state,NoOpEventPublisher(Task 2) - ✅
presentation/Cargo.tomlno longer imports postgres, nats, auth, sqlx, etc. (Task 2) - ✅
AppStatehas nofed_configfield — pureArc<dyn Port>(Task 2) - ✅
cargo tree -p presentation | grep postgresreturns nothing (Task 2)
Placeholder scan: None.
Type consistency:
factory::build(cfg: &Config) -> Infrastructure— matchesmain.rscallInfrastructure { state: AppState, fed_config: ApFederationConfig }—statematchesroutes::router().with_state(state),fed_configmatchesroutes::router(&infra.fed_config)AppStatewithoutfed_config—factory.rsconstructs it correctly (nofed_config:field)sqlx::migrate!("../adapters/postgres/migrations")infactory.rs— path is relative toCARGO_MANIFEST_DIRofbootstrapcrate (crates/bootstrap/), resolves tocrates/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.