Client Integrations

Register an EF Core DbContext and a Redis client with Aspire client integrations, configure them, and know what they add beyond plain registration.

Syntaxbuilder.AddNpgsqlDbContext<OrdersDb>("ordersdb", s => s.CommandTimeout = 15); builder.AddRedisClient("cache");

Aspire integrations come in pairs. A hosting integration (Aspire.Hosting.PostgreSQL) is referenced by the AppHost and models the container. A client integration (Aspire.Npgsql.EntityFrameworkCore.PostgreSQL, Aspire.StackExchange.Redis) is referenced by the service and registers the client the way a careful team would by hand. The pair meets only at a name: the AppHost's AddDatabase("ordersdb") and the service's AddNpgsqlDbContext<OrdersDb>("ordersdb").

What AddNpgsqlDbContext registers

ConcernDefaultSetting
Connection stringRead from ConnectionStrings:ordersdb; start-up fails if it is missingConnectionString
LifetimeRegistered with DbContext pooling (AddDbContextPool)Use EnrichNpgsqlDbContext to register it yourself
Transient failuresEF Core retrying execution strategy enabledDisableRetry
Command timeoutNpgsql default (30 seconds)CommandTimeout
Health checkA DbContext check added to /health, not to /aliveDisableHealthChecks
Tracing and metricsNpgsql activity source and meter added to OpenTelemetryDisableTracing, DisableMetrics

Settings come from the configuration section Aspire:Npgsql:EntityFrameworkCore:PostgreSQL (optionally with a per-context sub-section named after the DbContext type), and the configureSettings delegate runs after configuration binding, so code wins. AddRedisClient("cache") does the same for StackExchange.Redis: it registers one shared IConnectionMultiplexer singleton, a health check and tracing, from Aspire:StackExchange:Redis.

The decision

Use the client integration when its defaults match what you want, which for most APIs they do: pooling, retries, health and telemetry in one line. Switch to EnrichNpgsqlDbContext when you must own the registration, for example to configure an NpgsqlDataSource with enum mappings or to share a data source between EF Core and Dapper; Enrich adds health, telemetry and retry to the registration you made. Do not call both AddDbContext and AddNpgsqlDbContext for the same type: two registrations, one silently wins.

Production failure modes

  • Retries and transactions. With the retrying strategy on, after BeginTransactionAsync the first SaveChangesAsync (or query) inside it throws InvalidOperationException: the strategy cannot replay a transaction it did not start. Wrap the unit of work in db.Database.CreateExecutionStrategy().ExecuteAsync(...) and start it with db.ChangeTracker.Clear(), so a replay never sees entities the failed attempt modified.
  • Pooling and state. Pooled contexts are reset and reused, so the context can only have a constructor that takes DbContextOptions. Injecting a scoped tenant service fails at start-up, and per-request state set on a property is not reset for you. Use a pooled factory that sets it on every lease, or turn pooling off with AddDbContext plus EnrichNpgsqlDbContext.
  • Name drift. Rename the AppHost resource and every service fails at start-up with a missing connection string. That is the good outcome; it fails before taking traffic.
  • Production values. The integration reads ordinary configuration, so production supplies ConnectionStrings__ordersdb exactly as the AppHost did locally.

Example

Example · csharp
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();

// Names match the AppHost: AddDatabase("ordersdb") and AddRedis("cache").
builder.AddNpgsqlDbContext<OrdersDb>("ordersdb", settings =>
{
    settings.CommandTimeout = 15;
});
builder.AddRedisClient("cache");

var app = builder.Build();
app.MapDefaultEndpoints();

app.MapGet("/orders/{id:int}", async (int id, OrdersDb db, IConnectionMultiplexer redis, CancellationToken ct) =>
{
    var cache = redis.GetDatabase();
    var key = $"order-summary:{id}";
    var cached = await cache.StringGetAsync(key);
    if (cached.HasValue)
        return Results.Text(cached.ToString(), "application/json");

    var order = await db.Orders.AsNoTracking()
        .Where(o => o.Id == id)
        .Select(o => new OrderSummary(o.Id, o.CustomerId, o.Total, o.Status))
        .FirstOrDefaultAsync(ct);
    if (order is null)
        return Results.NotFound();

    var json = System.Text.Json.JsonSerializer.Serialize(order);
    await cache.StringSetAsync(key, json, TimeSpan.FromMinutes(5));
    return Results.Text(json, "application/json");
});

app.MapPost("/orders/{id:int}/cancel", async (int id, OrdersDb db, IConnectionMultiplexer redis, CancellationToken ct) =>
{
    // The retrying strategy replays this whole block, transaction included.
    var strategy = db.Database.CreateExecutionStrategy();
    var cancelled = await strategy.ExecuteAsync(async () =>
    {
        // A replay must re-read the database, not reuse entities the failed attempt modified.
        db.ChangeTracker.Clear();
        await using var tx = await db.Database.BeginTransactionAsync(ct);
        var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
        if (order is null)
            return false;
        // The commit may have landed before the connection dropped; a replay then finds
        // the order already cancelled and reports success instead of a false 409.
        if (order.Status == "Cancelled")
            return true;
        if (order.Status != "Placed")
            return false;
        order.Status = "Cancelled";
        db.StockReleases.Add(new StockRelease { OrderId = id, ReleasedAt = DateTimeOffset.UtcNow });
        await db.SaveChangesAsync(ct);
        await tx.CommitAsync(ct);
        return true;
    });
    if (!cancelled)
        return Results.Conflict();

    await redis.GetDatabase().KeyDeleteAsync($"order-summary:{id}");
    return Results.NoContent();
});

