Skip to content

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). 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, BR-14).
  • 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 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) 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.

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.

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*.

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