11 KiB
Handlers call use cases only; check-handler-repos guard, and presentation goes lib-only
CONTRIBUTING.md had said "presentation handlers never touch repositories directly" for a while,
but nothing enforced it. 16 call sites across crates/presentation/src/handlers/ called a
repository method directly — repos.user.find_by_id(...), repos.wrapup_repo.find_existing(...),
repos.import_session.get(...), and thirteen more — instead of going through an
application-layer use case. Each bypass carried real logic that had nowhere principled to live:
get_user_profile's local-vs-federated dispatch, get_watchlist_page's decision to read a local
or a remote repository, get_wrapup_html's "is this report actually ready" gate, get_mapping_page's
"has this session even been parsed" gate, build_page_context's viewer-chrome assembly (email,
admin flag, pending-follower badge count) — all sitting in a handler function, untested except by
whatever HTTP-level test happened to exercise that route, and invisible to check-appcontext
because bypassing application entirely means there is no application code to scan.
A companion problem, orthogonal but resolved in the same plan: crates/presentation carried
main.rs and every backend-selection feature (sqlite, postgres, sqlite-federation,
postgres-federation, nats) even though crates/composition (ADR-0005) was already the actual
composition root. The binary and the library were the same crate for no reason connected to
either one's job.
Decision
Handlers call use cases only, never repository methods, enforced by make check-handler-repos.
The Makefile target greps crates/presentation/src/handlers/ for repos.<field>.<method>( and
fails, naming file and line, on any match whose method name isn't clone. .clone() stays
legal: ~40 sites in handlers still read state.app_ctx.repos.<field>.clone() to pass an Arc
positionally into a use case that takes individual params rather than a deps struct — Plan C2's
job, not this plan's. The guard is two grep passes rather than one, specifically so a single
line carrying both a legal clone and an illegal call doesn't hide the illegal one: grep -o
extracts each repos.<field>.<method>( occurrence onto its own output line (file:line still
attached, since -n/-r key off the match, not the containing line), then a second grep -v
drops the occurrences whose method name is exactly clone. A single grep -v filtering whole
lines would have wrongly cleared a line pairing repos.a.clone() with an illegal
repos.b.find_by_id(...). Verified against the current tree (zero offenders) and against a
deliberately reintroduced repos.user.find_by_id( call in handlers/users.rs::get_user_profile,
which the guard reported by exact file and line before the call was reverted.
Sixteen bypass call sites became eleven use cases (twelve modules, because one use case split in two — see below), each one absorbing the logic that used to live in the handler:
users::get_local_profile/users::get_federated_profile_stats— the local and federated halves of what was oneget_profileuse case (ADR-0004). Absorbs the local-vs-federated identity decision described below.users::get_page_viewer— page-chrome assembly: viewer email, admin flag, and pending- follower badge count, previously three separate lookups scattered throughhandlers/helpers.rs::build_page_contextwith independent, inconsistent error tolerance.get_page_viewer::executenow propagates every error with?;build_page_contextis the one place that decides to degrade chrome on failure (logs, falls back to(None, false, 0)).users::resolve_username_to_id— username → id lookup, previously a barerepos.user.find_by_username(...)call.users::get_profile_settings— settings-page field assembly (username, display name, bio, avatar/banner,also_known_as, custom profile fields), previously assembled inline from two separate repository calls in the handler.import::get_mapping_stage— import stage gating:NotFoundfor both "session missing" and "session has no parsed file yet", collapsing two conditions the old handler redirected to the same place for. Owns theSAMPLE_ROW_LIMIT = 5constant that used to be a bare.take(5).import::get_preview_stage— the other import stage gate:Ok(NotYetMapped)when row results are absent (a business state, not an error) vsOk(Ready(PreviewRows)). Called from two handler sites (get_preview_pageHTML andapi_get_previewJSON) — the one deliberate exception to "one use case, one call site" in this plan.import::get_session_state— session summary (columns, mapping status, row count) for the JSON session-state endpoint.import::apply_profile_and_map— orchestratesapply_profile::executethenapply_mapping::executeinside the use case layer instead of the handler, matching the existingMovieEnrichmentHandler-calls-enrich_movieprecedent rather than inventing a new shape for cross-use-case calls.diary::get_user_feed— feed author lookup: resolves the RSS feed's author display name (from the user's email local-part) alongside the diary entries, previously two separate reads (repos.user.find_by_idthenget_diary::execute) glued together inhandlers/rss.rs.watchlist::get_watchlist_for_owner— the local-vs-federated watchlist decision: probesrepos.user.find_by_idto decide whether the requested owner is a local user (paginated, validatedPageParams) or a federated one (unpaginated,limit/offsetignored — a pre-existing asymmetry, preserved deliberately rather than "fixed" into new behavior). ReturnsWatchlistView::Local(Paginated<..>) | Remote(Vec<..>); the handler still chooses which template to render.wrapup::get_ready_report— wrapup report gating: collapses "no record", "wrong status", "repository error", and "Readybutreport: None" into oneNotFound/ValidationErrorsurface, reproducing a pre-existing quirk (repo errors becoming a 404) rather than introducing new-looking behavior for it. Takesdomain::models::wrapup::WrapUpScope(User(Uuid) | Global) directly — an application-layer duplicate of that type was drafted and then dropped in favor of the existing domain enum once it was clear the duplicate added no impedance, just a second name for the same idea.
The pending-follower count is one use case behind two transports. Both the SPA's
GET /api/v1/social/followers/pending/count endpoint and the classic UI's page chrome
(get_page_viewer) read the same FollowGraphQuery::count_pending_followers call, which is also
the same method application::social::count_pending_followers wraps. No second counting path
exists; the two frontends cannot show different numbers because there is exactly one place the
number comes from.
The ADR-0004 sentinel wart is closed. ADR-0004 recorded a "known wart": get_profile's
tolerant, non-NotFound handling of a missing local row filled username/handle with
empty-string sentinels while actor_url stayed a well-formed-looking (but meaningless) URL — one
field obviously wrong, one plausibly wrong — and noted it was inert only because no caller read
profile.identity on the federated path. This plan is the "moment any caller starts consuming
profile.identity on the federated path" ADR-0004 said to watch for, and it resolved the wart by
removing the shared type rather than patching it: get_local_profile returns
Err(DomainError::NotFound(_)) for a missing local row with identity always fully populated on
success, and get_federated_profile_stats's return type (FederatedProfileStats) carries no
identity field at all, because the federated handler never read one. No type in the codebase
can represent the old sentinel state anymore — there is no ProfileIdentity for the federated
path to half-fill.
get_federated_profile_stats::tests::succeeds_for_a_user_id_with_no_local_row carries forward
the old regression test's intent;
get_local_profile::tests::get_local_profile_is_not_found_for_unknown_user pins the new
NotFound behavior directly.
crates/presentation is lib-only; crates/server owns the binary. main.rs and the
integration test that exercised it moved to a new crates/server crate, which now holds every
backend-selection feature (sqlite, postgres, sqlite-federation, postgres-federation,
nats) that used to live on presentation. presentation's Cargo.toml now has exactly one
feature axis — federation (on by default) — and depends on no adapter crate except
activitypub, gated behind that same feature. cargo tree -p presentation shows no
sqlite/postgres/sqlx/infra-wiring anywhere in the tree. The one remaining adapter
dependency, activitypub, is out of scope here — inverting ActivityPubPort so presentation
depends on nothing but domain/application/composition ports is Plan D's job, not this
plan's.
app_ctx.repos deliberately remains, and stays pub. This plan removed sixteen repository
method calls from handlers; it did not touch the ~40 state.app_ctx.repos.<field>.clone()
sites that pass an Arc positionally into use cases taking individual params, and it did not
delete the repos field itself. check-handler-repos is written narrowly on purpose — it
forbids the bypass this plan closes, not the clone-and-pass pattern Plan C2 is scoped to migrate.
Removing repos now would break every one of those 40 sites for no gain this plan can claim.
Considered Options
- Thin entity-fetch use cases — e.g. a bare
get_user(id) -> Userwrappingrepos.user.find_by_id— were rejected as a repository with extra indirection. They would satisfycheck-handler-reposliterally while leaving every real decision (local-vs-federated, stage gating, chrome assembly, report gating) sitting in the handler exactly where it was, which is the actual problem this plan exists to fix, not a naming exercise. - Deleting
app_ctx.reposin this plan — rejected because ~40 clone-passed sites across the converted handler files still construct use case calls positionally from it; deleting the field would require migrating all 40 to deps-struct calls in the same change, which is Plan C2's scope, not this one's. Landing that migration and the use-case extraction together would make either piece harder to review and revert independently.
Known follow-ups, out of scope here
- Plan C2 migrates the ~40
repos.<field>.clone()sites in handlers to deps-struct calls and then removesapp_ctx.repos.check-handler-reposdoes not (and should not) block on these — it only forbids the method-call bypass this plan closed. - Plan D inverts
ActivityPubPortsopresentationdrops its one remaining adapter dependency,activitypub. - The pre-existing
follow_backfill_handler.rsfederation-gate bug (recorded in ADR-0005's follow-ups) is untouched by this plan. cargo test -p compositionin isolation fails one test,worker_deps_wiring, withunreachable code: worker_deps_wiring needs the sqlite feature; cargo test --workspace enables it transitively via presentation/worker's default features— pre-existing, not introduced or fixed by this plan, and invisible tomake check's workspace-widecargo test, which enables the feature transitively.