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.
builder.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
| Concern | Default | Setting |
|---|---|---|
| Connection string | Read from ConnectionStrings:ordersdb; start-up fails if it is missing | ConnectionString |
| Lifetime | Registered with DbContext pooling (AddDbContextPool) | Use EnrichNpgsqlDbContext to register it yourself |
| Transient failures | EF Core retrying execution strategy enabled | DisableRetry |
| Command timeout | Npgsql default (30 seconds) | CommandTimeout |
| Health check | A DbContext check added to /health, not to /alive | DisableHealthChecks |
| Tracing and metrics | Npgsql activity source and meter added to OpenTelemetry | DisableTracing, 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
BeginTransactionAsyncthe firstSaveChangesAsync(or query) inside it throwsInvalidOperationException: the strategy cannot replay a transaction it did not start. Wrap the unit of work indb.Database.CreateExecutionStrategy().ExecuteAsync(...)and start it withdb.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 withAddDbContextplusEnrichNpgsqlDbContext. - 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__ordersdbexactly as the AppHost did locally.
Example
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.
{
"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.
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.
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