---
title: "ADR-027: Split TaskController"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-027@0.35"
source: "Adr/Adr027SplitTaskController.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# 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 / catalog** — `listAction()`.
-   **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.
1.  **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.
1.  **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/TaskListController` — `listAction`
    only.
-   `Controller/Backend/TaskWizardController` — the four wizard
    actions.
-   `Controller/Backend/TaskExecutionController` —
    `executeFormAction`, `executeAction`,
    `refreshInputAction`.
-   `Controller/Backend/TaskRecordsController` —
    `listTablesAction`, `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.
1.  **Slice 13b** — extract `TaskInputResolverInterface` \+
    implementation. `TaskController` private helpers become
    service calls. No behaviour change.
1.  **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).
1.  **Slice 13d** — introduce typed responses; convert every
    `JsonResponse(['success' => …])` site.
1.  **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.
    1.  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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-024@0.35) (Dashboard Widgets) — typed-response
    precedent.
-   [ADR-026](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-026@0.35) (Provider Middleware Pipeline) — the
    natural integration point for REC #4 once
    `TaskExecutionService` exists.
