Split the 28-method AppRepository god-trait along its aggregates #30
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
rosa/vernier#30
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
AppRepository(crates/domain/src/ports.rs:24-159) is a single trait spanning five unrelated aggregates — Users, Confirmations, Posts, Access Tokens, Authorization Codes — with 28 methods. It is deep per method (each Postgres impl hides real SQL), but wide as an interface: a caller or test author must learn, or stub, all 28 to work with any one.The cost lands hardest on tests. A service test that only creates a Post still instantiates a
MemoryAppRepo(crates/domain/src/mocks.rs, 604 lines) carrying users, indexes, confirmations, tokens, and codes. There is no way to say "this behaviour depends only on the Post store." AndAppServiceis generic over exactly oneR: AppRepository, so its type signature claims a dependency on all five aggregates even for the authorization flow, which never touches a Post.Deletion test (sibling rule): splitting isn't a deletion, so apply "one adapter is a hypothetical seam, two is a real one." Here it inverts: there are two adapters but one seam bundling five independent responsibilities that never vary together. That bundling is the smell.
Solution
Split the god-trait along the aggregates already named in
CONTEXT.md:UserRepositoryPostRepositoryTokenRepository(access tokens + authorization codes)ConfirmationRepositoryPostgresAppRepositorykeeps implementing all of them (nothing changes at the infra seam); each service method depends on only the trait it needs. Mocks shrink to the surface a given test exercises.Benefits
TokenRepository + UserRepository— the type states the real dependency surface instead of "everything."Counter-weight (grill before committing)
Vernier is deliberately single-context (one
CONTEXT.md, one binary), and one wide trait keeps wiring trivial. Splitting adds trait bounds toAppService. Grill whether the test-ergonomics win beats that cost — this may be a "later, when a second Post adapter or a read-model appears" call rather than now.Architecture review candidate 3 of 4 (Worth exploring). Sequence after candidates 1 and 2.
Resolution: deferring — the premise doesn't hold yet
Grilled the split before committing. Three findings, each undercutting a stated benefit:
1. The real boundary is 3-way, not 4-way. Confirmation isn't an independent
aggregate — it's a subordinate entity in the user/identity lifecycle, and CONTEXT.md
never names it as an aggregate. Every confirmation write happens atomically inside a
user-lifecycle transaction:
create_userwritesusers+confirmationsin one transaction(
postgres.rs:274-327).update_user_passwordwritesusers+confirmations+sessions/user_sessionsin one transaction (
postgres.rs:618-659).A standalone
ConfirmationRepositorywould only create a "which trait owns thiscross-aggregate transaction?" problem. If we ever split, it's User (incl.
confirmations) / Post / Token.
2. A single
AppServicedelivers no leverage/navigability benefit.AppServicetouches all three aggregate groups, so its bound becomes the union
UserRepository + PostRepository + TokenRepository— the signature still advertises"everything." The headline "a reader of the auth flow sees it needs
TokenRepository + UserRepository" is only delivered by also decomposingAppService, which this issueexplicitly scopes out.
3. The testability benefit is illusory in this codebase. The issue assumes per-test
stubbing ("a Post test stubs ~7 methods, not 28"). But there are exactly two
impl AppRepositoryblocks —PostgresAppRepositoryand one shared real in-memoryadapter
MemoryAppRepo— and all 33 test construction sites just callMemoryAppRepo::new(). No test stubs anything; a Post-only test pays zero incrementalcost for the machinery it never calls. Splitting the trait shrinks no existing test —
it only enables a hypothetical focused fake (
MemoryPostRepo) that nothing needs. Theissue's own deletion-test rule actually cuts against the split here: two adapters over
one seam, where the fake is a real implementation, not a stub, so the fake-surface pain
the argument assumes never materializes.
Revisit when a genuine second Post adapter, a read-model, or a caching decorator
lands — that's the real driver that would make the seam earn its keep. Until then the
one wide trait keeps the wiring trivial (the counter-weight the issue itself raised).
Full write-up:
thoughts/shared/research/004_split_apprepository_god_trait.md.