app.Run();

public sealed record OrderSummary(int Id, string CustomerId, decimal Total, string Status);

public sealed class Order
{
    public int Id { get; set; }
    public required string CustomerId { get; set; }
    public decimal Total { get; set; }
    public string Status { get; set; } = "Placed";
}

public sealed class StockRelease
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public DateTimeOffset ReleasedAt { get; set; }
}

public sealed class OrdersDb(DbContextOptions<OrdersDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<StockRelease> StockReleases => Set<StockRelease>();
}

// Stand-ins for the ServiceDefaults project this API references.
public static class ServiceDefaultsStandIn
{
    public static WebApplicationBuilder AddServiceDefaults(this WebApplicationBuilder builder) => builder;
    public static WebApplication MapDefaultEndpoints(this WebApplication app) => app;
}

When to use it

  • Register the orders DbContext with pooling, retries, a readiness health check and Npgsql tracing in one AddNpgsqlDbContext line wired to the AppHost's ordersdb.
  • Share one NpgsqlDataSource with a mapped payment_status enum between EF Core and Dapper, using EnrichNpgsqlDbContext to keep Aspire's health check and telemetry.
  • Cancel an order and release its stock in one transaction that the retrying execution strategy can replay safely after a transient Postgres failover.

More examples

Settings from configuration

Every switch can be set per environment without a code change, for example a longer <code>CommandTimeout</code> for a reporting deployment. The binding order matters: configuration is bound first and the <code>configureSettings</code> delegate runs after it, so a value set in code cannot be overridden from <code>appsettings.Production.json</code>. Put tunables in configuration and leave the delegate for things that must never change.

Example · json
{
  "ConnectionStrings": {
    // Injected by the AppHost locally and by a Secret in production; never committed.
    "ordersdb": ""
  },
  "Aspire": {
    "Npgsql": {
      "EntityFrameworkCore": {
        "PostgreSQL": {
          "DisableRetry": false,
          "CommandTimeout": 15,
          "DisableHealthChecks": false,
          "DisableTracing": false
        }
      }
    },
    "StackExchange": {
      "Redis": {
        "DisableTracing": false,
        "DisableHealthChecks": false
      }
    }
  }
}

Owning the registration with EnrichNpgsqlDbContext

<code>AddNpgsqlDbContext</code> builds its own connection and does not let you configure the <code>NpgsqlDataSource</code>, so a Postgres enum or a shared data source needs this route. You keep full control of the registration and still get the integration's health check, telemetry and retry policy. The guard on the connection string restores the fail-fast behaviour that <code>AddNpgsqlDbContext</code> gave for free.

Example · csharp
using Microsoft.EntityFrameworkCore;
using Npgsql;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("ordersdb")
    ?? throw new InvalidOperationException("ConnectionStrings:ordersdb is not configured.");

// One data source for EF Core and for Dapper queries, with the enum mapped once.
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.MapEnum<PaymentStatus>("payment_status");
var dataSource = dataSourceBuilder.Build();
builder.Services.AddSingleton(dataSource);

builder.Services.AddDbContextPool<PaymentsDb>(options =>
    options.UseNpgsql(dataSource, npgsql => npgsql.MapEnum<PaymentStatus>("payment_status")));

// Adds health check, tracing, metrics and the retrying strategy to the registration above.
builder.EnrichNpgsqlDbContext<PaymentsDb>(settings => settings.CommandTimeout = 10);

var app = builder.Build();
app.MapGet("/payments/{id:guid}", async (Guid id, PaymentsDb db) =>
    await db.Payments.FindAsync(id) is { } p ? Results.Ok(p) : Results.NotFound());
app.Run();

public enum PaymentStatus { Pending, Captured, Refunded, Failed }

public sealed class Payment
{
    public Guid Id { get; set; }
    public int OrderId { get; set; }
    public decimal Amount { get; set; }
    public PaymentStatus Status { get; set; }
}

public sealed class PaymentsDb(DbContextOptions<PaymentsDb> options) : DbContext(options)
{
    public DbSet<Payment> Payments => Set<Payment>();
}

Two Redis instances with keyed clients

Separating a disposable cache from session data lets you flush or resize the cache without logging every customer out. Keyed registration gives each instance its own multiplexer, health check and connection string (<code>ConnectionStrings:cache</code> and <code>ConnectionStrings:sessions</code>). Asking for an unkeyed <code>IConnectionMultiplexer</code> when only keyed clients are registered is not caught by DI scope validation: a minimal API parameter fails when the endpoint is first built or called, and a constructor-injected one fails when it is resolved (<code>ValidateOnBuild</code> catches that case for registered services). Cover each endpoint with an integration test.

Example · csharp
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);

// AppHost: builder.AddRedis("cache") and builder.AddRedis("sessions")
builder.AddKeyedRedisClient("cache");
builder.AddKeyedRedisClient("sessions");

var app = builder.Build();

app.MapGet("/products/{sku}/price", async (string sku,
    [FromKeyedServices("cache")] IConnectionMultiplexer cache) =>
{
    var price = await cache.GetDatabase().StringGetAsync($"price:{sku}");
    return price.HasValue ? Results.Ok(new { sku, price = (decimal)price }) : Results.NotFound();
});

app.MapDelete("/sessions/{customerId}", async (string customerId,
    [FromKeyedServices("sessions")] IConnectionMultiplexer sessions) =>
{
    await sessions.GetDatabase().KeyDeleteAsync($"session:{customerId}");
    return Results.NoContent();
});

app.Run();

Discussion

  • Be the first to comment on this lesson.