---
title: "ADR-065: Reduce the public service surface (ADR-028 follow-up)"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-065@0.35"
source: "Adr/Adr065ReducePublicServiceSurface.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-065: Reduce the public service surface (ADR-028 follow-up)

-   *Status:* Accepted
-   *Date:* 2026-07-15
-   *Supersedes:* the count and Category 3 / tail rationale of [ADR-028](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-028@0.35)
-   *Authors:* Netresearch DTT GmbH

## Context

ADR-028 froze the `public: true` overrides in
`Configuration/Services.yaml` at 45 and concluded "no reduction in
count": every entry was said to be load-bearing because removing it
would break either downstream consumers or the extension's own
functional tests. The functional-test half of that argument rested on
one premise:

> "TYPO3 `FunctionalTestCase::get()` uses the Symfony container's
> `->get()` lookup, which only resolves public services."

That premise is wrong. The testing framework ships a fixture extension
`typo3/testing-framework` →
`Resources/Core/Functional/Extensions/private_container` whose
`PrivateContainerWeakRefPass` runs at `TYPE_BEFORE_REMOVING` and
registers **every private service** (and private alias) into a public
service locator. `FunctionalTestCase::get()` falls back to that
locator:

```php
public function get(string $id): mixed
{
    if ($this->getContainer()->has($id)) {
        return $this->getContainer()->get($id);
    }
    return $this->getPrivateContainer()->get($id);   // private services
}
```

So `$this->get(SomeConcrete::class)` resolves a **private** service in
functional and backend-E2E tests without any override. The repository in
fact already relied on this: `WizardGeneratorService` was private
(`public: false` interface alias, no public concrete) yet resolved by
class name in green functional tests. ADR-028 Category 3 ("repositories
must be public for `FunctionalTestCase::get()`") and the
class-name-resolution tail were therefore public for a reason that never
held.

## Decision

Keep `public: true` **only** where it is genuinely required, and
privatise everything that was public solely for test resolution. A
service needs `public: true` if, and only if, it is:

1.  part of the documented downstream LLM-API contract that consuming
    extensions resolve by class name or interface via
    `$container->get()` / a DI type hint; or

1.  a supporting-service **interface alias** consumers wire against
    (the concrete class is private); or

1.  a concrete-only documented surface with no interface (only
    `PromptSnippetComposer`, ADR-031); or
1.  a specialized standalone consumer API (speech / image in isolation);
    or

1.  resolved **outside DI** via `GeneralUtility::makeInstance()`, which
    only reuses the container-built, dependency-injected instance for
    public services.

Everything else — the repositories, the setup-wizard collaborators
`ModelDiscovery` / `ConfigurationGenerator`, the read-only
`UsageAnalyticsService`, and the concrete supporting services behind a
public interface alias — is now private (autoregistered by the
`Netresearch\NrLlm\` namespace block). Functional and backend-E2E
tests resolve them unchanged through the private container. No test code
and no runtime behaviour changed; only container visibility did.

## The reduced public surface (27)

**A. Documented downstream LLM-API contract** — 7 concrete + 7 interface
aliases = 14:

-   `Service\LlmServiceManager` (+ `LlmServiceManagerInterface`)
-   `Provider\ProviderAdapterRegistry`
    (+ `ProviderAdapterRegistryInterface`)
-   `Service\Feature\CompletionService` (+ Interface)
-   `Service\Feature\VisionService` (+ Interface)
-   `Service\Feature\EmbeddingService` (+ Interface)
-   `Service\Feature\TranslationService` (+ Interface)
-   `Service\Feature\ToolCallingService` (+ Interface, ADR-051)

Added to Category A after this ADR (count superseded by
[ADR-071: Public keyword-search facade over the retrieval cascade](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-071@0.35)):

-   `Service\Retrieval\KeywordSearchInterface` (alias; the concrete
    `KeywordSearchService` stays private, ADR-071)
-   `nr_llm.keyword_search.index_backed` (named index-backed-only
    variant, ADR-071)

**B. Supporting-service interface aliases** (concrete classes now
private) — 6:

-   `Service\CacheManagerInterface`
-   `Service\UsageTrackerServiceInterface`
-   `Service\PromptTemplateServiceInterface`
-   `Service\LlmConfigurationServiceInterface`
-   `Service\BudgetServiceInterface`
-   `Specialized\Translation\TranslatorRegistryInterface`

**C. Concrete-only documented surface** — 1:

-   `Service\Prompt\PromptSnippetComposer` (ADR-031, no interface)

**D. Specialized standalone consumer API** — 4:

-   `Specialized\Speech\WhisperTranscriptionService`
-   `Specialized\Speech\TextToSpeechService`
-   `Specialized\Image\DallEImageService`
-   `Specialized\Image\FalImageService`

**E. Resolved outside DI via makeInstance()** — 2:

-   `Service\Tool\ToolRegistry` — TCA `itemsProcFunc` in
    `Form\Tca\ToolGroupItems` (ADR-042).
-   `Service\SetupWizard\ProviderDetector` — the DataHandler hook
    `Hook\ProviderEndpointNormalizationHook`.

Total: 14 + 6 + 1 + 4 + 2 = **27** (down from 45).

## What became private

Removed from the public set (autoregistered private; injected via DI,
resolved in tests via the private container):

-   Repositories (8): `LlmConfigurationRepository`,
    `ProviderRepository`, `ModelRepository`, `TaskRepository`,
    `PromptSnippetRepository`, `UserBudgetRepository`,
    `SkillRepository`, `SkillSourceRepository`.
-   Setup-wizard collaborators: `ModelDiscovery` (concrete;
    `ModelDiscoveryInterface` alias kept for autowiring, now private) and
    `ConfigurationGenerator`.
-   Supporting concretes behind a public interface alias:
    `CacheManager`, `UsageTrackerService`, `PromptTemplateService`,
    `LlmConfigurationService`, `BudgetService`,
    `Specialized\Translation\TranslatorRegistry`.
-   `Service\UsageAnalyticsService` (read-only Analytics reporting
    service; its interface alias was already private).

## Consequences

-   The audited public surface drops from 45 to 27. The
    `PublicServicesPolicyTest` count constant and this ADR are the audit
    trail; ADR-028's "no reduction" conclusion is superseded.
-   Consuming extensions that resolved a **concrete** supporting service by
    class name (e.g. `$container->get(CacheManager::class)`) must switch
    to the interface (`CacheManagerInterface`). This is a breaking change
    for those callers, acceptable pre-1.0 and consistent with the
    interface being the documented contract.
-   Tests are unchanged: the private container keeps `$this->get()` on a
    private service working. Any future test that needs a private service
    by class name works out of the box for the same reason.
-   Adding a new `public: true` still requires the three-part change from
    ADR-028 (service definition, ADR entry, `EXPECTED_PUBLIC_TRUE_COUNT`
    bump) — but the bar is now "does a production, non-DI caller or a
    documented downstream consumer need it?", not "does a test resolve it?"

## Relation to ADR-028

ADR-028 stays as the record of the original policy and the
`public: true` enforcement test. This ADR supersedes its **count** and
its Category 3 / tail rationale. The enforcement mechanism
(`Tests/Unit/Configuration/PublicServicesPolicyTest.php`) is retained;
only the expected total changed (45 → 27).
