12 KiB
AppContext carries no Repositories; composition is a dev-only dependency of presentation
ADR-0006 closed sixteen call sites where a presentation handler called a repository method
directly, but explicitly left AppContext.repos in place: ~40 sites still read
state.app_ctx.repos.<field>.clone() to pass an Arc positionally into a use case that took
individual params rather than a deps struct, and migrating all of them at once would have made
ADR-0006's handler-extraction change harder to review and revert independently. check-handler-repos
was written narrowly on purpose, forbidding only the direct-call bypass ADR-0006 closed, not the
clone-and-pass pattern this plan (Plan C2) exists to remove.
Plan C2 did that migration in five tasks. Tasks 1-3 converted the 23 use cases the plan's initial
audit found reading an Arc cloned from Repositories — across integrations/webhook, wrapup,
watchlist, import, users, movies, goals, auth, and search — from individual Arc
parameters to a single &FooDeps struct each, registered on application::Deps and constructed by
composition::build_deps, following the convention ~30 other use cases already used. Task 4 replaced
the last two direct repository calls in the codebase — extractors.rs's AdminApiUser and
AdminUser both called repos.user.find_by_id(...) inline to check admin status — with a new
users::authorize_admin use case, closing the gap ADR-0006 honestly scoped as handlers-only (these
were two axum extractors, not handler functions).
Task 5 widened check-handler-repos from handlers/ to all of crates/presentation/src, and reported
zero offenders. That measurement was wrong. Its pattern was repos\.[a-z_]+\.clone\(\) — built to
catch the clone-and-pass shape the plan had inventoried — and it could not see two other shapes present
in the same tree: a bare reference passed by argument (&state.app_ctx.repos.diary, no .clone()
anywhere) and a let-bound access (if let Some(ref fed_query) = ...repos.federated_profile,
followed by a method call on the binding rather than on repos itself). diary::get_diary,
diary::get_review_history, and diary::export_diary all took their repository argument by
reference rather than owned, so their three call sites (handlers/diary.rs, handlers/rss.rs,
handlers/movies.rs, handlers/helpers.rs) were structurally invisible to a regex anchored on
.clone(). handlers/users.rs's federated-profile lookup bound repos.federated_profile with if let and called .get_federated_profile(...) on the bound variable, which no repos.<field>.<method>(
pattern can match across a let. The first attempt at this task found the five live sites by
inspection when it went to delete the field the guard was meant to make safe to delete, and correctly
returned blocked rather than deleting AppContext.repos out from under them; a new Task 7 then
converted all five (plus a sixth, non-repos internal caller broken purely by get_diary's signature
change) before this attempt at Task 6 ran.
Decision
AppContext no longer has a repos field, and presentation no longer depends on composition
in production. pub repos: Repositories and the pub use composition::Repositories; re-export are
both deleted from crates/presentation/src/context.rs. There is no route by which any code in
crates/presentation/src (outside src/tests/, which builds its own Repositories purely to drive
composition::build_deps for test fixtures) can reach a repository directly — not a stored field, not
an import, not a re-export. Every dependency a handler or extractor needs now comes from
state.app_ctx.deps, a prebuilt Arc<application::Deps> assembled once at the composition root.
crates/presentation/Cargo.toml moves composition from [dependencies] to [dev-dependencies].
After the field deletion, composition is referenced only from src/tests/extractors.rs, which calls
composition::build_deps to produce a real Deps for test fixtures — a legitimate use of the
composition root's public API, just not one production code needs. Confirmed both ways:
cargo tree -p presentation -e normal --depth 1 shows no composition anywhere in the production
dependency tree; cargo tree -p presentation -e dev --depth 1 shows it as a direct dev-dependency.
crates/server (ADR-0006's home for the binary and every backend-selection feature) already depended
on composition directly in [dependencies] — that entry is untouched by this task — because it is
the actual composition root, not presentation. It still builds a Repositories and still needs
AppContext { deps, .. } without a repos field, so this task's field deletion required two small
production-adjacent fixes outside crates/presentation that no earlier task in this plan touched:
crates/server/src/main.rs::wire_dependencies (production) and crates/server/tests/api_test.rs
(its integration-test harness) both used to write presentation::context::{AppContext, Repositories}
and construct AppContext { repos, deps, .. }; both now import Repositories from composition
directly and drop repos from the struct literal, keeping the local repos binding alive only long
enough to pass &repos into composition::build_deps, the same pattern presentation's own test
helper uses.
All use cases that used to read an Arc out of AppContext.repos — 23 converted in Tasks 1-3, three
more (get_diary, get_review_history, export_diary) converted in Task 7 once the guard's blind
spot surfaced them, plus two newly-created use cases (users::authorize_admin in Task 4,
users::get_federated_profile in Task 7) — now take a single deps struct. The two extractors.rs
admin checks (AdminApiUser, AdminUser) both now call users::authorize_admin::execute, returning
Result<Option<bool>, DomainError> so "user not found" and "repository call failed" stay
distinguishable by construction rather than by pattern-matching a DomainError variant an adapter
happens not to raise today. handlers/users.rs's federated-profile branch now calls
users::get_federated_profile::execute, which mirrors Repositories::federated_profile's own
Option-typed optionality (federation can be configured off) rather than inventing a new way to say
"this binary doesn't need it." Both migrations preserve their call sites' exact rejection behavior on
every path (missing port, repository error, empty result, found result) — verified path-by-path
against the pre-migration code in Task 4's and Task 7's own reports, not asserted here.
Two use cases remain on individual params, correctly, because they were never reachable through
AppContext.repos in the first place: movies::request_enrichment::fetch_if_stale and
movies::enrich_movie::execute, both called only from MovieEnrichmentHandler
(crates/application/src/movies/event_handler.rs), a domain::ports::EventHandler implementor that
the composition root constructs directly with individual Arc fields, the same way any adapter is
wired — never through state.app_ctx.deps. fetch_if_stale takes fully individual parameters
(&dyn MovieEnrichmentClient, &Arc<dyn MovieProfileRepository>, plus two value params).
enrich_movie::execute actually does take a struct, EnrichMovieDeps — but one built locally inside
MovieEnrichmentHandler::handle from its own already-held Arc fields, not a struct registered on
application::Deps or constructed by composition::build_deps. Neither shape is what this ADR means
by "use cases now take deps structs"; both are correctly out of scope for the same reason Services-
sourced use cases are (see below), and the plan's original note that they're "adapter-called, don't
migrate them" holds up.
This ADR's "use cases take deps structs" claim is scoped to what Plan C2 touched: use cases that
used to read AppContext.repos. It is not a claim that every execute function in the codebase
takes an application::Deps-registered struct. Two examples outside that scope, found while
fact-checking this ADR, are worth naming so nobody mistakes their shape for a regression:
diary::log_review::execute is called directly by two presentation handlers
(handlers/diary.rs::post_review and ::create_review) with an individual
&state.app_ctx.services.review_logger argument — legitimate, because review_logger comes from
AppContext.services, a field this plan never touched, not from repos. wrapup::compute::execute,
import::cleanup::execute, and integrations::cleanup::execute also take individual params, but none
of the three is ever called from a presentation handler — compute is called internally by
wrapup::handle_requested, and the two cleanup use cases are called only from
crates/application/src/jobs/, the background-job runner. All four were outside Plan C2's stated
scope before this task started and remain untouched by it.
check-handler-repos is now a single whole-crate rule: any mention of app_ctx.repos outside
src/tests/ is an offender. The two-pass shape-matching regex (repos.<field>.<method>(,
repos.<field>.clone().<method>() is gone. It is gone because it is no longer needed, not because it
was simplified for its own sake: with AppContext.repos deleted, there is no shape of legitimate
production access left to distinguish from an illegal one — no clone-as-argument to permit, no direct
call to forbid separately. A single substring match is sufficient and, unlike the old guard,
unevadeable: it cannot be blind to a reference pass or a let-binding the way the shape-matching guard
demonstrably was (see above), because it does not attempt to parse the shape of the access at all — it
only asks whether the now-nonexistent field is mentioned. Verified by injecting app_ctx.repos.user
into extractors.rs (a non-test production file) and confirming make check-handler-repos fails,
naming the exact file and line, before reverting.
Considered Options
- Leaving the shape-matching two-pass guard in place, just widened to the whole crate — this is
what Task 5 actually did, and it is what let three reference-pass sites and one
let-binding site through with a clean ✅. Oncereposno longer exists, keeping the old guard's shape-matching would mean maintaining two regex passes to catch a category of mistake (any mention at all) that a singlegrepalready catches completely. Rejected as strictly worse than the simple rule, not merely equivalent to it. - Deleting
check-handler-reposentirely, since the compiler now enforces the invariant — a production file referencingapp_ctx.repossimply fails to build once the field is gone, so the guard is redundant withcargo buildin the narrow sense of "will this compile." Rejected because the guard is cheaper to run (grepvs. a full workspace build) formake check's fast-path checks, and because it fails with a direct "here's the offending line" message rather than a compiler error several frames removed from the actual mistake (e.g. a struct-literal error at theAppContextconstruction site rather than at the stray reference itself).
Known follow-ups, out of scope here
- Plan D inverts
ActivityPubPortsopresentationdrops its one remaining adapter dependency,activitypub— untouched by this plan, exactly as ADR-0006 recorded. - The pre-existing
follow_backfill_handler.rsfederation-gate bug and the isolatedcargo test -p compositionworker_deps_wiringfailure (both recorded in ADR-0006) are unaffected. diary::log_review,wrapup::compute,import::cleanup, andintegrations::cleanupremain on individual params, correctly — none of them ever read fromAppContext.repos, so none was ever in this plan's scope. Whether they should eventually move to deps structs for consistency with the rest of the codebase is a separate, unstarted question this ADR takes no position on.CONTRIBUTING.md's architecture rules were updated in this same commit to stop describing use cases as taking&AppContext(a claim already false before this plan) and instead describe the&FooDeps-struct convention this ADR documents.