Files
movies-diary/docs/adr/0007-no-repositories-in-presentation.md

149 lines
12 KiB
Markdown

# `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 ✅. Once `repos` no 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 single
`grep` already catches completely. Rejected as strictly worse than the simple rule, not merely
equivalent to it.
- **Deleting `check-handler-repos` entirely, since the compiler now enforces the invariant** — a
production file referencing `app_ctx.repos` simply fails to build once the field is gone, so the
guard is redundant with `cargo build` in the narrow sense of "will this compile." Rejected because
the guard is cheaper to run (`grep` vs. a full workspace build) for `make 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 the `AppContext`
construction site rather than at the stray reference itself).
## Known follow-ups, out of scope here
- Plan D inverts `ActivityPubPort` so `presentation` drops its one remaining adapter dependency,
`activitypub` — untouched by this plan, exactly as ADR-0006 recorded.
- The pre-existing `follow_backfill_handler.rs` federation-gate bug and the isolated
`cargo test -p composition` `worker_deps_wiring` failure (both recorded in ADR-0006) are unaffected.
- `diary::log_review`, `wrapup::compute`, `import::cleanup`, and `integrations::cleanup` remain on
individual params, correctly — none of them ever read from `AppContext.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.