Aspire Integrations: Hosting and Client Integrations, SQL Server, Redis, RabbitMQ, Keycloak and Your Own Resource
A hosting integration models the resource in the AppHost; a client integration registers the client library in the service with a health check, telemetry and retries, and those defaults don't suit every dependency. The post fixes the settings that made the catalog wait 12 seconds and go unhealthy without Redis, traces an order event and its consumer span with RabbitMQ, protects a back office endpoint with Keycloak and adds a dashboard command.
Version note: The code in this post was built and run with .NET 10, Aspire 13.6 (Keycloak integration 13.6.5-preview), RabbitMQ.Client 7.2 and StackExchange.Redis 2.13; the containers are SQL Server 2025, Redis 8.6, RabbitMQ 4.3 and Keycloak 26.6.
Guess First
In post 02, when we stopped Redis from the dashboard, Catalog's /health returned 503 and the book list returned 200 after waiting 11.5 seconds. In this service Redis is only an output cache that stores list responses. A missing cache can slow a request down, fair enough. But why more than ten seconds? And why does a cache make the whole service "unhealthy"? In production a service whose readiness check fails is taken out of traffic, so a few minutes of Redis downtime could shut the catalog down completely. We didn't write this behavior. These are the defaults of the client integration that comes with the AddRedisOutputCache line. In this post we open up what integrations add and how to change it when needed.
Hosting Integrations and Client Integrations
In Aspire a piece of infrastructure has two separate packages. A hosting integration is added to the AppHost and models the resource: which image, which port, which password parameter, which health check. A client integration is added to the service that uses that resource and registers the client library in DI: it reads the connection string from configuration, turns on telemetry, adds a health check and sets up retries where needed. The two are independent. You can use a client integration without a hosting integration (you provide the connection string yourself), and a hosting integration without a client integration (the service configures its library itself). Bookstore, the online bookstore used as the shared example across this site's series, now has four pairs:
| Resource | Hosting (AppHost) | Client (service) | Used by |
|---|---|---|---|
| SQL Server | Aspire.Hosting.SqlServer | Aspire.Microsoft.EntityFrameworkCore.SqlServer | Catalog, migrations |
| Redis | Aspire.Hosting.Redis | Aspire.StackExchange.Redis.OutputCaching | Catalog |
| RabbitMQ | Aspire.Hosting.RabbitMQ | Aspire.RabbitMQ.Client | Ordering, Inventory |
| Keycloak | Aspire.Hosting.Keycloak | Aspire.Keycloak.Authentication | Catalog |
RabbitMQ and Keycloak arrive in this post. Package versions follow Aspire's version: all are 13.6.5, except the Keycloak packages, which are 13.6.5-preview.1.26559.7 because the Keycloak integration is still in preview. Keep the integration packages on the same version as the AppHost SDK; aspire update updates the SDK and all Aspire.* packages together.
What Does a Client Integration Add?
The single line in Catalog, builder.AddSqlServerDbContext<BookstoreContext>("bookstoredb"), actually does four things: it reads ConnectionStrings:bookstoredb and registers BookstoreContext, wires Entity Framework Core and SqlClient spans into OpenTelemetry, adds a DbContext health check to /health, and turns on an execution strategy that retries queries on transient SQL errors. We saw the last item with a small console app:
Console.WriteLine(db.Database.CreateExecutionStrategy().GetType().Name);
SqlServerRetryingExecutionStrategy
The others are similar. AddRedisOutputCache adds an IConnectionMultiplexer, a Redis health check and StackExchange.Redis spans. AddRabbitMQClient registers a single IConnection, retries the connection a few times at startup, and adds a health check named RabbitMQ.Client plus RabbitMQ's publish/deliver spans. Every client integration has a settings class; these behaviors can be turned off in code or through the Aspire: section of configuration: DisableRetry, DisableHealthChecks and DisableTracing under Aspire:Microsoft:EntityFrameworkCore:SqlServer, and DisableHealthChecks and DisableTracing for Redis and RabbitMQ too.
When shouldn't you use a client integration? If you already manage the DbContext's settings yourself (your own interceptors, your own retry policy, choosing between several connection strings), register it yourself with AddDbContext instead of AddSqlServerDbContext and then call builder.EnrichSqlServerDbContext<BookstoreContext>(): you get the health check and telemetry, and your DbContext configuration is left alone. If a library has no Aspire client integration, that's fine too; the connection string still arrives as ConnectionStrings__x, and the connection properties we saw in 02 (X_HOST, X_PORT, X_URI) are there for non-.NET services.
Redis: the Cache Isn't a Critical Dependency
We repeated the Guess First experiment with the code from 04 and stopped Redis with aspire resource redis stop:
/health 503 5.580711s
/api/books?page=5 200 11.987081s
/api/books?page=5 200 11.993322s
/api/books/lookup?isbn=9780547928227 200 0.004292s
The uncached /lookup isn't affected; the cached list takes 12 seconds on every request, and the second request is no faster. Catalog's log tells us why:
fail: Microsoft.AspNetCore.OutputCaching.OutputCacheMiddleware[13]
StackExchange.Redis.RedisConnectionException: The message timed out in the backlog attempting to send
because no connection became available (5000ms) - ... command=SETEX, timeout: 5000 ...
When the connection drops, StackExchange.Redis doesn't reject commands right away; it holds them in a backlog waiting for the connection to return, 5 seconds by default. The output cache middleware first looks in the cache on every request, then writes the response: two commands, 5 seconds twice. Because the middleware catches the error and still returns the response, the result is 200, but after 12 seconds. The 5.6 seconds on /health is a PING waiting in the same backlog.
For Catalog, Redis is only an accelerator; without Redis it must still answer from the database. We said so in three places:
// The cache is optional: without Redis the catalog must still answer, and quickly
builder.AddRedisOutputCache("redis",
settings => settings.DisableHealthChecks = true,
options => options.BacklogPolicy = BacklogPolicy.FailFast);
DisableHealthChecks takes Redis out of Catalog's health, and BacklogPolicy.FailFast makes a command fail immediately when there's no connection instead of waiting. The third is in the AppHost: Catalog no longer says WaitFor(redis), only WithReference(redis). Use WaitFor for dependencies you really can't start without. The same experiment with the new code:
/health 200 0.004646s
/api/books?page=7 200 0.060637s
/api/books?page=7 200 0.027793s
Redis's own state is already visible in the dashboard, in the health of the redis resource; Catalog's health should only say whether Catalog works.
RabbitMQ: the Order Event and the Consumer Span
Let's make one step of ordering asynchronous: Ordering stores the order as Pending and publishes an OrderPlaced message; the new Bookstore.Inventory worker reserves stock and replies with StockReserved; when Ordering receives the reply it marks the order Confirmed. Message and queue names are a contract between two services, so they live in a small Bookstore.Contracts project. How to split services and design event flows is the subject of the .NET ile Mikroservis Uygulaması series; here we look at the Aspire side. What was added to the AppHost:
IResourceBuilder<RabbitMQServerResource> messaging = builder.AddRabbitMQ("messaging")
.WithContainerName("aspire-rabbitmq")
.WithManagementPlugin();
IResourceBuilder<ProjectResource> ordering = builder.AddProject<Projects.Bookstore_Ordering_Api>("ordering")
.WithReference(catalog)
.WithReference(messaging)
.WaitFor(catalog)
.WaitFor(messaging)
.WithHttpHealthCheck("/health");
builder.AddProject<Projects.Bookstore_Inventory>("inventory")
.WithReference(messaging)
.WaitFor(messaging);
WithManagementPlugin() switches the image to rabbitmq:4.3-management and adds a second endpoint (the management UI) to the resource. The variables sent to Ordering (aspire describe ordering --format Json):
"ConnectionStrings__messaging": "amqp://guest:<password>@localhost:60012",
"MESSAGING_HOST": "localhost",
"MESSAGING_PASSWORD": null,
"MESSAGING_PORT": "60012",
"MESSAGING_URI": "amqp://guest:<password>@localhost:60012",
"MESSAGING_USERNAME": "guest"
The AMQP address arrives both as a connection string and as the connection properties introduced in 13.0. One detail stands out: in 13.6 aspire describe hides MESSAGING_PASSWORD, but the password is still visible in the URI and the connection string; keep that in mind when you share describe output. The service side is a single line: builder.AddRabbitMQClient("messaging");. Inventory's consumer:
public sealed class OrderPlacedConsumer(
IConnection connection,
StockLedger ledger,
TimeProvider clock,
ILogger<OrderPlacedConsumer> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await using IChannel channel = await connection.CreateChannelAsync(
new CreateChannelOptions(publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true),
stoppingToken);
await channel.QueueDeclareAsync(Queues.OrderPlaced, durable: true, exclusive: false, autoDelete: false, cancellationToken: stoppingToken);
await channel.QueueDeclareAsync(Queues.StockReserved, durable: true, exclusive: false, autoDelete: false, cancellationToken: stoppingToken);
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 10, global: false, stoppingToken);
AsyncEventingBasicConsumer consumer = new(channel);
consumer.ReceivedAsync += async (_, delivery) =>
{
OrderPlaced? message = JsonSerializer.Deserialize<OrderPlaced>(delivery.Body.Span, JsonSerializerOptions.Web);
if (message is null)
{
// A message we cannot read goes to the dead-letter path, not back to the queue
await channel.BasicNackAsync(delivery.DeliveryTag, multiple: false, requeue: false);
return;
}
ledger.Reserve(message.Items);
logger.LogInformation("Reserved stock for order {OrderId} ({ItemCount} items)", message.OrderId, message.Items.Count);
StockReserved reply = new(message.OrderId, clock.GetUtcNow());
BasicProperties properties = new() { ContentType = "application/json", DeliveryMode = DeliveryModes.Persistent };
await channel.BasicPublishAsync(exchange: "", routingKey: Queues.StockReserved, mandatory: true,
basicProperties: properties, body: JsonSerializer.SerializeToUtf8Bytes(reply, JsonSerializerOptions.Web));
await channel.BasicAckAsync(delivery.DeliveryTag, multiple: false);
};
await channel.BasicConsumeAsync(Queues.OrderPlaced, autoAck: false, consumer, stoppingToken);
// Keep the channel open until the host stops
await Task.Delay(Timeout.Infinite, stoppingToken);
}
}
OrderEventPublisher in Ordering uses a single channel for the lifetime of the service and guards it with a SemaphoreSlim, because RabbitMQ channels must not be used from two threads at once. Publisher confirms are on: the await on BasicPublishAsync returns once the broker has stored the message. Ordering's StockReserved consumer is a short copy of the one above. The RabbitMQ.Client 7 API is async from end to end; the IModel and synchronous BasicPublish of 6.x are gone.
Now the waterfall we promised at the end of 04. We placed a two-line order through the gateway and drew the aspire otel traces output the same way:
0.0 ms 20 ms gateway: POST /api/orders/{**rest}
0.2 ms 20 ms gateway: POST http://localhost:5082/api/orders
0.6 ms 19 ms ordering: POST /api/orders/
1.1 ms 17 ms ordering: Fetch catalog prices
1.8 ms 16 ms ordering: GET http://localhost:5080/api/books/lookup?*
2.1 ms 15 ms catalog: GET /api/books/lookup
4.4 ms 12 ms catalog: SELECT [Books]
17.7 ms 0 ms ordering: publish ordering.order-placed
19.1 ms 2 ms inventory: deliver ordering.order-placed
19.5 ms 0 ms inventory: publish inventory.stock-reserved
20.5 ms 0 ms ordering: deliver inventory.stock-reserved
The HTTP request and the processing of both messages are in the same trace. Ordering's publish span writes the trace context into the message headers; Inventory's deliver span reads it from there and places itself under it. We didn't write this: RabbitMQ.Client 7 produces these spans with its own ActivitySources, and the client integration adds those sources to OpenTelemetry. The span attributes follow OpenTelemetry's messaging semantic conventions: messaging.system=rabbitmq, messaging.operation.type=process, messaging.rabbitmq.destination.routing_key=ordering.order-placed, and the order id as messaging.message.id. The logs carry the same trace id:
19:27:46.355 INFO inventory Reserved stock for order 01a1dae2-e931-7603-913a-72822d1921c2 (2 items)
19:27:46.356 INFO ordering Order 01a1dae2-e931-7603-913a-72822d1921c2 confirmed
What happens when the broker stops? After aspire resource messaging stop, Ordering's /health returned 503 (the log said Health check RabbitMQ.Client with status Unhealthy), and a new order ended with a 500: AlreadyClosedException: ... CONNECTION_FORCED - broker forced connection closure with reason 'shutdown'. This time we didn't turn the health check off, because an Ordering that can't publish messages can't take orders; here RabbitMQ really is a critical dependency. The 500 has another side effect: the order had been written to the in-memory store, but the message never went out. The order stays Pending forever. This is the classic "dual write" problem of writing to a database and publishing a message; its solution, the outbox pattern, is in the Message Queue'larla Çalışmak series. After we restarted RabbitMQ, the connection and both consumers recovered on their own; a new order became Confirmed within a second. That's RabbitMQ.Client's default automatic recovery behavior.
Keycloak: a Token for the Back Office Endpoint
Adding books to the catalog is back office work; only the StoreStaff role should be able to call POST /api/books. Locally, Keycloak issues the tokens:
IResourceBuilder<ParameterResource> backofficeSecret = builder.AddParameter("backoffice-client-secret", secret: true);
// Realm import is for local development only; it is not part of publish/deploy
IResourceBuilder<KeycloakResource> keycloak = builder.AddKeycloak("keycloak", 8080)
.WithContainerName("aspire-keycloak")
.WithRealmImport("./Realms")
.WithEnvironment("BOOKSTORE_BACKOFFICE_SECRET", backofficeSecret);
We gave a fixed port (8080): the token's iss value comes from Keycloak's address and shouldn't change on every run. If no admin password is given, Aspire generates one and writes it to the AppHost's user secrets as Parameters:keycloak-password. WithRealmImport copies the JSON in the Realms folder into the container's import folder, and Keycloak creates the realm at startup. We didn't write the client secret into the realm file; during import Keycloak fills ${...} placeholders from environment variables:
{
"realm": "bookstore",
"roles": { "realm": [ { "name": "StoreStaff" } ] },
"clients": [
{
"clientId": "bookstore-backoffice",
"publicClient": false,
"secret": "${BOOKSTORE_BACKOFFICE_SECRET}",
"serviceAccountsEnabled": true,
"standardFlowEnabled": false,
"protocolMappers": [
{ "name": "catalog-audience", "protocolMapper": "oidc-audience-mapper", ... },
{ "name": "realm-roles", "protocolMapper": "oidc-usermodel-realm-role-mapper", ... }
]
}
],
"users": [
{ "username": "service-account-bookstore-backoffice", "serviceAccountClientId": "bookstore-backoffice", "realmRoles": [ "StoreStaff" ] }
]
}
That way the secret never enters the repo; the value comes from the parameter, which means from user secrets. The Catalog side:
builder.Services.AddAuthentication()
.AddKeycloakJwtBearer(serviceName: "keycloak", realm: "bookstore", options =>
{
options.Audience = "bookstore-catalog";
// Keep claim names as Keycloak writes them; otherwise "roles" is renamed and RequireRole finds nothing
options.MapInboundClaims = false;
options.TokenValidationParameters.RoleClaimType = "roles";
// Locally Keycloak is reached over http; production sets an https Authority
if (builder.Environment.IsDevelopment())
{
options.RequireHttpsMetadata = false;
}
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("BackOffice", policy => policy.RequireRole("StoreStaff"));
AddKeycloakJwtBearer builds the Authority through service discovery: Catalog receives KEYCLOAK_HTTP=http://localhost:8080 and services__keycloak__http__0, and the metadata is read from there. Catalog doesn't say WaitFor(keycloak): the metadata is read on the first protected request, and the book list must work without Keycloak. On our machine the Keycloak container became healthy in 20–28 seconds. The attempts:
# without a token
POST /api/books 401
# client_credentials token for bookstore-backoffice; part of the payload
POST .../openid-connect/token 200
"iss": "http://localhost:8080/realms/bookstore",
"aud": "bookstore-catalog",
"azp": "bookstore-backoffice",
"roles": [ "StoreStaff" ]
# with the token
POST /api/books 403
The 403 is because the first attempt didn't have the MapInboundClaims = false line. By default the JWT handler maps some claim names to .NET's old long names; the roles claim becomes http://schemas.microsoft.com/ws/2008/06/identity/claims/role. Since we said RoleClaimType = "roles", the role isn't found. After adding the line, the second surprise came: the book was written to the database, but the response was a 500. The cause is related to the decision in the Redis section: we hadn't started Redis in this run, and the EvictByTagAsync call that clears the list's cache when a new book is added threw an AggregateException wrapping the expected RedisException instead of the exception itself. We fixed the catch filter:
// A failed eviction must not fail the request; the list expires in 30 s anyway
try
{
await cache.EvictByTagAsync(BookListTag, ct);
}
catch (Exception ex) when (ex is RedisException or AggregateException { InnerException: RedisException })
{
loggerFactory.CreateLogger("Bookstore.Api.Books").LogWarning(ex, "Could not evict the book list cache");
}
# with the token
POST /api/books 201 {"isbn":"9780140268867","title":"The Odyssey","author":"Homer","price":280.00,"currency":"TRY"}
# the same request again
POST /api/books 409
# token request with a wrong client secret
POST .../openid-connect/token 401
The documentation says plainly that WithRealmImport is for local development only; aspire publish and aspire deploy don't support it. In production the realm is set up by Keycloak's Admin API, Terraform or your own image, and services connect to an existing Keycloak. Authentication and authorization themselves (OAuth 2.0 flows, token lifetime, refresh) aren't this series' subject.
Finding Integrations and Version Compatibility
Which technologies have an integration? You can search from the CLI:
aspire integration search keycloak
┌──────────────────────────────────────┬─────────────────────────────────────────────────────┬──────────────────────────┐
│ Name │ Package │ Version │
├──────────────────────────────────────┼─────────────────────────────────────────────────────┼──────────────────────────┤
│ keycloak │ Aspire.Hosting.Keycloak │ 13.6.5-preview.1.26559.7 │
│ communitytoolkit-keycloak-extensions │ CommunityToolkit.Aspire.Hosting.Keycloak.Extensions │ 17.0.0-beta.1051 │
└──────────────────────────────────────┴─────────────────────────────────────────────────────┴──────────────────────────┘
On that day aspire integration list listed 164 hosting integrations, 67 of them from the Community Toolkit. The Aspire team's packages are named Aspire.Hosting.* and ship with the same version number as Aspire. Community Toolkit packages have their own version line and are maintained by the community; before picking one, look at its last update and which Aspire version it targets. aspire add rabbitmq adds the package to the AppHost project; since we use Central Package Management, we wrote the versions in Directory.Packages.props. The preview label matters: Keycloak's API may change in the next minor release, so take that into account for production code.
Your Own Resource and a Dashboard Command
In 02 we added Mailpit with AddContainer; it worked, but no other resource could connect to it with WithReference, because a plain container has no connection string. Making a resource typed takes a few lines:
// A typed resource instead of AddContainer: other resources can WithReference it and get an SMTP connection string
public sealed class MailpitResource(string name) : ContainerResource(name), IResourceWithConnectionString
{
internal const string SmtpEndpointName = "smtp";
private EndpointReference? smtpEndpoint;
public EndpointReference SmtpEndpoint => smtpEndpoint ??= new(this, SmtpEndpointName);
public ReferenceExpression ConnectionStringExpression =>
ReferenceExpression.Create($"smtp://{SmtpEndpoint.Property(EndpointProperty.HostAndPort)}");
}
public static IResourceBuilder<MailpitResource> AddMailpit(this IDistributedApplicationBuilder builder, string name) =>
builder.AddResource(new MailpitResource(name))
.WithImage("axllent/mailpit", "v1.33")
.WithImageRegistry("docker.io")
.WithHttpEndpoint(targetPort: 8025, name: "http")
.WithEndpoint(targetPort: 1025, name: MailpitResource.SmtpEndpointName, scheme: "smtp");
In the AppHost we now write builder.AddMailpit("mailpit"); a service that sends e-mail gets, through WithReference(mailpit), a ConnectionStrings__mailpit variable whose value has the shape in ConnectionStringExpression (smtp://host:port). The same structure lets you model any tool your team uses. The second extension point is resource commands. We added a "Clear cache" command to Redis that shows up in the dashboard and the CLI:
// Dashboard and CLI command: empties Redis, enabled only while the resource is healthy
public static IResourceBuilder<RedisResource> WithClearCacheCommand(this IResourceBuilder<RedisResource> redis) =>
redis.WithCommand(
name: "clear-cache",
displayName: "Clear cache",
executeCommand: async context =>
{
string? connectionString = await redis.Resource.GetConnectionStringAsync(context.CancellationToken);
try
{
await using ConnectionMultiplexer connection = await ConnectionMultiplexer.ConnectAsync($"{connectionString},allowAdmin=true");
foreach (IServer server in connection.GetServers())
{
await server.FlushAllDatabasesAsync();
}
}
catch (RedisConnectionException)
{
return CommandResults.Failure("Redis is not reachable.");
}
return CommandResults.Success();
},
commandOptions: new CommandOptions
{
IconName = "Delete",
UpdateState = context => context.ResourceSnapshot.HealthStatus == HealthStatus.Healthy
? ResourceCommandState.Enabled
: ResourceCommandState.Disabled
});
/api/books?page=6 200 0.322477s
# the second request comes from the cache
/api/books?page=6 200 0.003947s
aspire resource redis clear-cache
✅ Command 'clear-cache' executed successfully on resource 'redis'.
# the cache is empty, the response comes from the database again
/api/books?page=6 200 0.052924s
The command runs in the AppHost process; it can reach the resource's connection string and endpoints. It's called from the resource row's menu in the dashboard and with aspire resource <name> <command> in the CLI. It's a good place for chores everyone on the team does by hand: reloading seed data, creating a test user, purging a queue.
In Production
The client integrations' defaults were chosen for local development and a typical production setup, but they don't know your dependency map. Ask two questions for every dependency: can the service do its job without it (should it be in the health check), and how long should it wait when it's missing (timeout, backlog, retry)? In Bookstore the answer was "yes, without waiting" for Redis and "no" for RabbitMQ.
Separate health checks into liveness and readiness. /alive in ServiceDefaults runs only checks tagged live; the checks added by client integrations go to /health. Putting a database check in liveness on Kubernetes means every pod gets restarted during a database outage.
Keep client secrets, admin passwords and connection strings as parameters. The placeholder approach with WithRealmImport is nice locally; in production the source of secrets is the secret store described in the Configuration ve Secret Management series.
Pin preview integrations to an exact version and update them deliberately. With central package management the version lives in one place, which makes this easy.
Common Pitfalls
Letting the client integration's retry clash with your own code. Because the EF Core client integration turns on a retrying execution strategy, a transaction you start yourself fails. In the same small console app, a query after BeginTransactionAsync:
InvalidOperationException: The configured execution strategy 'SqlServerRetryingExecutionStrategy' does not support
user-initiated transactions. Use the execution strategy returned by 'DbContext.Database.CreateExecutionStrategy()'
to execute all the operations in the transaction as a retriable unit.
Either wrap the work in CreateExecutionStrategy().ExecuteAsync(...), or turn off the client integration's retry with DisableRetry and apply your own policy. Leaving both on (EF's retry plus another retry with Polly on top) multiplies the number of requests during a failure.
Tying an optional dependency to WaitFor and a health check. Parts like a cache, a metrics collector or a mail server shouldn't take the service down with them.
Blaming authorization when a role isn't found in a JWT. If you see 403, look first at the claim names in the token, then at MapInboundClaims and RoleClaimType.
Assuming the exception type. The output cache's Redis store threw an AggregateException on eviction; write catch filters after seeing the real error.
Taking realm import for the production path. Locally it sets up the realm on every start; it has no counterpart in the publish output.
A Small Experiment
★☆☆ Remove the BacklogPolicy.FailFast line from Catalog, stop Redis and measure the list request. Then also remove DisableHealthChecks and look at /health. Do the numbers match the ones in this post?
★★☆ Stop Inventory from the dashboard, place three orders and look at the message count of the ordering.order-placed queue in the RabbitMQ management UI. When you start Inventory, how long until the orders become Confirmed?
★★★ Write a small Notifications worker that uses Mailpit: it listens to OrderPlaced and sends a confirmation e-mail to the SMTP address it gets through WithReference(mailpit). Since Mailpit is explicit start, what does the worker do before Mailpit is started?
With integrations, health checks and messaging, Bookstore's parts are now really connected. In the next post we test those connections: we start the AppHost in a test with Aspire.Hosting.Testing and verify the "add a book, see it in the list" and "place an order, it becomes Confirmed" scenarios end to end; we'll also see why we shouldn't use Task.Delay while waiting for RabbitMQ's asynchronous reply.