ADR-027: Split TaskController
- Status
-
Accepted
- Date
-
2026-04
- Authors
-
Netresearch DTT GmbH
Context
Classes/ has grown to 920
lines carrying eleven public actions, nine private helpers, and three
distinct user-facing pathways:
- List / catalog —
list.Action () - AI wizard (create a Task from a natural-language description) —
wizard,Form Action () wizard,Generate Action () wizard,Generate Chain Action () wizard.Create Action () - Execution (run a stored Task with various input sources) —
execute,Form Action () execute,Action () refresh.Input Action () - Record picking (browse DB tables to source Task input from a
record) —
list,Tables Action () fetch,Records Action () load.Record Data Action ()
The 2026-04 architecture audit — generated locally and kept under the
gitignored claudedocs/ directory rather than checked in (the
codebase intentionally excludes Claude Code working notes from version
control via .gitignore) — flagged three concrete problems with the
controller as it stands:
- Inline SQL. Eight call sites use
Connection/Pool Querydirectly to queryBuilder sys_, the picked record's table, and so on. Repository layer is bypassed.log - Inconsistent response shape. Most backend controllers return
typed
Response/*DTOs (ToggleActiveResponse,TestConfigurationResponse, etc.) — see ADR-024 widget pattern and theConfigurationControllerprecedent.TaskController's AJAX actions instead return rawnew Jsonliterals at sixteen call sites.Response ( ['success' => …, 'error' => …]) - God-class scope. Three independent user pathways (catalog, wizard, execution + record picking) sharing one class makes navigation, testability, and per-feature ownership harder than it needs to be.
Adding any of the planned follow-ups — pre-flight budget gating in the
execute flow (REC #4), a typed exception layer for execute errors
(REC #8), domain-JSON-to-DTO promotion for Task::getInputConfig()
(REC #6) — would each make this class even larger.
The audit explicitly noted that REC #5 should ship behind an ADR because the change touches backend module routing, the AJAX URL surface JavaScript depends on, and the boundary between controllers and the service layer.
Decision
We will adopt a hybrid split: per-pathway controllers + service extraction + uniform typed responses. Concretely:
Per-pathway controllers
The eleven public actions move into four focused controllers, each
sharing the same dependency-injection patterns we already use for
Configuration / Provider /
Model:
Controller/—Backend/ Task List Controller listonly.Action Controller/— the four wizard actions.Backend/ Task Wizard Controller Controller/—Backend/ Task Execution Controller execute,Form Action execute,Action refresh.Input Action Controller/—Backend/ Task Records Controller list,Tables Action fetch,Records Action load.Record Data Action
Each controller is # and remains thin: parse the
request DTO, delegate to a service, return a typed response.
Service extraction
Two new application services capture the logic the controllers currently embed:
Service/(withTask/ Task Input Resolver Interface TaskInput Resolver final readonlyimpl) — owns the four "where does the input text come from" branches that today live asget,Input Data () get,Syslog Data () get,Deprecation Log Data () getprivate helpers. Each branch becomes an injectable strategy (or aTable Data () matchover a typed source enum, depending on shape after closer inspection).Service/(withTask/ Task Execution Service Interface Taskimpl) — coordinates: resolve input viaExecution Service TaskInputResolver, render the prompt template via the existingPromptTemplateService, dispatch toLlmServiceManager, return a typed result DTO. This is also the hook for the future REC #4 budget pre-flight.
Repository layer
Inline SQL moves to repository methods on two repositories:
Domain/gainsRepository/ Task Repository fetchandSample Records (string $table, ...) loadfor the picker controller.Record Row (string $table, int $uid) - The
sys_and deprecation-log reads (which are TYPO3-internal, not Task-domain) move into a smalllog Service/collaborator that wraps the appropriateTask/ Task Input Resolver Connection/Pool Filesystemcalls in named methods, then is exposed via an interface so tests can stub it.
Typed response normalization
Every AJAX action returns a typed Response/* DTO. Five new ones
are introduced where no existing match is good enough:
Response/(record picker — table dropdown).Table List Response Response/(record picker — row results).Record List Response Response/(record picker — single row payload).Record Data Response Response/(execute success).Task Execution Response Response/(refresh-input result).Task Input Response
Existing Error covers every error branch; raw
new Json calls go away.
Rollout plan
The split lands as a sequence of slices, each its own PR, each independently revertible. A single mega-PR would block on every review iteration; small slices keep each step reviewable.
Sequence
- Slice 13a — extract repository methods.
TaskRepositorygains the new methods;TaskControllergets refactored to call them but keeps every route. Pure SQL move; no behaviour change. - Slice 13b — extract
Task+ implementation.Input Resolver Interface TaskControllerprivate helpers become service calls. No behaviour change. - Slice 13c — extract
Task. Controller delegates execute orchestration to the service; this is also where the future REC #4 budget pre-flight will hook in (see ADR-025 / ADR-026).Execution Service - Slice 13d — introduce typed responses; convert every
JsonResponse(['success' => …])site. -
Slice 13e — split the controller in two passes:
- Register the four new controllers (each with the
#attribute) and repoint every entry in[As Controller] Configuration/Backend/AjaxRoutes.phpandConfiguration/Backend/Modules.phpfromTaskto the matching action on the new per-pathway controller.Controller:: action Xxx TaskControlleritself remains in the tree at this point, but no production code references it any more — every route resolves to a new controller. - In a follow-up commit (or follow-up PR if review surface gets
large), delete
TaskController.phpalong with any test doubles still referencing it. This pass is mechanical: drop the file, drop test imports, run the test suite.
Sequencing matters. Routes must move before the file is deleted, otherwise the container compile would fail at the intermediate step.
- Register the four new controllers (each with the
Each slice maintains AJAX URL stability. JavaScript ajaxUrls
constants registered via Page
keep their existing names; only the route's target field
changes.
Backwards compatibility
- The four existing AJAX routes
(
ajax_,nrllm_ task_ execute ajax_,nrllm_ task_ list_ tables ajax_,nrllm_ task_ fetch_ records ajax_) keep their identifiers and paths. Frontend code that resolves them via the inline-settings mechanism is unaffected.nrllm_ task_ load_ record - The backend module entry under
Configuration/Backend/Modules.phpkeeps its current identifier; the controllertargetvalue updates fromTaskController::listActiontoTaskListController::listAction. - No public API change:
TaskControlleris annotated#and is not part of any documented extension point.[As Controller]
Consequences
Positive
- Each pathway becomes navigable in isolation. PR scope on Task-area changes shrinks accordingly.
- The repository layer regains its position as the single source of Task-domain DB access. Future schema changes touch one file.
- The audit's "DTO/VO vs arrays" axis (currently 8/10 after slice 7) closes the last open gap on the controller layer: every backend AJAX endpoint then ships a typed response.
Taskbecomes the natural seam for REC #4 (auto budget + usage in feature services). Without this service, REC #4 would have had to injectExecution Service Interface BudgetServicedirectly into the controller — a smell.- Each new controller has < 250 LOC, so PHPMD/PHPStan complexity metrics improve uniformly.
Negative / costs
- Five PRs of churn touching 25 files. CI matrix runs each, the review backlog scales accordingly.
- Backend module config (
Configuration/Backend/Modules.php) and AJAX routes (Configuration/Backend/AjaxRoutes.php) need to point at the new controllers; any extension that programmatically resolvesTaskby class name (none in this repo, but possible downstream) breaks.Controller - Functional + E2E tests that reference
TaskController::classneed updating (counted: 6 functional, 2 E2E). Each gets a one-line change per slice that touches the relevant action.
Alternatives considered
- Smallest-delta — keep
TaskControllerwhole, only do service + repository extraction, don't split into per-pathway classes. Hits the audit's SQL and DTO sub-points but leaves the god-class shape. Rejected: doesn't solve "navigation" problem.
- Split-only — split into four controllers but leave SQL inline and DTO usage inconsistent. Rejected: the SQL and DTO problems are the audit's specific findings; a split that doesn't address them is rearranging deck chairs.
- One mega-PR — perform every extraction in a single change. Rejected: review surface too large; per-slice revertability gone; bisect harder.
References
- Audit:
claudedocs/audit-2026-04-23-architecture.md§ REC #5 (kept locally under the gitignoredclaudedocs/directory; not part of the published documentation tree). - Existing controller patterns:
Configuration,Controller Provider,Controller Model.Controller - ADR-024 (Dashboard Widgets) — typed-response precedent.
- ADR-026 (Provider Middleware Pipeline) — the
natural integration point for REC #4 once
TaskExecutionServiceexists.