Service Defaults

Put OpenTelemetry, health checks, service discovery and HTTP resilience behind one AddServiceDefaults call, and fix the template's production gaps.

Syntaxvar builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); // OTel, health, discovery, resilience var app = builder.Build(); app.MapDefaultEndpoints(); // /health and /alive

The ServiceDefaults project is a class library the Aspire template generates and your team then owns. It holds two extension methods: AddServiceDefaults(), called on the builder of every service, and MapDefaultEndpoints(), called on the app. It is not a package and has no hidden behaviour; open Extensions.cs and read it. Mark the project with <IsAspireSharedProject>true</IsAspireSharedProject> so the AppHost does not treat it as a runnable service.

What AddServiceDefaults wires

ConcernTemplate codeEffect
Logsbuilder.Logging.AddOpenTelemetry(...)ILogger records exported as OTLP logs with scopes and formatted messages
MetricsWithMetrics(... AddAspNetCoreInstrumentation, AddHttpClientInstrumentation)Request duration, active requests, outbound call metrics
TracesWithTracing(... AddSource(ApplicationName) ...)Spans for incoming and outgoing HTTP, excluding health probes
ExportUseOtlpExporter() only when OTEL_EXPORTER_OTLP_ENDPOINT is setSends to the dashboard locally, to your collector in production
HealthAddHealthChecks().AddCheck("self", ..., ["live"])A liveness check that only proves the process responds
DiscoveryAddServiceDiscovery()Resolves http://inventory from services:inventory:* configuration
ResilienceConfigureHttpClientDefaults(h => h.AddStandardResilienceHandler())Timeouts, retries and a circuit breaker on every factory-created HttpClient

ConfigureHttpClientDefaults applies to every client

ConfigureHttpClientDefaults runs its configuration for every client created through IHttpClientFactory: named, typed, and ones added by libraries. That is how one line gives the whole service retries and discovery. It is also how a payment POST gets retried after a timeout and the customer is charged twice. Opt such clients out: RemoveAllResilienceHandlers() (marked experimental, diagnostic EXTEXP0001) and add a handler with Retry.DisableForUnsafeHttpMethods(), or make the call idempotent with an idempotency key.

Health endpoints

MapDefaultEndpoints maps /health (every registered check, used for readiness) and /alive (only checks tagged live, used for liveness). Client integrations add their database and Redis checks without the live tag, so a database outage marks the pod not ready, without restarting it in a loop. The catch: a shared dependency in readiness removes every replica at once during its outage, and the ingress then answers 503 even for routes that never touch the database. Many teams keep readiness to local concerns and watch dependency health through monitoring, or tag dependency checks and exclude them from the readiness predicate. The template maps both only when the environment is Development, because the response could reveal internal state to the internet.

Decisions to make before production

  • Map the health endpoints in every environment, and keep them off the public ingress: listen on a second port (ASPNETCORE_HTTP_PORTS=8080;8081), expose only 8080 through the Service, and check Connection.LocalPort. RequireHost alone matches the client-controlled Host header and is not access control. Apply the gate only when a port is configured, so the AppHost's WithHttpHealthCheck("/health") still works locally.
  • Set OTEL_EXPORTER_OTLP_ENDPOINT in every deployment. Without it the exporter is never registered and no error is logged; dashboards are simply empty.
  • Review the standard resilience timeouts (a 30 second total request timeout, 10 seconds per attempt) against your own SLAs instead of inheriting them.
  • Keep the project small. When teams add authentication, Swagger or business code to it, every worker and job inherits them.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Give an orders API, an inventory API and a background worker identical tracing, metrics and health endpoints by adding one AddServiceDefaults line to each.
  • Expose /alive to the Kubernetes liveness probe and /health to the readiness probe on an internal port, so a database outage removes the pod from the Service without restarting it.
  • Stop the default retry policy from replaying POST /charges to the payment gateway while keeping retries on every GET the service makes.

More examples

The full Extensions.cs, ready for production

This is the template's shape with the production changes applied. Health endpoints exist in every environment. A deployment sets <code>ASPNETCORE_HTTP_PORTS=8080;8081</code> and <code>HealthChecks__Port=8081</code>, the kubelet probes 8081 and only 8080 sits behind the Service; the check uses the socket's local port because <code>RequireHost</code> trusts the Host header, which a client can spoof. With no port configured, as under the AppHost, nothing is gated, so <code>WithHttpHealthCheck("/health")</code> keeps working in Development. The OTLP exporter registration is unchanged, so the deployment must set <code>OTEL_EXPORTER_OTLP_ENDPOINT</code>. The namespace <code>Microsoft.Extensions.Hosting</code> is deliberate: services pick up the extension methods without an extra using. Probe traffic is filtered out of traces so it does not drown real requests.

Example · csharp
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;

namespace Microsoft.Extensions.Hosting;

public static class Extensions
{
    private const string HealthEndpointPath = "/health";
    private const string AlivenessEndpointPath = "/alive";

    public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
    {
        builder.ConfigureOpenTelemetry();
        builder.AddDefaultHealthChecks();
        builder.Services.AddServiceDiscovery();
        builder.Services.ConfigureHttpClientDefaults(http =>
        {
            http.AddStandardResilienceHandler();
            http.AddServiceDiscovery();
        });
        return builder;
    }

