ADR 065 · Accepted

Reduce the Public Service Surface (ADR-028 Follow-up)

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-frameworkResources/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:

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

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

  3. a concrete-only documented surface with no interface (only PromptSnippetComposer, ADR-031); or

  4. a specialized standalone consumer API (speech / image in isolation); or

  5. 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):

  • 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).

← All ADRs