Want to add superpowers to your Aspire dashboard? Custom commands are here to save the day! In this module, we'll explore how to create interactive commands that let you manage your application resources directly from the Aspire dashboard - no more switching between terminals, browsers, and tools.
We'll cover:
- Understanding the custom commands architecture
- Creating resource-specific commands (like clearing a Redis cache)
- Building HTTP commands for API interactions
- Adding visual polish with icons and confirmations
- State management and conditional command availability
Think of custom commands as buttons with brains - they know when to be available, what to do when clicked, and how to provide feedback to developers.
Custom commands in Aspire are interactive actions you can perform on resources directly from the dashboard. They provide a unified way to:
- Manage Resources: Clear caches, restart services, trigger maintenance tasks
- Execute Operations: Call specific API endpoints, run database migrations, invalidate caches
- Provide Developer Tools: Debug helpers, data seeders, test utilities
- Integrate with External Systems: Trigger deployments, send notifications, update configurations
The best part? All commands are discoverable in the dashboard UI and can include rich metadata like descriptions, icons, and confirmation dialogs.
Aspire supports several types of custom commands:
| Command Type | Use Case | Example |
|---|---|---|
| Resource Commands | Direct resource manipulation | Clear Redis cache, restart container |
| HTTP Commands | API endpoint calls | Invalidate cache via HTTP, trigger webhooks |
| Executable Commands | Run external processes | Database migrations, file operations |
| State-Aware Commands | Context-sensitive actions | Enable when healthy, disable when offline |
Let's start by adding a command to clear our Redis cache. This is perfect for development when you want to reset the cache and review cache loading scenarios.
First, let's create an extension method that adds a clear command to Redis resources. Create a new file RedisResourceBuilderExtensions.cs in your AppHost project:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Aspire.Hosting;
internal static class RedisResourceBuilderExtensions
{
public static IResourceBuilder<RedisResource> WithClearCommand(
this IResourceBuilder<RedisResource> builder)
{
builder.WithCommand(
name: "clear-cache",
displayName: "Clear Cache",
executeCommand: context => OnRunClearCacheCommandAsync(builder, context),
commandOptions: new CommandOptions
{
IconName = "AnimalRabbitOff",
IconVariant = IconVariant.Filled,
UpdateState = OnUpdateResourceState,
ConfirmationMessage = "Are you sure you want to clear the cache?",
Description = "This command will clear all cached data in the Redis database.",
}
);
return builder;
}
private static async Task<ExecuteCommandResult> OnRunClearCacheCommandAsync(
IResourceBuilder<RedisResource> builder,
ExecuteCommandContext context)
{
var connectionString = await builder.Resource.GetConnectionStringAsync() ??
throw new InvalidOperationException(
$"Unable to get the '{context.ResourceName}' connection string.");
await using var connection = ConnectionMultiplexer.Connect(connectionString);
var database = connection.GetDatabase();
await database.ExecuteAsync("FLUSHALL");
return CommandResults.Success();
}
private static ResourceCommandState OnUpdateResourceState(
UpdateCommandStateContext context)
{
var logger = context.ServiceProvider.GetRequiredService<ILogger<Program>>();
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
"Updating resource state: {ResourceSnapshot}",
context.ResourceSnapshot);
}
return context.ResourceSnapshot.HealthStatus is HealthStatus.Healthy
? ResourceCommandState.Enabled
: ResourceCommandState.Disabled;
}
}Let's break down what makes this command work:
builder.WithCommand(
name: "clear-cache", // Internal identifier
displayName: "Clear Cache", // What users see
executeCommand: context => OnRunClearCacheCommandAsync(builder, context),
commandOptions: new CommandOptions { /* ... */ }
);commandOptions: new CommandOptions
{
IconName = "AnimalRabbitOff", // Fluent UI icon
IconVariant = IconVariant.Filled, // Icon style
UpdateState = OnUpdateResourceState, // State management
ConfirmationMessage = "Are you sure...", // Safety prompt
Description = "This command will...", // Help text
}The IconName comes from the Blazor FluentUI icons that the dashboard uses. You can choose an icon to use on their site at: https://www.fluentui-blazor.net/Icon#explorer
The OnRunClearCacheCommandAsync method:
- Gets the Redis connection string
- Connects to Redis
- Executes the
FLUSHALLcommand - Returns success or failure
The OnUpdateResourceState method determines when the command should be available:
- Enabled: When Redis is healthy
- Disabled: When Redis is unhealthy or offline
Now update your AppHost Program.cs to use the new command:
var builder = DistributedApplication.CreateBuilder(args);
var cache = builder.AddRedis("cache")
.WithClearCommand() // 🎉 Our new command!
.WithRedisInsight();
// ...rest of your configurationHTTP commands are perfect for triggering API operations. Let's create a command that invalidates our API cache via an HTTP endpoint.
Before we can create an HTTP command, we need an actual API endpoint to call. Let's add a cache invalidation endpoint to our API project.
Open the NwsManager.cs file in the Api/Data folder and update the MapApiEndpoints method to include a new cache invalidation endpoint:
public static WebApplication? MapApiEndpoints(this WebApplication app)
{
app.UseOutputCache();
app.MapGet("/zones", async (Api.NwsManager manager) =>
{
var zones = await manager.GetZonesAsync();
return TypedResults.Ok(zones);
})
.CacheOutput(policy => policy.Expire(TimeSpan.FromHours(1)))
.WithName("GetZones")
.WithOpenApi();
app.MapGet("/forecast/{zoneId}", async Task<Results<Ok<Api.Forecast[]>, NotFound>> (Api.NwsManager manager, string zoneId) =>
{
try
{
var forecasts = await manager.GetForecastByZoneAsync(zoneId);
return TypedResults.Ok(forecasts);
}
catch (HttpRequestException)
{
return TypedResults.NotFound();
}
})
.CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(15)).SetVaryByRouteValue("zoneId"))
.WithName("GetForecastByZone")
.WithOpenApi();
// 🎉 Add this new cache invalidation endpoint
app.MapPost("/cache/invalidate", static async (
[FromHeader(Name = "X-CacheInvalidation-Key")] string? header,
IOutputCacheStore cacheStore,
IConfiguration config) =>
{
var hasValidHeader = config.GetValue<string>("ApiCacheInvalidationKey") is { } key
&& header == $"Key: {key}";
if (hasValidHeader is false)
{
return Results.Unauthorized();
}
await cacheStore.EvictByTagAsync("AllCache", CancellationToken.None);
return Results.Ok();
});
return app;
}You'll also need to add the required using statements at the top of the file:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;And update the output caching configuration in the AddNwsManager method to include cache tags:
services.AddOutputCache(options =>
{
options.AddBasePolicy(builder => builder.Tag("AllCache")
.Cache());
});This endpoint:
- Validates the invalidation key through a secure header
- Clears all cached data by evicting the "AllCache" tag
- Returns appropriate HTTP status codes (200 OK or 401 Unauthorized)
Create ApiCommandExtensions.cs in your AppHost project:
namespace Aspire.Hosting;
public static class ApiCommandExtensions
{
public static IResourceBuilder<ProjectResource> WithApiCacheInvalidation(
this IResourceBuilder<ProjectResource> builder,
IResourceBuilder<ParameterResource> invalidationKey)
{
builder.WithEnvironment("ApiCacheInvalidationKey", invalidationKey)
.WithHttpCommand(
path: "/cache/invalidate",
displayName: "Invalidate Cache",
commandOptions: new HttpCommandOptions
{
Description = "Invalidates the API cache by removing all output cache.",
PrepareRequest = (context) =>
{
var key = invalidationKey.Resource.Value;
context.Request.Headers.Add("X-CacheInvalidation-Key",
$"Key: {key}");
return Task.CompletedTask;
},
Method = HttpMethod.Post,
IconName = "DocumentLightning",
IconVariant = IconVariant.Filled,
ConfirmationMessage = "Are you sure you want to invalidate the API cache?",
});
return builder;
}
}The PrepareRequest callback lets you customize the HTTP request:
PrepareRequest = (context) =>
{
var key = invalidationKey.Resource.Value;
context.Request.Headers.Add("X-CacheInvalidation-Key", $"Key: {key}");
return Task.CompletedTask;
}Method = HttpMethod.Post, // GET, POST, PUT, DELETE, etc.The command uses a parameter for the invalidation key, ensuring secure API access.
Update your AppHost to include the cache invalidation command:
var builder = DistributedApplication.CreateBuilder(args);
var invalidationKey = builder.AddParameter("ApiCacheInvalidationKey");
var cache = builder.AddRedis("cache")
.WithClearCommand()
.WithRedisInsight();
var api = builder.AddProject<Projects.Api>("api")
.WithApiCacheInvalidation(invalidationKey) // 🎉 HTTP command!
.WithReference(cache);
// ...rest of your configurationAspire uses Fluent UI icons for command buttons. Here are some popular choices:
| Icon Name | Use Case | Visual Style |
|---|---|---|
Delete |
Clear, remove, reset | Destructive actions |
Refresh |
Restart, reload | Refresh operations |
DocumentLightning |
Fast operations | Performance actions |
AnimalRabbitOff |
Stop, disable | Toggle off states |
Play |
Start, execute | Execution actions |
Settings |
Configure, setup | Configuration |
For potentially destructive operations, always include confirmation messages:
ConfirmationMessage = "Are you sure you want to clear the cache?"This creates a dialog that users must confirm before the command executes.
Provide clear descriptions to help users understand what commands do:
Description = "This command will clear all cached data in the Redis database."Aspire provides the ResourceCommandService API for executing commands programmatically. This enables scenarios like composite commands that coordinate multiple operations or unit testing of commands.
Note: The
ResourceCommandServiceAPI was introduced in Aspire 9.4.
// Add a composite command that coordinates multiple operations
var api = builder.AddProject<Projects.Api>("api")
.WithReference(database)
.WithReference(cache)
.WithCommand("reset-all", "Reset Everything", async (context, ct) =>
{
var logger = context.ServiceProvider.GetRequiredService<ILogger<Program>>();
var commandService = context.ServiceProvider.GetRequiredService<ResourceCommandService>();
logger.LogInformation("Starting full system reset...");
try
{
// Execute other resource commands programmatically
var flushResult = await commandService.ExecuteCommandAsync(cache.Resource, "clear", ct);
var restartResult = await commandService.ExecuteCommandAsync(database.Resource, "restart", ct);
if (!restartResult.Success || !flushResult.Success)
{
return CommandResults.Failure("System reset failed");
}
logger.LogInformation("System reset completed successfully");
return CommandResults.Success();
}
catch (Exception ex)
{
logger.LogError(ex, "System reset failed");
return CommandResults.Failure(ex);
}
},
displayDescription: "Reset cache and restart database in coordinated sequence",
iconName: "ArrowClockwise");You can also use ResourceCommandService in unit tests:
[Fact]
public async Task Should_ResetCache_WhenTestStarts()
{
var builder = DistributedApplication.CreateBuilder();
var cache = builder.AddRedis("test-cache")
.WithClearCommand();
var api = builder.AddProject<Projects.TestApi>("test-api")
.WithReference(cache);
await using var app = builder.Build();
await app.StartAsync();
// Reset cache before running test using ResourceCommandService
var result = await app.ResourceCommands.ExecuteCommandAsync(
cache.Resource,
"clear",
CancellationToken.None);
Assert.True(result.Success, $"Failed to reset cache: {result.ErrorMessage}");
}Commands can be enabled or disabled based on resource state:
UpdateState = (context) =>
{
// Enable only when resource is healthy
return context.ResourceSnapshot.HealthStatus is HealthStatus.Healthy
? ResourceCommandState.Enabled
: ResourceCommandState.Disabled;
}Always handle errors gracefully in your command implementations:
private static async Task<ExecuteCommandResult> OnRunCommandAsync(
ExecuteCommandContext context)
{
try
{
// Your command logic here
return CommandResults.Success();
}
catch (Exception ex)
{
return CommandResults.Failure(ex.Message);
}
}Include logging in your commands for better debugging:
private static async Task<ExecuteCommandResult> OnRunCommandAsync(
ExecuteCommandContext context)
{
var logger = context.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Executing command for resource {ResourceName}",
context.ResourceName);
// Command logic...
logger.LogInformation("Command completed successfully");
return CommandResults.Success();
}public static IResourceBuilder<ProjectResource> WithMigrationCommand(
this IResourceBuilder<ProjectResource> builder)
{
return builder.WithCommand(
name: "run-migrations",
displayName: "Run Migrations",
executeCommand: async (context) =>
{
// Execute EF migrations
var connectionString = await GetConnectionStringAsync(context);
await RunMigrationsAsync(connectionString);
return CommandResults.Success();
},
commandOptions: new CommandOptions
{
IconName = "Database",
Description = "Runs Entity Framework database migrations",
ConfirmationMessage = "Run database migrations?"
});
}- Start your Aspire application:
dotnet runin the AppHost project - Open the dashboard: Usually at
https://localhost:17137for this sample - Find your resources: Look for the new command buttons under the ellipsis button in the table of resources
- Test the commands: Click and verify they work as expected
Each command should do one thing well. Don't create mega-commands that perform multiple unrelated operations.
Command names should clearly indicate what they do:
- ✅
clear-cache,restart-service,run-migrations - ❌
action1,do-stuff,command
Always return appropriate results and log errors:
try
{
await PerformOperation();
return CommandResults.Success();
}
catch (Exception ex)
{
logger.LogError(ex, "Command failed");
return CommandResults.Failure(ex.Message);
}Any command that deletes, clears, or modifies data should require confirmation.
Disable commands when they shouldn't be available (e.g., when a service is offline).
- Use meaningful icons
- Write clear descriptions
- Choose appropriate confirmation messages
Custom commands transform the Aspire dashboard from a passive monitoring tool into an active development environment. They provide:
- Developer Productivity: Common operations at your fingertips
- Consistency: Standardized way to interact with resources
- Safety: Built-in confirmations and state management
- Discoverability: All commands visible in one place
The examples we've built - Redis cache clearing and API cache invalidation - are just the beginning. You can create commands for:
- Database operations (migrations, seeding, backups)
- Service management (restarts, scaling, configuration updates)
- Development tools (test data generation, log clearing)
- Integration triggers (webhooks, notifications, deployments)
Start with simple commands and gradually build more sophisticated operations as your application grows. Your future self (and your teammates) will thank you for the productivity boost!