---
title: "ADR-059: Decompose LlmServiceManager into focused collaborators"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-059@0.35"
source: "Adr/Adr059DecomposeLlmServiceManager.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-059: Decompose LlmServiceManager into focused collaborators

-   *Status:* Accepted
-   *Date:* 2026-07-14
-   *Authors:* Netresearch DTT GmbH

## Context

`LlmServiceManager` had grown to 987 lines. It is the extension's central
entry point — a `SingletonInterface` implementing
`LlmServiceManagerInterface`, which [ADR-028](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-028@0.35) classifies as
a Category-1 public API. A responsibility scan found nine distinct groups in one
class:

1.  Skill-injection glue (delegates to `SkillInjectionService`).
1.  Default-configuration resolution.
1.  Loading the `nr_llm` extension configuration.
1.  The keyed, ExtensionConfiguration-backed provider registry (register / look
    up / list / configure providers by string identifier).
1.  Generic dispatch (`chat`, `complete`, `embed`, `vision`,
    `streamChat`, `chatWithTools`).
1.  Configuration-backed dispatch (the `*WithConfiguration` methods).
1.  The database-backed adapter factory facade.
1.  Pipeline plumbing (`runThroughPipeline`, budget metadata, transient
    configuration synthesis).
1.  Message shaping (system-prompt injection and message normalisation).

Two of these groups additionally carried duplicated code: the two embedding
entry points each held an inline copy of the cache-metadata block, and six
generic entry points repeated the same options / provider-key extraction
preamble.

## Decision

Decompose the manager in stages. This ADR records **stage 1**, which extracts
the self-contained groups while leaving the dispatch logic in place. The manager
remains `final` and keeps implementing the unchanged
`LlmServiceManagerInterface`; every former public method is retained as a
thin delegation, so the Category-1 contract, the class name, `registerProvider`
and the three non-interface public methods (`getAdapterFromModel`,
`getAdapterFromConfiguration`, `getAdapterRegistry`) are unchanged.

Extracted collaborators (all private, autowired via the `Classes/*` resource
block in `Services.yaml`):

-   `KeyedProviderRegistry` — groups 3 + 4. Holds the mutable provider map
    and the loaded extension configuration, so it is itself a
    `SingletonInterface`. `ProviderCompilerPass` still adds its
    `registerProvider` method calls to the manager service, which forward here.
-   `ConfigurationResolver` — group 2. `readonly`; resolves the
    backend-managed default configuration through the repository.
-   `MessageShaper` — group 9. `readonly`, stateless message normalisation
    and system-prompt injection.
-   `EmbedCacheKeyBuilder` — deduplicates the two inline embed cache-metadata
    blocks. The blocks are **not** identical: the ad-hoc `embed()` path keys
    by provider identifier with the payload `{input, options}` and a
    `nrllm_provider_<id>` tag, while `embedForConfiguration()` keys by
    configuration identifier plus the *effective* model (options override or the
    configuration's model id) with a `nrllm_configuration_<id>` tag, so two
    configurations pointing at different models never share entries. The builder
    therefore shares only the *structure* (the positive-ttl guard and the
    `{cacheKey, cacheTtl, cacheTags}` shape with the common `nrllm_embeddings`
    tag); each caller supplies its own namespace, key payload and scope tag. This
    difference is intentional, not a defect.

The repeated options / provider-key preamble becomes a private
`splitProviderKey()` on the manager (the six callers all remain on the manager
in stage 1).

The manager's constructor changes accordingly — it drops
`ExtensionConfiguration`, `LoggerInterface`,
`CacheManagerInterface` and the `LlmConfigurationRepository` (now
owned by the collaborators) and gains the four collaborators. The constructor is
not part of the interface contract; production wiring is autowired.

## Consequences

-   Behaviour is unchanged. The interface signatures, the provider-resolution
    error messages and their exception codes, and the two embed cache-keying
    strategies are all preserved. The existing manager / integration / e2e tests
    continue to exercise the behaviour through the facade; they construct the
    manager through a test factory (`LlmServiceManagerTestFactory`) that accepts
    the previous leaf-dependency shape and wires the new collaborators, so the call
    sites did not each have to change shape. The extracted classes additionally get
    their own unit tests.
-   New private services keep the documented public-service set stable
    ([ADR-028](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-028@0.35)); the `PublicServicesPolicyTest` count is
    unchanged.
-   The manager drops from 987 to roughly 790 lines.

### Stage 2 (not in this change)

Groups 5 and 6 (generic and configuration-backed dispatch) plus the pipeline
plumbing (group 8) remain on the manager. Extracting per-operation dispatchers
(completion, embedding, streaming, tool-calling) is deferred to a follow-up so
this change stays reviewable and behaviour-preserving. It remains worthwhile:
after stage 1 the manager is still dominated by the dispatch methods, and those
are the natural seam for the privacy-model entry point work
([ADR-026](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-026@0.35) payload constraint). Stage 2 should be taken up when
that work needs the seam.
