5.8 KiB
crates/composition is the sole composition root; worker stops hand-wiring adapters
The workspace had two composition roots. crates/presentation built its adapters and deps
through crates/composition; crates/worker built its own, separately, in
crates/worker/src/db.rs (125 lines) — a 25-field WorkerDbOutput of which 22 duplicated fields
composition's then-28-field DatabaseOutput already had, two of those under different names
(_goal_command / _watch_event_query, underscore-prefixed because the worker's copy never read
them). Every adapter change had to be
made twice, in two files that had already drifted (three fields — deduplicator,
image_ref_command, image_ref_query — existed only in the worker's copy, unreachable from the
server). Nothing enforced that the two stayed in sync; only a diff would catch it.
Decision
crates/composition is now the only composition root. crates/worker/src/db.rs is deleted.
Both binaries call composition::factory::build_database_adapters to get one DatabaseOutput,
and each then builds only the deps it can honestly use from it.
DatabaseOutput(crates/composition/src/factory.rs) is a deliberate 31-field superset of every adapter either binary needs. The server constructs three adapters it never reads —deduplicator,image_ref_command,image_ref_query(worker-only) — in exchange for one construction path that cannot drift. That trade is the point of this ADR: a handful of wastedArcconstructions on the server side is cheaper than a second hand-maintained factory.- The deps container is split, not shared:
application::Deps(25 fields across 10 groups) is built bycomposition::build_depsand consumed only bycrates/presentation; the five groups with no server-reachable consumer —enrich_movie,reindex_search,merge_duplicates,enrich_person,handle_requested— moved into a newapplication::WorkerDeps, built bycomposition::build_worker_depsand consumed only bycrates/worker. Every field ofDepsis now reachable frompresentationexceptusers.delete_account— see the dead-code exception recorded below — and every field ofWorkerDepsis reachable fromworker. No field needs anOptionor a Noop port to express "not for this binary" — which container it lives in already says that. application::WorkerServicesis the analogous split on the services side: the strict subset ofapplication::Servicesthe worker can actually construct (object_storage,event_publisher,person_enrichment). The worker has noauth,password_hasher,diary_exporter,document_parser, orreview_logger— those use cases don't run in the worker process, soWorkerServicessimply doesn't carry them.build_worker_depstakes&DatabaseOutput, not&Repositories, even thoughRepositoriesis whatbuild_depstakes and superficially looks like the more natural shared type. The worker cannot honestly construct aRepositories: five of its six federation-sourced fields (remote_watchlist,social_command,follow_graph,block_query,federation_admin— the sixth,federated_profile, is alreadyOptioninRepositoriesfor unrelated reasons) have no worker-side adapter.DatabaseOutputonly carries fields both binaries can genuinely fill, so passing it instead ofRepositoriesneeded no newOptionand no fake adapter.- Exactly one field is unreachable from its own binary, and it is called out by name rather than
folded into a group comment:
Deps.users.delete_account(DeleteAccountDeps) has zero callers anywhere in the workspace — a pre-existing dead use case, not a consequence of this split. Every other field that used to be described as "worker-only" or "not reachable" is now reachable from the binary that reaches it, because it lives in that binary's own container. - Both binaries were booted under the default feature set (
sqlite,sqlite-federation) to prove this: the worker reached its steady polling state without panicking, and the server served both a Bearer-authenticated API call and a cookie-authenticated HTML page. A container wired to the wrong adapter is exactly the kind of defect unit tests can't see — only a running process can.
Considered Options
- A separate
WorkerExtrasstruct, kept alongsideDeps— rejected because it recreates the exact problem this ADR fixes: two structs that have to be kept in sync by hand, just smaller ones thanWorkerDbOutputwas. - Noop ports for the five
Servicesthe worker lacks (auth,password_hasher,diary_exporter,document_parser,review_logger) — rejected because it reintroduces the sentinel-port pattern this codebase has been actively removing elsewhere; aServicesfield the worker can't fill should not exist in a struct the worker holds, dressed up as a port that panics or no-ops if called. Optionfields on a single sharedDeps/Services, meaning "not for this binary" — rejected for the same reason as ADR-0002/0003 avoidOptionfor "not applicable here": absence is a fact about which binary you're in, and that fact is better expressed by which container (DepsvsWorkerDeps,ServicesvsWorkerServices) a field lives in than by anOptionevery reader then has to unwrap or justify.
Known follow-up, out of scope here
crates/worker/src/follow_backfill_handler.rs references activitypub::ActivityPubPort
unconditionally, with no #[cfg(feature = "federation")] gate. cargo build -p worker --no-default-features --features sqlite (federation off) fails to compile because of it. This
predates worker unification — confirmed byte-identical at the commit before this plan started —
and is unrelated to the composition-root split; it needs its own fix.