---
title: SAIF.NetCore.PingableService architecture
summary: Architectural responsibilities, boundaries, and interaction patterns.
status: draft
owner: SAIF Platform Team
last_reviewed: 2026-10-02
library: SAIF.NetCore.PingableService
reference_type: package
---

# SAIF.NetCore.PingableService architecture

SAIF.NetCore.PingableService is a Framework.NCore implementation-layer library that adds a framework-standard, anonymous `GET /Ping` health endpoint to any ASP.NET Core API host; a request to that endpoint pings every DI-registered `IPingableService` implementation and every MVC controller that implements `IPingableService`, surfacing a 500 through `PingServiceException` if any resolution or ping fails. It depends only on the foundation package `SAIF.NetCore.ServiceContracts`, and it is consumed by `SAIF.NetCore.ServiceExtensions` and, as one of the three documented implementation-to-implementation exceptions, by `SAIF.NetCore.Services.Messaging`.

## Responsibilities
- Expose an inbound, anonymous `GET /Ping` MVC endpoint (`PingableController.Get`) that aggregates the health of every pingable service and controller in the host process.
- Define `IPingableService`, a default-interface-method mixin that gives every implementer a standard `PingAsync` orchestration while allowing `Ping`, `IsThrowablePingError`, `OnPingFailure`, and `OnPingSuccess` to be overridden as hooks.
- Provide two discovery channels for pingable components: explicit DI registration (`AddPingableTransient/Scoped/Singleton<TService,TImplementation>`) and implicit MVC controller discovery (`IControllerRetriever`, opted into via `WithPingableController()`).
- Translate any ping failure — including a DI resolution failure for a misconfigured dependency — into a `PingServiceException` (HTTP 500). A host built on `BaseApiStartup` formats that into a response through `SAIF.NetCore.ServiceExtensions`'s `DynamicErrorHandler` middleware, which delegates to its injected `IErrorHandler` (the `ErrorHandler` class); the older `SAIF.NetCore.ErrorHandler`'s own `Invoke` middleware method is kept only for backwards compatibility and is not installed by `BaseApiStartup`.

## Component boundaries
- `IPingableService`: the mixin contract; `PingAsync` is the only member with a non-trivial default implementation, running `Ping` on the thread pool via `Task.Run` and invoking the failure/success hooks.
- `PingableController`: the hosted endpoint. It resolves `IServiceProvider` and `IControllerRetriever`, runs all registered and controller-discovered pings concurrently with `Task.WhenAll`, and converts any failure into a thrown `PingServiceException`.
- `ControllerRetriever` / `IControllerRetriever`: the controller-discovery boundary — reflects over MVC application-part assemblies for types assignable to both `IPingableService` and `ControllerBase`, then resolves each from the DI container.
- `IServiceCollectionExtensions`: the DI-registration boundary for explicitly opted-in pingable services; each call registers the implementation under both `TService` and `IPingableService` with the same lifetime.
- `MvcBuilderExtensions.WithPingableController()`: the host-wiring boundary — adds this library's assembly as an MVC `AssemblyPart` and registers `IControllerRetriever` as a singleton. It is the supported convenience path for the controller-discovery channel; pairing it with `AddControllersAsServices()` lets pingable *controllers* resolve by concrete type ([BR-14](business-rules.md#br-14-controller-discovery-requires-controllers-registered-as-services)). An equivalent explicit application-part registration plus a direct `IControllerRetriever` registration satisfies the same prerequisites without this call.
- `PingServiceException`: extends `ServiceContracts`' `HttpStatusCodeException` (itself extending `SAIFException`); its constructor initializes the inherited `StatusCode` property to `500`, but that property keeps its public setter and remains mutable after construction.

## Interaction model
- A host composes this library at startup by calling `services.AddControllers().WithPingableController().AddControllersAsServices()` (or the equivalent in `BaseApiStartup`), and optionally registers application-specific pingable services with `AddPingableTransient/Scoped/Singleton`. This is the supported convenience path, not the only wiring that works: the `/Ping` route needs only `PingableController`'s assembly present as an MVC application part and an `IControllerRetriever` registered, and plain DI-registered `IPingableService` services need neither `WithPingableController()` nor `AddControllersAsServices()` at all — only pingable *controllers* resolved through the controller-discovery channel need the `AddControllersAsServices()` pairing ([BR-13](business-rules.md#br-13-withpingablecontroller-enables-route-and-controller-discovery), [BR-14](business-rules.md#br-14-controller-discovery-requires-controllers-registered-as-services)).
- At request time, `PingableController.Get` resolves every `IPingableService` from DI (`GetServices<IPingableService>()`) and every controller-discovered type (`ControllerRetriever.GetControllers`), starts all pings concurrently via each implementation's `PingAsync`, and awaits them with `Task.WhenAll`. The awaited aggregation (faulted vs. canceled), and a synchronous failure during resolution or during `Task.WhenAll`'s own enumeration of the projection, are distinct outcomes handled differently — see [OP-01](operations/op-01-ping-the-api.md) steps 6–7.
- A missing dependency, a constructor failure, a synchronous throw, a faulted awaited task, or a canceled awaited task all become a thrown `PingServiceException`, which this library's own code throws rather than converts into an HTTP response. In hosts built on `BaseApiStartup`, `SAIF.NetCore.ServiceExtensions`'s `DynamicErrorHandler` middleware catches it and delegates to its injected `IErrorHandler` (the `ErrorHandler` class) to format the 500 response; the legacy `SAIF.NetCore.ErrorHandler` `Invoke` middleware method is kept only for backwards compatibility and is not installed by `BaseApiStartup`.
  > **Origin**: Converting the thrown exception into an HTTP response is implemented in [Dynamic error handler invocation](../saif-netcore-serviceextensions/operations/dynamic-error-handler-invoke.md) (`SAIF.NetCore.ServiceExtensions`) and executed on behalf of this library's failure path.
