Status: Accepted Domain: BFF API Architecture / DI Composition Date Accepted: 2026-06-01 Last Updated: 2026-06-02 (renumbered) Concise version:
.claude/adr/ADR-032-bff-nullobject-kill-switch.mdNote (2026-06-02): Originally drafted on master as ADR-030. Renumbered to ADR-032 during R4 merge to resolve number collision with R4's ADR-030 (PaneEventBus) + ADR-031 (Stage Lifecycle). See.claude/adr/INDEX.md. Cross-references:
- Supersedes parts of ADR-018 (kill switches) — extends with composition-root binding requirement
- Reinforces ADR-010 (DI minimalism) — Null-Objects do NOT add new interfaces
- Codifies the workflow rule from CLAUDE.md §10 bullet 6 ("Endpoints that map unconditionally must have unconditional service registration") — explains HOW to satisfy that rule when a service must remain feature-gated
- Resolves r1 ledger entries RB-T028-03/04/05/06
- Originates from escalation
E-01(5-layer asymmetric-binding cascade)
The Sprk.Bff.Api BFF has feature-gated AI capabilities via two kill switches: DocumentIntelligence:Enabled and Analysis:Enabled. Per ADR-018, these flags MAY disable AI features at startup — services like IOpenAiClient, IRagService, IBriefingAi, SprkChatAgentFactory are not registered when the flag is off.
However, the BFF's minimal-API endpoints are registered in EndpointMappingExtensions.MapDomainEndpoints and ASP.NET Core performs endpoint metadata generation at startup. This pipeline fails on the FIRST unresolvable handler parameter — taking down the entire WebApplication regardless of whether the failing endpoint was even going to be hit.
The r1 ledger captured 4 entries (RB-T028-03/04/05/06) tracing to this symptom:
- RB-T028-03: KnowledgeBase endpoints — 13 tests skipped
- RB-T028-04: Chat endpoints — 11 tests skipped
- RB-T028-05: Re-analysis flow — 8 tests skipped
- RB-T028-06: Auth filter collateral — 4 tests skipped
The r2 project's E-01 escalation surfaced that the actual surface is wider:
- Phase 1a inventory identified 13 services requiring treatment (8 BLOCKING + 5 LATENT pairs)
- Phase 1c iterative discovery flushed 3 additional residuals (ChatContextMappingService, DocxExportService, IWorkingDocumentService) that the static inventory missed because their endpoint param-injection wasn't exercised by the immediate test fixtures
- Step 9.5 latent-bug scan flushed 2 more residuals (IVisualizationService, IFileIndexingService) hidden by Moq stubs in 5 test fixtures
- Total: 18 services migrated across r2 task 011 (10 P3 Null-Objects + 8 P1 promote-to-unconditional)
ADR-018 in its current form is INSUFFICIENT: it says "Disabled features return 503 ProblemDetails" at the endpoint layer but does NOT specify what happens at the DI-composition layer when an endpoint declares a parameter whose type is conditionally registered. Without a binding pattern, the choice was made ad hoc per service — leading to the cluster bug r2 closes.
This ADR codifies the Null-Object kill-switch pattern: every conditionally-registered service that has an unconditionally-mapped endpoint consumer MUST have a Null-Object impl registered in the else branch of the gate.
For every BFF service T that is registered conditionally (inside an if (flag) { ... } block in any *Module.cs file) AND has at least one consumer in an unconditionally-mapped endpoint handler:
A Null-Object implementation of
TMUST be registered in the correspondingelsebranch of the gate.
The Null-Object MUST be one of three behavioral patterns, chosen per service:
| Pattern | When to use | Behavior contract |
|---|---|---|
| P1: Promote-to-unconditional | Service has zero feature-gated transitive deps | Move the registration outside the if-block. Real impl runs in both states. |
| P2: Quiet no-op Null-Object | Service is a side-effecting fire-and-forget operation where absence equals "nothing happened" | Methods return Task.CompletedTask, default-constructed values, or empty enumerables. No exception. |
| P3: Fail-fast Null-Object | Service returns data/computation; silent empty would mislead consumers about kill-switch state | Methods throw FeatureDisabledException("<feature>.disabled", "<feature> requires X:Enabled=true …"). Endpoint handler catches and returns 503 ProblemDetails per ADR-018. |
The choice MUST be documented in the corresponding module file's /// XML comment near the else branch. For r2 task 011, the per-service choices are enumerated in projects/sdap.bff.api-test-suite-repair-r2/decisions/D-09-nullobject-design.md.
- MUST register a Null-Object (or promote-to-unconditional) for any conditional service consumed by an unconditionally-mapped endpoint. CLAUDE.md §10 bullet 6 codifies the "endpoints that map unconditionally must have unconditional service registration" rule; this ADR is the canonical implementation pattern.
- MUST use one of the 3 patterns (P1/P2/P3). NO other patterns (e.g.,
IServiceProvider.GetService<T>()runtime resolution in endpoint handler) — that violates ADR-010 and breaks param-inference. - MUST throw
FeatureDisabledException(NOT genericInvalidOperationException) for P3 Null-Objects, so endpoint catch sites can convert to 503 ProblemDetails uniformly. - MUST keep the Null-Object's constructor signature compatible with consumers that don't change. The Null-Object impl typically takes ONLY
ILogger<T>(or nothing) — no other conditional deps. - MUST mention the pattern choice in the module file's XML comment for the
elsebranch, e.g.,// P3 Null-Object: see ADR-030. - MUST place
catch (FeatureDisabledException ex)BEFORE genericcatch (Exception ex)in endpoint handlers, so 503 takes precedence over fall-through 500. - MUST use the canonical
errorCodenaming conventionai.<feature>.disabled(e.g.,ai.chat.disabled,ai.rag.disabled,ai.briefing.disabled,ai.text-extraction.disabled,ai.visualization.disabled,ai.file-indexing.disabled).
- MUST NOT introduce a new interface SOLELY for the Null-Object. If the service is currently a concrete class (per ADR-010), the Null-Object SHOULD be a subclass (which may require unsealing) — NOT a new
IServiceextracted just for testability. - MUST NOT use Null-Object pattern to silently mask broken DI configuration. Null-Objects exist for INTENTIONAL kill-switch states only. Real registration mistakes (the r1 ledger RB-T028 root cause: services placed inside feature-gated DI modules because AI features happen to consume them, but whose constructor deps are CRUD-only) should be repaired via Promote-to-unconditional (P1).
- MUST NOT allow Null-Object to bypass authorization filters. Null-Object only neutralizes the SERVICE behavior; ASP.NET Core endpoint filters (auth, rate limit) fire unchanged.
- MUST NOT register a quiet no-op (P2) for query/computation services. The silent-empty result misleads consumers.
public class SprkChatAgentFactory // NOTE: NOT sealed — Null-Object derives
{
// Production ctor — takes all the AI deps.
public SprkChatAgentFactory(IOpenAiClient openAiClient, IPlaybookService playbookService, ILogger<SprkChatAgentFactory> logger) { … }
// Null-Object ctor — minimal deps, only ILogger.
protected SprkChatAgentFactory(ILogger<SprkChatAgentFactory> logger)
{
_logger = logger;
}
public virtual Task<ISprkChatAgent> CreateAgentAsync(...) { /* real impl */ }
}
internal sealed class NullSprkChatAgentFactory : SprkChatAgentFactory
{
public NullSprkChatAgentFactory(ILogger<SprkChatAgentFactory> logger) : base(logger) { }
public override Task<ISprkChatAgent> CreateAgentAsync(...)
=> throw new FeatureDisabledException(
"ai.chat.disabled",
"AI chat requires Analysis:Enabled=true AND DocumentIntelligence:Enabled=true.");
}
// In AnalysisServicesModule.AddNullObjectsForCompoundOff:
services.AddSingleton<SprkChatAgentFactory>(sp =>
new NullSprkChatAgentFactory(sp.GetRequiredService<ILogger<SprkChatAgentFactory>>()));internal sealed class NullBriefingAi : IBriefingAi
{
private readonly ILogger<NullBriefingAi> _logger;
public NullBriefingAi(ILogger<NullBriefingAi> logger) => _logger = logger;
public Task<string> GenerateNarrativeAsync(string prompt, int? maxOutputTokens = null, CancellationToken ct = default)
{
_logger.LogDebug("NullBriefingAi.GenerateNarrativeAsync invoked — AI disabled");
throw new FeatureDisabledException("ai.briefing.disabled", "Briefing requires AI.");
}
}
// In AnalysisServicesModule else-branch:
services.AddScoped<IBriefingAi, NullBriefingAi>();private static async Task<IResult> SearchInvoices(
string query, IInvoiceSearchService searchService, ...)
{
try
{
var result = await searchService.SearchAsync(query, ...);
return TypedResults.Ok(result);
}
catch (FeatureDisabledException ex)
{
return ex.AsFeatureDisabled503(); // shared helper — see §5
}
catch (Exception ex) // generic catch placed AFTER
{
logger.LogError(ex, "Unexpected error in SearchInvoices");
return Results.Problem(statusCode: 500, ...);
}
}// BEFORE (in AddPlaybookServices, inside compound gate):
private static void AddPlaybookServices(IServiceCollection services)
{
services.AddHttpClient<IPlaybookService, PlaybookService>();
services.AddSingleton<NotificationService>(); // ← misregistered (CRUD-only deps)
}
// AFTER:
private static void AddPlaybookServices(IServiceCollection services)
{
services.AddHttpClient<IPlaybookService, PlaybookService>();
// NotificationService promoted to unconditional — has zero AI deps (per ADR-030).
}
// At top of AddAnalysisServicesModule (UNCONDITIONAL):
services.AddSingleton<NotificationService>();For SSE handlers where the response stream has been committed (HTTP headers written + content-type text/event-stream), a 503 ProblemDetails JSON cannot be returned. Instead, emit an error chunk:
try
{
await foreach (var chunk in agent.StreamAsync(ct))
await WriteChatSseAsync("token", chunk, response, ct);
await WriteChatSseAsync("done", "", response, ct);
}
catch (FeatureDisabledException ex)
{
await WriteChatSseAsync("error", $"[{ex.ErrorCode}] {ex.Message}", response, ct);
}Clients consuming SSE MUST handle event: error chunks. The [errorCode] prefix in the data payload allows symbolic detection.
A single common exception type for all P3 Null-Objects. Lives in src/server/api/Sprk.Bff.Api/Configuration/FeatureDisabledException.cs:
namespace Sprk.Bff.Api.Configuration;
public sealed class FeatureDisabledException : InvalidOperationException
{
public string ErrorCode { get; }
public FeatureDisabledException(string errorCode, string detail) : base(detail)
=> ErrorCode = errorCode ?? throw new ArgumentNullException(nameof(errorCode));
}The shared 503 helper at src/server/api/Sprk.Bff.Api/Configuration/FeatureDisabledResults.cs:
public static class FeatureDisabledResults
{
public const string TypeUri = "https://errors.spaarke.com/feature-disabled";
public static IResult AsFeatureDisabled503(this FeatureDisabledException ex)
=> Results.Problem(
title: "Feature Disabled",
detail: ex.Message,
statusCode: StatusCodes.Status503ServiceUnavailable,
type: TypeUri,
extensions: new Dictionary<string, object?> { ["errorCode"] = ex.ErrorCode });
}| Scenario | Action |
|---|---|
Service is genuinely optional (e.g., Cosmos persistence) and consumers already use IServiceProvider.GetService<T>() with null-tolerance — and that consumer is NOT a minimal-API handler param |
Leave as-is. Optional-via-GetService is a valid ADR-010 pattern for services NEVER consumed by minimal-API param-inference. |
| Service is consumed ONLY inside another conditional service (transitively conditional) and never injected directly into an endpoint | Leave as-is. The outer conditional registration is enough. |
Feature flag is a true environment-only toggle (e.g., Redis on/off where both branches register a valid IDistributedCache) |
Leave as-is. That's already a symmetric registration. |
Background hosted service (IHostedService) or job handler (IJobHandler<T>) |
Leave conditional. These don't participate in endpoint metadata generation. Under kill switch, fail-fast on dequeue; existing Service Bus retry/DLQ machinery handles per ADR-018. |
// ❌ DON'T: use [FromServices] + nullable + null-check inline in endpoint
private static async Task<IResult> Handler([FromServices] IBriefingAi? briefingAi = null) {
if (briefingAi is null) return Results.Problem(503, ...); // forces every endpoint to repeat
}
// ❌ DON'T: register Null-Object that silently returns empty data (quiet for query services)
public sealed class NullRagService : IRagService {
public Task<RagResponse> SearchAsync(...) => Task.FromResult(RagResponse.Empty); // misleading!
}
// ❌ DON'T: extract a new interface just to Null-Object a concrete sealed class
public interface ISprkChatAgentFactory { ... } // new interface SOLELY for Null-Object — violates ADR-010
public sealed class NullSprkChatAgentFactory : ISprkChatAgentFactory { ... }
public sealed class SprkChatAgentFactory : ISprkChatAgentFactory { ... }
// ✅ DO: throw FeatureDisabledException, catch at endpoint, return 503
internal sealed class NullRagService : IRagService {
public Task<RagResponse> SearchAsync(...)
=> throw new FeatureDisabledException("ai.rag.disabled", "RAG search requires AI.");
}| ADR | Relationship |
|---|---|
| ADR-001 (Minimal API) | Null-Object preserves minimal-API param-inference compatibility — no IServiceProvider injection workarounds in handler signatures. |
| ADR-007 (SpeFileStore facade) | Facade pattern precedent for the P3 Null-Object treatment of public-contracts facades (IBriefingAi, IInvoiceAi, etc.). Also: the B8 refactor in r2 task 011 Phase 1b Tier 3 (KnowledgeBaseEndpoints → IRagService instead of direct SearchIndexClient injection) was incidental ADR-007 cleanup made possible by the Null-Object work. |
| ADR-008 (Endpoint filters) | Null-Object does NOT bypass endpoint filters; auth + rate limit run as normal. |
| ADR-010 (DI minimalism) | Null-Objects MUST NOT introduce new interfaces (DI minimalism). Concrete-class Null-Object via inheritance is the preferred pattern. |
| ADR-018 (Feature flags / kill switches) | ADR-018 says "disabled features return 503 ProblemDetails." ADR-030 specifies HOW to achieve that at the DI-composition layer (Null-Object), in service of the same outcome. |
| ADR-019 (ProblemDetails) | All Null-Object-triggered 503 responses use ProblemDetails per ADR-019 (stable type URI, extensions["errorCode"]). |
| ADR-029 (BFF publish hygiene) | Net code addition for r2 task 011 (10 Null-Objects + helper + exception + endpoint catches) was ~1900 LOC across 34 files — well within publish-size budget. |
| CLAUDE.md §10 (BFF Hygiene — Binding Governance) | This ADR is the canonical mechanism by which "endpoints that map unconditionally must have unconditional service registration" is satisfied when a service must remain conditional. |
Per .claude/constraints/bff-extensions.md § F, every change to BFF service registration MUST be accompanied by test coverage. For Null-Object impls, the test obligation is implicitly satisfied by the existing fixtures (which already set Analysis:Enabled=false) — the Skip→Pass transitions of 37 RB-T028-tagged integration tests in r2 task 011 Phase 1c IS the test evidence.
For NEW Null-Object impls added after r2 closes, a unit test SHOULD verify the Null-Object throws FeatureDisabledException with the expected ErrorCode (one xUnit [Fact] per Null-Object class).
Phase 5 of source project codifies this checklist into docs/procedures/testing-and-code-quality.md for ongoing enforcement. Before merging any PR that adds a new service registration to a *Module.cs DI helper:
- Is the new registration inside a feature-gate
if (flag) { ... }block? If YES: - Does the service have any consumer in an endpoint handler signature (parameter type without
[FromServices] T? = nulldefensive nullable)? - Is that endpoint mapped UNCONDITIONALLY (e.g.,
app.MapXxxEndpoints()called outside anyif (flag))? - If YES to all three: apply ADR-030. Choose P1/P2/P3 per Decision table. Document choice in
else-branch comment.
Static-scan recipe (for code-review automation):
# For each conditional service S:
rg -t cs -n "[\s,(]S\s+\w+[,)]" src/server/api/Sprk.Bff.Api/Api/ # find endpoint param injection
# Cross-check that the consuming endpoint's MapXxx() is itself unconditional in EndpointMappingExtensions.cs.-
Q: Should
FeatureDisabledExceptionderive fromInvalidOperationExceptionor be its own root? A: Derive fromInvalidOperationException. Maintains compatibility with existing endpoint catch-all-exceptions middleware while allowing endpoint-specific filtering by type. -
Q: Should the Null-Object pattern apply to background hosted services (e.g.,
PlaybookSchedulerService)? A: NO. Hosted services that depend on conditional types should remain conditional (no.AddHostedService<NullHostedService>()worker). Hosted services don't participate in endpoint metadata generation. Under kill switch, fail-fast on dequeue; existing Service Bus retry/DLQ machinery handles per ADR-018. -
Q: Should we add a CI rule to ENFORCE this pattern? A: NOT in r2 (per design.md §5.5 explicit non-CI enforcement). Enforcement is PR template + code review + reviewer judgment. Future project could add NetArchTest rule.
-
Q: Why didn't the Phase 1a inventory catch all 18 services? A: The inventory methodology focused on "find conditionally-registered services in DI module files" and "find unconditionally-mapped endpoints in
EndpointMappingExtensions" but did not systematically cross-reference all CONSUMERS of conditional services across the full endpoint surface. The 5 residuals (3 caught at Phase 1c iteration + 2 at Step 9.5 latent-bug scan) were registered conditionally AND consumed by unconditional endpoints, but the consumption sites weren't enumerated until tests surfaced the failure or the proactive scan ran. Lesson: Phase 5 governance update codifies the static-scan recipe in §10 above so this can be done preemptively.
The 13 services identified in r2's initial asymmetric-registration inventory + the 5 residuals discovered during execution = 18 services migrated through r2 task 011 Phase 1b. Per-service patterns:
- P1 Promote-to-unconditional (8 services): NotificationService, IChatDataverseRepository + ChatDataverseRepository, ChatSessionManager, ChatHistoryManager, AnalysisChatContextResolver, StandaloneChatContextProvider, ChatContextMappingService, DocxExportService, IWorkingDocumentService — all had zero AI deps in their constructors; their conditional registration was misclassification.
- P3 Fail-fast Null-Object (10 services): NullBriefingAi, NullInvoiceSearchService, NullPlaybookOrchestrationService, NullTextExtractor, NullPlaybookService, NullRagService, NullSprkChatAgentFactory (subclass via unseal), NullPendingPlanManager (subclass via unseal), NullVisualizationService, NullFileIndexingService.
- P2 Quiet no-op: zero services qualified in r2.
Future PRs adding NEW conditional BFF services MUST apply this pattern preemptively per the test update obligation. See projects/sdap.bff.api-test-suite-repair-r2/decisions/D-09-nullobject-design.md for the per-service rationale.
- Concise version:
.claude/adr/ADR-030-bff-nullobject-kill-switch.md - Source project:
projects/sdap.bff.api-test-suite-repair-r2/ - Initial escalation:
projects/sdap.bff.api-test-suite-repair-r2/escalations/E-01-rb-t028-cluster-scope-expansion.md - Inventory:
projects/sdap.bff.api-test-suite-repair-r2/baseline/asymmetric-registration-inventory-2026-06-01.md - Per-service design:
projects/sdap.bff.api-test-suite-repair-r2/decisions/D-09-nullobject-design.md - r1 ledger entries closed: RB-T028-03, RB-T028-04, RB-T028-05, RB-T028-06
- Fix commits (task 011 Phase 1b + 1c):
d207ae93,1cfac08c,5613b8ad,d932f355,43ca4f9b,dbd3888e,56e74b84,08343e32 - Triple-run evidence:
projects/sdap.bff.api-test-suite-repair-r2/baseline/per-fix-triple-run-rb-t028-cluster-2026-06-01.md