ADR-028: Public services policy in Configuration/Services.yaml
- Status
-
Accepted
- Date
-
2026-04-30
- Slice
-
25 (audit 2026-04-23 REC #9c)
Context
The 2026-04-23 architecture audit (claudedocs/audit-2026-04-23-architecture.md)
flagged the count of public: true overrides in
Configuration/Services.yaml (32 at the time of the audit; 37 after
intermediate slices added new typed-interface aliases) as
"excessive". The default in this extension's _defaults block is
public: false, so every public: true line is an explicit
override that needs justification.
REC #9c asked: "reduce public: true to only those genuinely needed."
Decision
The current public-service set is documented here as the deliberate
policy. Each public service belongs to one of four categories below,
each with a load-bearing reason. New public: true entries must fit
one of these categories or add a new one (with rationale appended to
this ADR).
A new unit test (Tests/Unit/Configuration/PublicServicesPolicyTest.php)
keeps the count honest going forward — when the policy adds a new
category it must also record the rationale.
Categories
1. Public LLM-API surface. Services that downstream extensions
and host-instance integrations consume via
$container->get(ServiceClass::class) or via direct DI hint in
their own services.yaml. These are the documented application
surface; they MUST be public.
Service\LlmServiceManager(+LlmServiceManagerInterface)Service\Feature\CompletionService(+ Interface)Service\Feature\EmbeddingService(+ Interface)Service\Feature\TranslationService(+ Interface)Service\Feature\VisionService(+ Interface)Service\BudgetService(+BudgetServiceInterface)Service\CacheManager(+CacheManagerInterface)Service\UsageTrackerService(+UsageTrackerServiceInterface)Service\LlmConfigurationService(+LlmConfigurationServiceInterface)Service\PromptTemplateService(+PromptTemplateServiceInterface)Provider\ProviderAdapterRegistry(+ProviderAdapterRegistryInterface)Specialized\Translation\TranslatorRegistry(+TranslatorRegistryInterface)
2. Specialized services with public method surfaces. AI-domain services that act as discrete public APIs, exposed for callers that want them in isolation (image-only, speech-only consumers).
Specialized\Speech\WhisperTranscriptionServiceSpecialized\Speech\TextToSpeechServiceSpecialized\Image\DallEImageServiceSpecialized\Image\FalImageService
3. Repositories consumed by tests through the TYPO3 testing
framework. TYPO3 FunctionalTestCase::get() uses the Symfony
container's ->get() lookup, which only resolves public services.
Repositories are exercised by functional tests that round-trip
fixtures through real Doctrine, so they must be public.
Domain\Repository\LlmConfigurationRepositoryDomain\Repository\ProviderRepositoryDomain\Repository\ModelRepositoryDomain\Repository\TaskRepositoryDomain\Repository\UserBudgetRepository
4. SetupWizard collaborators. Three services that are
co-instantiated by the wizard controller's typed-DTO factories
(DetectedProvider, DiscoveredModel,
SuggestedConfiguration). They are public so the wizard's
multi-step flow can re-resolve them across requests without holding
mutable state in the controller.
Service\SetupWizard\ProviderDetectorService\SetupWizard\ModelDiscovery(+ModelDiscoveryInterface)Service\SetupWizard\ConfigurationGenerator
What is NOT public (intentionally)
The autowiring resource block at the top of Services.yaml
(Netresearch\NrLlm\: { resource: '../Classes/*' }) registers
every other class in the namespace as private by default. That
covers:
- Compiler passes (
DependencyInjection\) - Middleware (
Provider\Middleware\Fallback / Budget / Usage / Cache) - The fallback executor and its support helpers
- Setup-wizard support DTOs and resolvers
- All form / TCA / widget data-provider helpers
- Internal coercion / parsing helpers
These flow through DI constructor injection only. There is no
$container->get() call site for any of them, no test fixture
requires them by class name, and there is no documented external
consumer.
Constraint and enforcement
The unit test
Tests/Unit/Configuration/PublicServicesPolicyTest.php parses
Configuration/Services.yaml and asserts:
- The total count of
public: truekeys matches the expected total (currently 37). - The ADR file exists and references both
REC #9cand thepublic: truepolicy text.
Breakdown of the 37:
- 21 Category 1 — Public LLM API surface
(12 concrete services + 9 interface aliases).
Note the 12 / 9 asymmetry:
CompletionService,EmbeddingService,TranslationService,VisionServicecontribute 4 concrete entries but their interface aliases are registered separately (4 aliases). The remaining 8 concrete services pair with 5 interface aliases; three core services (LlmServiceManager,ProviderAdapterRegistry,TranslatorRegistry) keep the interface-alias entry whileBudgetService,CacheManager,UsageTrackerService,LlmConfigurationService,PromptTemplateServiceeach have both a concrete + interface entry. The maths: 12 concrete + 9 aliases = 21. - 4 Category 2 — Specialized services (Whisper, TextToSpeech, DallE, Fal).
- 5 Category 3 — Repositories (LlmConfiguration, Provider, Model, Task, UserBudget).
- 4 Category 4 — SetupWizard (3 concrete: ProviderDetector, ModelDiscovery, ConfigurationGenerator + 1 alias: ModelDiscoveryInterface).
- 3 Doctrine + provider-adapter wiring tail — small set of services that the host instance / dashboard widgets resolve by class-name through the public container.
The current test enforces only the count and the ADR's
presence. It does not statically validate that each individual
public: true entry maps to a category line in this ADR — that
would require parsing the ADR's bullet lists. The intentional
friction is therefore: a contributor who adds a public: true
line bumps the count, the test fails with a prompt to update both
this ADR and the constant. Reviewers verify the entry against the
categories during PR review.
Adding a new public service therefore requires three things in the
same PR: the service definition, this ADR amended (with the new
entry placed in the appropriate category, and the running total in
the test docblock updated), and the
EXPECTED_PUBLIC_TRUE_COUNT constant bumped.
Consequences
- No reduction in count. Every current entry is justified; removing any of them would break either downstream consumers (Category 1, 2) or our own functional tests (Category 3, 4).
- Future-proofing. A new "I'll just make it public" PR now needs an explicit ADR amendment.
- Drift detection. The architecture test catches a silent
public: trueaddition that bypasses the policy.
Alternative considered
Mass reduction (privatize everything except Category 1).
Rejected: would break 22 functional tests that resolve repositories
and wizard services via $this->get(), and the eight functional
test files would each need a parallel services-test.yaml
override. The maintenance cost outweighs the static-policy win;
auditing through this ADR + architecture test is the same outcome
without the test-infrastructure churn.