    public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
    {
        builder.Logging.AddOpenTelemetry(logging =>
        {
            logging.IncludeFormattedMessage = true;
            logging.IncludeScopes = true;
        });

        builder.Services.AddOpenTelemetry()
            .WithMetrics(metrics => metrics
                .AddAspNetCoreInstrumentation()
                .AddHttpClientInstrumentation())
            .WithTracing(tracing => tracing
                .AddSource(builder.Environment.ApplicationName)
                .AddAspNetCoreInstrumentation(o => o.Filter = ctx =>
                    !ctx.Request.Path.StartsWithSegments(HealthEndpointPath)
                    && !ctx.Request.Path.StartsWithSegments(AlivenessEndpointPath))
                .AddHttpClientInstrumentation());

        if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]))
        {
            builder.Services.AddOpenTelemetry().UseOtlpExporter();
        }
        return builder;
    }

    public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
    {
        builder.Services.AddHealthChecks()
            .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
        return builder;
    }

    public static WebApplication MapDefaultEndpoints(this WebApplication app)
    {
        // The template wraps the mapping in if (app.Environment.IsDevelopment()).
        // Probes need it everywhere. HealthChecks:Port is set only in deployments
        // (with ASPNETCORE_HTTP_PORTS=8080;8081); unset, as under the AppHost, nothing is gated.
        var healthPort = app.Configuration.GetValue<int?>("HealthChecks:Port");
        if (healthPort is int port)
        {
            // The real socket port, not the Host header, which the client controls.
            app.Use(async (ctx, next) =>
            {
                var isProbe = ctx.Request.Path.StartsWithSegments(HealthEndpointPath)
                    || ctx.Request.Path.StartsWithSegments(AlivenessEndpointPath);
                if (isProbe != (ctx.Connection.LocalPort == port))
                {
                    ctx.Response.StatusCode = StatusCodes.Status404NotFound;
                    return;
                }
                await next(ctx);
            });
        }

        app.MapHealthChecks(HealthEndpointPath);
        app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
        {
            Predicate = r => r.Tags.Contains("live")
        });
        return app;
    }
}

Opting a payment client out of retries

The default handler retries on timeouts and 5xx responses for every method. For a charge that is the classic double-payment bug: the gateway processed the first attempt, the response was slow, and the retry charged again. <code>RemoveAllResilienceHandlers</code> drops what <code>ConfigureHttpClientDefaults</code> added for this one client, and the replacement keeps the timeouts and circuit breaker but retries only safe methods. The idempotency key is the second line of defence; it identifies one payment attempt, not the order, and is reused only when that same attempt is retried.

Example · csharp
using Microsoft.Extensions.Http.Resilience;

// RemoveAllResilienceHandlers is marked experimental; acknowledge it explicitly.
#pragma warning disable EXTEXP0001

var builder = WebApplication.CreateBuilder(args);

// What AddServiceDefaults does for every client.
builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler());

builder.Services.AddHttpClient<PaymentGatewayClient>(c => c.BaseAddress = new Uri("https://payments.example.com"))
    .RemoveAllResilienceHandlers()
    .AddStandardResilienceHandler(options =>
    {
        // Never replay a POST /charges: the first attempt may have succeeded.
        options.Retry.DisableForUnsafeHttpMethods();
        options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(8);
        options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(20);
    });

var app = builder.Build();
app.MapPost("/orders/{id:int}/pay", async (int id, PaymentGatewayClient payments, CancellationToken ct) =>
{
    // One key per payment attempt, stored with a PaymentAttempt row before the call in real code,
    // so a declined card followed by a new card is a new charge, not a replayed decline.
    var attemptId = Guid.CreateVersion7();
    return TypedResults.Ok(await payments.ChargeAsync(id, attemptId, 129.90m, ct));
});
app.Run();

public sealed record ChargeResult(string ChargeId, string Status);

public sealed class PaymentGatewayClient(HttpClient http)
{
    public async Task<ChargeResult?> ChargeAsync(int orderId, Guid attemptId, decimal amount, CancellationToken ct)
    {
        using var request = new HttpRequestMessage(HttpMethod.Post, "/charges")
        {
            Content = JsonContent.Create(new { orderId, amount, currency = "EUR" })
        };
        request.Headers.Add("Idempotency-Key", attemptId.ToString());
        using var response = await http.SendAsync(request, ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<ChargeResult>(ct);
    }
}

The ServiceDefaults project file

None of these packages is an Aspire package: it is OpenTelemetry, Microsoft.Extensions resilience and service discovery, all of which work in any hosting environment. That is why ServiceDefaults is safe to ship to production. The <code>FrameworkReference</code> gives the library <code>WebApplication</code> and health check mapping without making it a web project itself.

Example · xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsAspireSharedProject>true</IsAspireSharedProject>
  </PropertyGroup>

  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.11.0" />
    <PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.11.0" />
    <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.20.1" />
    <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.20.1" />
    <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.20.1" />
    <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.20.1" />
  </ItemGroup>

</Project>

Discussion

  • Be the first to comment on this lesson.