Seeding test data is a crucial step in building reliable and maintainable applications, especially when working with complex, multi-service environments where a clean baseline and realistic domain state make the difference between catching a defect early and shipping it to production. In this post we’ll go beyond the theory and actually build a custom command in .NET Aspire that streamlines (and standardizes) the way you populate databases, message queues, and any other backing resources with deterministic seed data. By leaning on Aspire’s orchestration model and extensibility points, you can automate repeatable data preparation, dramatically shorten developer onboarding time, and keep local, CI, and ephemeral preview environments perfectly aligned instead of relying on ad-hoc SQL scripts or brittle one-off console utilities. You’ll see how to design your seeding so it’s idempotent, fast to re-run, and safe to invoke from pipelines or interactive tooling—while still being flexible enough to branch into scenario-based or load-style seeds when you need richer test coverage. Finally, we’ll walk through wiring the command into your Aspire app host, exposing it as an easy-to-type target for developers, and adding a few quality-of-life touches (like environment guards and verbose diagnostics) so the workflow feels first-class from day one.
What is .NET Aspire
.NET Aspire is an opinionated, end‑to‑end stack for building and running distributed cloud‑ready .NET applications. It gives you a higher‑level application model (an AppHost) that coordinates your projects, infrastructure dependencies (databases, queues, caches, secrets), service discovery, configuration wiring, and standardized local development experience—all with sensible defaults. Aspire ships curated component packages (for things like PostgreSQL, Redis, Azure resources, Dapr, etc.), unified diagnostics (structured logging, OpenTelemetry traces & metrics out of the box), and simple orchestration so you can stand up the whole system with a single command rather than juggling scripts and manual steps. In short: it reduces boilerplate and friction so teams can focus on domain code while still following modern cloud architecture practices.
Why do you want seeding
When you run an Aspire solution locally, those same building blocks that will ultimately live in managed cloud services are abstracted behind local containers, lightweight processes, or emulators (for example a local Postgres instead of Azure Database for PostgreSQL, Azurite for Blob/Queue/Table storage, a Redis container instead of a managed cache). This gives you fast feedback and zero cloud cost, but it also means the backing state is inherently ephemeral: containers are recreated, emulator data directories are wiped, and everything returns to an empty or factory state after machine restarts or a simple docker prune. Because of that volatility, it becomes extremely convenient—and quickly essential—to have an automatic way to rehydrate those resources with baseline configuration data, reference entities, feature flags, or scenario datasets so developers aren’t manually clicking portals or pasting ad‑hoc SQL every morning.
For this example service, I want certain image data to be available in an Azure Blob Storage account. This account runs locally in an emulator (Azurite). The process reads binary data (embedded resources) from a project and uploads this data to a blob container. This process is all taken care of in a class called BlobSeeder
.
To create a custom command on an Aspire resource, I have written the following extension method:
public static IResourceBuilder<AzureBlobStorageResource> WithDataLoadCommand(this IResourceBuilder<AzureBlobStorageResource> blobs)
{
return blobs.WithCommand(
name: "seed-image-blobs",
displayName: "Seed Image Blobs",
executeCommand: async context =>
{
var logger = context.ServiceProvider.GetRequiredService<ILogger<Program>>();
try
{
var blobSeeder = new BlobSeeder(logger, AspireConstants.BlobMemesContainerName);
var connectionString = await blobs.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None);
var blobServiceClient = new BlobServiceClient(connectionString);
// Ensure the container exists
try
{
var containerClient = await blobServiceClient.CreateBlobContainerAsync(AspireConstants.BlobMemesContainerName, PublicAccessType.Blob);
}
catch
{
logger.LogInformation("Blob container '{ContainerName}' already exists.", AspireConstants.BlobMemesContainerName);
}
await blobSeeder.SeedEmbeddedResourcesAsync(blobServiceClient, CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while seeding embedded meme resources");
}
return CommandResults.Success();
},
commandOptions: new CommandOptions
{
IconName = "ArrowUpload",
IconVariant = IconVariant.Filled,
Description = "Seed image blobs"
}
);
}
And now in the Azure AppHost
project, I changed Program.cs
to include the following lines:
var blobs = storage
.AddBlobs(AspireConstants.BlobServiceName)
.WithDataLoadCommand(); // <-- Adding the custom command
storage
.AddBlobContainer(AspireConstants.BlobUploadContainerName, AspireConstants.BlobServiceName);
var memesContainer = storage
.AddBlobContainer(AspireConstants.BlobMemesContainerName, AspireConstants.BlobServiceName);
OnResourceReady vs custom command
Aspire also offers an OnResourceReady
hook that fires each time a resource is considered ready (its container started, emulator reachable, connection string resolved). That makes it tempting to drop your seeding logic in there, but lifecycle differences matter. Some resources are effectively “per session” (transient containers wiped on each run), while others become persistent (a Docker volume, a durable Azurite data folder) and retain data across many debug sessions. If you always seed in OnResourceReady
, your code must be idempotent and quick—otherwise you add avoidable startup latency and noisy logs every single run even when nothing needs to change. A custom command flips that control model: seeding runs only when explicitly invoked (after a prune, before a demo, or to reset scenarios), eliminating redundant work but introducing a manual step that can be forgotten. A pragmatic hybrid is to keep a fast, defensive check (e.g. look for a version marker blob, table row, or migration record) in OnResourceReady
and do nothing if present, while exposing a richer custom command for a full re-seed or scenario switch. Choose based on developer friction: if forgetting to seed causes frequent confusion, lean on the automatic path with safeguards; if startup speed and determinism matter more, prefer the manual command plus clear docs.
Last modified on 2025-09-02