ADR-027: Split TaskController 

Status

Accepted

Date

2026-04

Authors

Netresearch DTT GmbH

Context 

Classes/Controller/Backend/TaskController.php has grown to 920 lines carrying eleven public actions, nine private helpers, and three distinct user-facing pathways:

  • List / cataloglistAction().
  • AI wizard (create a Task from a natural-language description) — wizardFormAction(), wizardGenerateAction(), wizardGenerateChainAction(), wizardCreateAction().
  • Execution (run a stored Task with various input sources) — executeFormAction(), executeAction(), refreshInputAction().
  • Record picking (browse DB tables to source Task input from a record) — listTablesAction(), fetchRecordsAction(), loadRecordDataAction().

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:

  1. Inline SQL. Eight call sites use ConnectionPool / QueryBuilder directly to query sys_log, the picked record's table, and so on. Repository layer is bypassed.
  2. Inconsistent response shape. Most backend controllers return typed Response/* DTOs (ToggleActiveResponse, TestConfigurationResponse, etc.) — see ADR-024 widget pattern and the ConfigurationController precedent. TaskController's AJAX actions instead return raw new JsonResponse(['success' => …, 'error' => …]) literals at sixteen call sites.
  3. 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 ConfigurationController / ProviderController / ModelController:

  • Controller/Backend/TaskListControllerlistAction only.
  • Controller/Backend/TaskWizardController — the four wizard actions.
  • Controller/Backend/TaskExecutionControllerexecuteFormAction, executeAction, refreshInputAction.
  • Controller/Backend/TaskRecordsControllerlistTablesAction, fetchRecordsAction, loadRecordDataAction.

Each controller is #[AsController] 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/Task/TaskInputResolverInterface (with TaskInputResolver final readonly impl) — owns the four "where does the input text come from" branches that today live as getInputData(), getSyslogData(), getDeprecationLogData(), getTableData() private helpers. Each branch becomes an injectable strategy (or a match over a typed source enum, depending on shape after closer inspection).
  • Service/Task/TaskExecutionServiceInterface (with TaskExecutionService impl) — coordinates: resolve input via TaskInputResolver, render the prompt template via the existing PromptTemplateService, dispatch to LlmServiceManager, 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/Repository/TaskRepository gains fetchSampleRecords(string $table, ...) and loadRecordRow(string $table, int $uid) for the picker controller.
  • The sys_log and deprecation-log reads (which are TYPO3-internal, not Task-domain) move into a small Service/Task/TaskInputResolver collaborator that wraps the appropriate ConnectionPool / Filesystem calls 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/TableListResponse (record picker — table dropdown).
  • Response/RecordListResponse (record picker — row results).
  • Response/RecordDataResponse (record picker — single row payload).
  • Response/TaskExecutionResponse (execute success).
  • Response/TaskInputResponse (refresh-input result).

Existing ErrorResponse covers every error branch; raw new JsonResponse(['success' => false, ...]) 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 

  1. Slice 13a — extract repository methods. TaskRepository gains the new methods; TaskController gets refactored to call them but keeps every route. Pure SQL move; no behaviour change.
  2. Slice 13b — extract TaskInputResolverInterface + implementation. TaskController private helpers become service calls. No behaviour change.
  3. Slice 13c — extract TaskExecutionService. 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).
  4. Slice 13d — introduce typed responses; convert every JsonResponse(['success' => …]) site.
  5. Slice 13e — split the controller in two passes:

    1. Register the four new controllers (each with the #[AsController] attribute) and repoint every entry in Configuration/Backend/AjaxRoutes.php and Configuration/Backend/Modules.php from TaskController::actionXxx to the matching action on the new per-pathway controller. TaskController itself remains in the tree at this point, but no production code references it any more — every route resolves to a new controller.
    2. In a follow-up commit (or follow-up PR if review surface gets large), delete TaskController.php along 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.

Each slice maintains AJAX URL stability. JavaScript ajaxUrls constants registered via PageRenderer::addInlineSettingArray() 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_nrllm_task_load_record) keep their identifiers and paths. Frontend code that resolves them via the inline-settings mechanism is unaffected.
  • The backend module entry under Configuration/Backend/Modules.php keeps its current identifier; the controller target value updates from TaskController::listAction to TaskListController::listAction.
  • No public API change: TaskController is annotated #[AsController] and is not part of any documented extension point.

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.
  • TaskExecutionServiceInterface becomes the natural seam for REC #4 (auto budget + usage in feature services). Without this service, REC #4 would have had to inject BudgetService directly 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 resolves TaskController by class name (none in this repo, but possible downstream) breaks.
  • Functional + E2E tests that reference TaskController::class need updating (counted: 6 functional, 2 E2E). Each gets a one-line change per slice that touches the relevant action.

Alternatives considered 

  1. Smallest-delta — keep TaskController whole, 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.
  1. 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.
  1. 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 gitignored claudedocs/ directory; not part of the published documentation tree).
  • Existing controller patterns: ConfigurationController, ProviderController, ModelController.
  • ADR-024 (Dashboard Widgets) — typed-response precedent.
  • ADR-026 (Provider Middleware Pipeline) — the natural integration point for REC #4 once TaskExecutionService exists.