Refactor: Extract business logic into Action classes (v1.4.0) #144
Labels
No labels
bug
devops
duplicate
enhancement
good first issue
layout
next major release
next minor release
question
research
testing
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lvl0/fedi-feed-router#144
Loading…
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?
Refactor the areas below to improve testability and separation of concerns.
Scope
Originally 17 items; narrowed to 7 on 2026-08-13 after inspecting each candidate. Three of those seven were then also found not to hold up, and were replaced — see below.
SaveArticleActionfromArticleFetcherValidateArticleActionfromValidationService— delivered as a rename. After item 3,ValidationServicewas one method with one caller; extracting would have left a pass-through class. Moved and renamed instead:ValidationService::validate()→ValidateArticleAction::execute().CreateRouteArticlesActionfromValidationServiceFetchRssArticlesActionandFetchWebsiteArticlesActionfromArticleFetcher5. ExtractRefreshArticlesActionfromArticlesController6. ExtractUpdateFeedActionfromFeedsController7. ExtractUpdateRouteActionfromRoutingControllerItems 5–7 replaced. On inspection each controller method was a one-liner —
ArticleDiscoveryJob::dispatch(),$feed->update($validated),Route::where(...)->update($validated)— with validation already in a FormRequest and the try/catch being HTTP concern. Extracting would have produced actions wrapping a single model call: the same pass-through objection that removed ten items from the original list.They were replaced with deleting
ArticleFetcherentirely, which items 1 and 4 had left as a 58-line class whose two methods shared no callers and no state:ArticleFetcherintoFetchFeedArticlesAction+FetchArticleDataAction, and delete itDropped from the original list
Inspected and found to be already single-purpose — extracting an Action would produce a class whose only method delegates to another class with one method:
GetActivitySummaryActionfromActivitySummary— 34 lines, one public methodSaveLogActionfromLogSaver— 65 lines, five one-line wrappers around a singlelog()UploadThumbnailActionfromThumbnailUploader— one public method plus private helpersGetCommunitiesActionfromCommunityDirectoryGetLemmyTokenActionfromLemmyAuthServiceAlso dropped as not worth the churn without a specific complaint driving them:
GetSystemStatusAction,CheckOnboardingStatusAction,SendNotificationAction,SyncLemmyChannelPostsAction,CheckPlatformCredentialsAction.Any of these can be re-added later if a concrete testability problem shows up.
Acceptance criteria
ArticleFetcherandValidationServiceare gone;app/Services/Article/no longer existsDelivered in five commits,
d8b85f8..dc64dd8.Result
app/Services/Article/no longer exists. Eight Actions inapp/Actions/, each with one publicexecute()and constructor-injected collaborators, forming a shallow DAG with no cycles:The testability gain is measurable, not nominal
ArticleFetcherTestusedReflectionClass+setAccessible(true)three times to reach the privatesaveArticle().SaveArticleActionTestcallsexecute()directly with zero reflection, and picked up two new tests for the fallback-title logic that were awkward to write under reflection.evaluateKeywords()andshouldAutoApprove()were private insideValidationService, reachable only by mockingArticleFetcherand driving the wholevalidate()flow.CreateRouteArticlesActionTestnow covers keyword matching, auto-approve precedence and decision stamping directly, with no mocks at all.Behaviour preservation
Every moved method body was diffed against its pre-move commit with
git show HEAD:… | diffand confirmed byte-identical — the only differences are method signatures and two comments dropped as restating the code below them. The finish-phasepr-reviewerindependently re-diffed and found no logic drift.The strongest evidence is the test suite itself: 1219 tests / 2924 assertions, unchanged in count across the whole ticket, with the pre-existing
ValidationServiceTest(19 tests) andArticleFetcherTest(11) passing throughout with only their construction lines edited.Scope changes, all reported before acting
Three of the seven listed items were not done as written. Each was inspected, the finding reported with evidence, and the alternative chosen deliberately — details in the ticket body above. The short version: extraction is warranted when a class does several things, not to satisfy a naming convention. Ten items were dropped on that basis before work started, and three more during it.
Verification
1219 tests / 2924 assertions green, Pint clean (350 files), PHPStan clean. Per-commit
code-revieweron all five commits plus a finish-phasepr-reviewerover the combined diff — no critical or must-fix issues at any point.Noted, not fixed
Both are behaviour changes with no place in a pure refactor:
FetchFeedArticlesAction'selseif/fallthrough for an unsupported feed type is unreachable —feeds.typeis a DBenum('website','rss')with matching validation. Amatchthat throws would be more honest than a warning-and-empty-collection.SaveArticleActionlogs and rethrows, while every fetch action logs and swallows. Inherited from the originalArticleFetcher, so preserved deliberately — but worth deciding what a failed fetch should do to a discovery run.