- This library makes no outbound calls of its own; it is purely an inbound, hosted health-check surface. `SAIF.NetCore.Services.Messaging` consumes it only through the controller-discovery channel (`BackgroundConsumerServicesController : ControllerBase, IPingableService`), not through any direct runtime call into this library's implementation code.

## Architecture diagram
The following diagram shows how a host wires the ping endpoint and how it reaches registered and controller-discovered pingable components.

```mermaid
flowchart TB
    Client["HTTP client"]
    PC["PingableController<br>GET /Ping"]
    CR["ControllerRetriever<br>(IControllerRetriever)"]
    DI["IServiceProvider<br>(DI container)"]
    SVC["Registered IPingableService<br>implementations"]
    CTRL["MVC controllers implementing<br>IPingableService"]
    EH["DynamicErrorHandler -><br>injected IErrorHandler<br>(host-installed via BaseApiStartup)"]

    Client -->|GET /Ping| PC
    PC -->|GetServices&lt;IPingableService&gt;| DI
    DI --> SVC
    PC -->|GetControllers| CR
    CR -->|GetService by type| CTRL
    PC -->|on failure: throw<br>PingServiceException| EH
```

## Sequence diagram
The following diagram shows the primary request/response sequence for a ping request.

```mermaid
sequenceDiagram
	participant Client
	participant PingableController
	participant DIContainer as DI container
	participant ControllerRetriever
	participant Pingables as IPingableService implementations

	Client->>PingableController: GET /Ping?guid=<runId>
	PingableController->>DIContainer: GetServices<IPingableService>()
	DIContainer-->>PingableController: registered services
	PingableController->>ControllerRetriever: GetControllers(provider)
	ControllerRetriever-->>PingableController: controller-discovered services
	PingableController->>Pingables: PingAsync(guid, logger) for each (parallel)
	Pingables-->>PingableController: Task.WhenAll result
	alt all awaited tasks succeed
		PingableController-->>Client: 200 OK (guid)
	else one or more awaited tasks faulted
		PingableController->>PingableController: flatten AggregateException,<br>throw new PingServiceException
		PingableController-->>Client: 500 (via DynamicErrorHandler -> IErrorHandler)
	else awaited tasks canceled (whenAll.Exception is null)
		PingableController->>PingableController: rethrow cancellation,<br>wrapped as PingServiceException
		PingableController-->>Client: 500 (via DynamicErrorHandler -> IErrorHandler)
	else synchronous failure (resolution, reflection, or pre-Task throw)
		PingableController->>PingableController: caught directly,<br>wrapped as PingServiceException
		PingableController-->>Client: 500 (via DynamicErrorHandler -> IErrorHandler)
	end
```

## Design constraints
- `WithPingableController()` paired with `AddControllersAsServices()` is the supported path for the controller-discovery channel; without `AddControllersAsServices()` (or an equivalent explicit registration), `ControllerRetriever.GetService(type)` returns `null` for any pingable controller, and the resulting `NullReferenceException` is wrapped into a `PingServiceException` rather than failing fast at startup*. This constraint applies only when the host has pingable controllers to discover — plain DI-registered `IPingableService` services are unaffected.
- All pings for a single request start concurrently and are awaited together; one slow or hanging `PingAsync` implementation delays the entire `/Ping` response.
- The endpoint is anonymous (`[AllowAnonymous]`) by design, since it is a health check; it carries no authentication or authorization of its own (see Security and authorization summary).
- Converting a thrown `PingServiceException` into a formatted HTTP 500 depends on the host having `DynamicErrorHandler` (or an equivalent `IErrorHandler` pipeline) installed, which `BaseApiStartup`-derived hosts do by default; without it, ASP.NET Core's default unhandled-exception handling still yields a 500, but without SAIF formatting*.

## Related documentation
- [Index](index.md)
- [Overview](overview.md)
- [Dependencies](dependencies.md)
- [Operations and processes](operations-processes.md)
- [Business rules](business-rules.md)
- [Security and authorization](security-authorization.md)
- [Service registration](service-registration.md)
- [API reference](api/index.md)
- [SAIF.NetCore.ServiceContracts](../saif-netcore-servicecontracts/overview.md)
- [Dynamic error handler invocation](../saif-netcore-serviceextensions/operations/dynamic-error-handler-invoke.md) (`SAIF.NetCore.ServiceExtensions`)

> **Note:** Statements marked with `*` were inferred from code structure, naming conventions, or contextual evidence where direct confirmation was not available in source artifacts.

## Sources
- `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/IPingableService.cs`
- `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Controllers/PingableController.cs`
- `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Services/ControllerRetriever.cs`
- `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Extensions/IServiceCollectionExtensions.cs`
- `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Extensions/MvcBuilderExtensions.cs`
- `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Exceptions/PingServiceException.cs`
- `src/SAIF.NetCore.ServiceExtensions/SAIF.NetCore.ServiceExtensions/SAIF.NetCore.ServiceExtensions/Startup/BaseApiStartup.cs`
- `src/SAIF.NetCore.ServiceExtensions/SAIF.NetCore.ServiceExtensions/SAIF.NetCore.ServiceExtensions/Middleware/DynamicErrorHandler.cs`
- `src/SAIF.NetCore.ErrorHandler/SAIF.NetCore.ErrorHandler/SAIF.NetCore.ErrorHandler/ErrorHandler.cs`
- `Architecture.md`
