In Part 5, we introduce a brand-new Orders API, teach it to talk to Inventory and see how the Aspire orchestrator handles all the messy wiring for us.
This is Part 5 of our six‑part deep dive into .NET Aspire.
Welcome back, friends. We’re really cooking with gas now.
Up to this point, we’ve:
We’ve had a lot of fun so far, but up to this point we’ve been hosting a party of one: an Inventory API that lists guitars for sale. Today, we’ll introduce a brand-new Orders API, teach it how to talk to Inventory, and see how the Aspire orchestrator handles all the messy wiring for us.
Note: This series focuses on .NET Aspire and not writing APIs, so I’ll be assuming you have general working knowledge of how to write C# APIs. I will explain new code where relevant.
Why does orchestration matter? Docker Compose and Kubernetes absolutely help with scheduling containers, but they live outside of your codebase. Your CI pipeline has to juggle a bunch of YAML files, keep ports in sync and pray nobody commits a hard‑coded endpoint by mistake.
With Aspire, orchestration moves back into C#. You declare what exists and how pieces relate.
AddServiceDefaults()
and every outbound call is wrapped with retries, time‑outs and OpenTelemetry spans.In short, with Aspire orchestration is the central hub that knows the dependency graph, watches health probes and keeps secrets safe. No more depending on a dated README.md
and launching nine terminals.
Before we start wiring up new services, let’s revisit service defaults. In our previous post, we saw how a single call to builder.AddServiceDefaults()
adds OpenTelemetry instrumentation, health probes, service discovery and a resilience pipeline. Those defaults apply to every ASP.NET Core project that opts in by calling builder.AddServiceDefaults()
.
This allows service defaults to do the heavy lifting for orchestration. When the AppHost injects environment variables like ConnectionStrings__guitardb
, our services automatically pick them up through configuration binding. Telemetry flows to the dashboard without any extra code. And when we call another service via HttpClient
, the standard resilience handler adds retries, timeouts and circuit breakers.
We won’t rehash all of the implementation details here—see Part 3 for in-depth details. However, keep in mind that everything we build in this post rests on these conventions.
Our guitar shop currently consists of a Blazor frontend and a single Inventory (/guitars
) API that handles basic create-read-update-delete (CRUD) operations. We’ll now introduce an Orders API.
The Orders API will:
201 Created
with an order summaryNote: For clarity and conciseness, the following code is under a single endpoint. For a “real-world” production app, much of this logic will live in other parts of your application.
Let’s create the Orders API. Let’s take a look at our POST
endpoint. I’ll walk you through it next.
app.MapPost("/orders", async (CreateOrderRequest req,
OrdersDbContext db,
InventoryClient inventory) =>
{
if (!req.Lines.Any())
return Results.BadRequest("At least one line is required.");
var validatedLines = new List<OrderLine>();
foreach (var line in req.Lines)
{
var product = await inventory.GetAsync(line.ProductId);
if (product is null) return Results.BadRequest($"Product {line.ProductId} not found");
if (product.Stock < line.Quantity) return Results.BadRequest($"Insufficient stock for {product.Sku}");
validatedLines.Add(new OrderLine
{
ProductId = line.ProductId,
Quantity = line.Quantity,
UnitPrice = product.Price
});
}
var subtotal = validatedLines.Sum(l => l.LineTotal);
var tax = Math.Round(subtotal * 0.05m, 2);
var order = new Order
{
CustomerName = req.CustomerName,
Subtotal = subtotal,
Tax = tax,
Lines = validatedLines
};
db.Orders.Add(order);
await db.SaveChangesAsync();
return Results.Created($"/orders/{order.Id}",
new { order.Id, order.OrderNumber, order.Subtotal, order.Tax, order.Total });
});
What just happened?
OrdersDb
database.201 Created
with the new resource.In our new API’s Program.cs
, we register an HTTP client:
builder.Services.AddHttpClient<InventoryClient>(client =>
client.BaseAddress = new Uri("https+http://inventory"));
The URI looks a little funky: notice the scheme (https+http
) and the host (inventory
).
https+http
tells Aspire’s resolver to try HTTPS first, then fall back to HTTP.inventory
isn’t a DNS name. It’s the canonical service name we’ll define in the AppHost
.If you remember, we defined it earlier:
var inventoryApi = builder.AddProject<Projects.Api>("inventory")
.WithReference(inventoryDb)
.WithReference(cache)
.WaitFor(inventoryDb);
With this in place, Aspire injects the actual URL via configuration. As a result, we don’t require port numbers, a localhost or environment‑specific configuration. At runtime, Aspire injects two environment variables:
Services__inventory__https = https://localhost:6001
Services__inventory__http = http://localhost:5001
The resolver swaps the placeholder URI for the real endpoint. Just like that, service discovery handles it all.
Our new service isn’t helpful if our customers can’t see it. Let’s now walk through the three components that showcase the Orders API.
Each component uses typed HttpClient
services so they inherit telemetry, resilience and service discovery out of the box.
builder.Services.AddHttpClient<OrdersHttpClient>(client =>
client.BaseAddress = new Uri("https+http://orders"))
.AddServiceDiscovery()
.AddStandardResilienceHandler();
For an order list, we’re using a paginated grid that lets you click a row for details. It also supports inline deletes without a page refresh.
@page "/orders"
@inject OrdersHttpClient Http
@inject NavigationManager Nav
@inject IJSRuntime JS
<PageTitle>Orders</PageTitle>
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="display-6 d-flex gap-2 mb-0">
<span>🧾</span> Orders
</h1>
<button class="btn btn-primary" @onclick="CreateNewOrder">
➕ New Order
</button>
</div>
@if (_orders is null)
{
<p>Loading…</p>
}
else
{
<table class="table table-striped">
<thead class="table-light small text-uppercase">
<tr>
<th>#</th>
<th>Date</th>
<th>Customer Name</th>
<th>Total</th>
<th>Delete?</th>
</tr>
</thead>
<tbody>
@foreach (var o in _orders)
{
<tr style="cursor:pointer"
@onclick="@(() => Nav.NavigateTo($"/orders/{o.Id}"))">
<td>@o.OrderNumber</td>
<td>@o.CreatedUtc.ToString("yyyy-MM-dd")</td>
<td>@o.CustomerName</td>
<td>@o.Total.ToString("C")</td>
<td>
<button class="btn btn-sm btn-link text-danger"
title="Delete"
@onclick:stopPropagation
@onclick="() => DeleteOrder(o.Id)">
🗑
</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
@code {
private List<OrderSummaryDto>? _orders;
protected override async Task OnInitializedAsync()
=> _orders = (await Http.GetOrdersAsync()).ToList();
async Task DeleteOrder(Guid id)
{
bool ok = await JS.InvokeAsync<bool>("confirm", "Delete this order?");
if (!ok) return;
var resp = await Http.DeleteOrderAsync(id);
if (resp.IsSuccessStatusCode)
{
var row = _orders.FirstOrDefault(x => x.Id == id);
if (row is not null)
{
_orders.Remove(row);
StateHasChanged();
}
}
else
{
await JS.InvokeVoidAsync("alert", $"Delete failed – {resp.StatusCode}");
}
}
private void CreateNewOrder() => Nav.NavigateTo("/create-order");
}
Here’s the finished Order List page.
With the Order List page set, we can build an Order Details page. This page displays the full invoice with the line-item pricing pulled directly from the server.
@page "/orders/{Id:guid}"
@inject OrdersHttpClient Http
<h1 class="mb-3">Order @Id</h1>
@if (_order is null)
{
<p>Loading…</p>
}
else
{
<p><b>Customer:</b> @_order.CustomerName</p>
<p><b>Date:</b> @_order.CreatedUtc.ToString("u")</p>
<table class="table">
<thead>
<tr>
<th>Product</th>
<th class="text-end">Qty</th>
<th class="text-end">Line Total</th>
</tr>
</thead>
<tbody>
@foreach (var l in _order!.Lines)
{
<tr>
<td>@l.ProductName</td>
<td class="text-end">@l.Quantity</td>
<td class="text-end">@l.LineTotal.ToString("C")</td>
</tr>
}
</tbody>
<tfoot>
<tr><td colspan="2" class="text-end">Subtotal</td><td class="text-end">@_order.Subtotal.ToString("C")</td></tr>
<tr><td colspan="2" class="text-end">Tax</td><td class="text-end">@_order.Tax.ToString("C")</td></tr>
<tr class="fw-bold"><td colspan="2" class="text-end">Total</td><td class="text-end">@_order.Total.ToString("C")</td></tr>
</tfoot>
</table>
}
@code {
[Parameter] public Guid Id { get; set; }
private OrderDetailDto? _order;
protected override async Task OnParametersSetAsync()
=> _order = await Http.GetOrderAsync(Id);
}
We can then easily view a breakdown of an order:
We also built a friendly form that queries the Inventory API for the guitar catalog, lets the user order multiple line items and then calculates the totals client-side.
@page "/create-order"
@using Entities
@inject InventoryHttpClient Inventory
@inject OrdersHttpClient Orders
@inject NavigationManager Nav
@inject IJSRuntime JS
<PageTitle>Create Order</PageTitle>
@if (_guitars is null)
{
<p class="m-4">Loading catalog…</p>
return;
}
<div class="container py-4" style="max-width:720px">
<h1 class="display-6 mb-4">➕ Create Order</h1>
<div class="mb-3">
<label class="form-label fw-semibold">Customer name</label>
<InputText @bind-Value="_customerName" class="form-control" />
</div>
<EditForm Model="_draft" OnValidSubmit="AddLine">
<div class="row g-2 align-items-end">
<div class="col-7">
<label class="form-label">Product</label>
<InputSelect TValue="Guid?" @bind-Value="_draft.ProductId" class="form-select">
<option value="">‒ select guitar ‒</option>
@foreach (var g in _guitars)
{
<option value="@g.Id">
@($"{g.Brand} {g.Model} — {g.Price:C}")
</option>
}
</InputSelect>
</div>
<div class="col-2">
<label class="form-label">Qty</label>
<InputNumber @bind-Value="_draft.Quantity" class="form-control" min="1" max="10" />
</div>
<div class="col-3 text-end">
<label class="form-label invisible">btn</label>
<button class="btn btn-outline-primary w-100" disabled="@(!_draft.IsValid)">
Add
</button>
</div>
</div>
</EditForm>
@if (_lines.Any())
{
<table class="table table-sm table-hover my-4">
<thead class="table-light small text-uppercase">
<tr>
<th>Product</th>
<th class="text-end">Qty</th>
<th class="text-end">Line Total</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var l in _lines)
{
var g = _guitarsById[l.ProductId];
<tr>
<td>@g.Brand @g.Model</td>
<td class="text-end">@l.Quantity</td>
<td class="text-end">@((g.Price * l.Quantity).ToString("C"))</td>
<td class="text-end">
<button class="btn btn-sm btn-link text-danger"
@onclick="() => RemoveLine(l)">
✖
</button>
</td>
</tr>
}
</tbody>
</table>
<div class="text-end mb-3">
<div>Subtotal: <strong>@_subtotal.ToString("C")</strong></div>
<div>Tax (5 %): <strong>@_tax.ToString("C")</strong></div>
<div class="fs-5">Total: <strong>@_total.ToString("C")</strong></div>
</div>
<button class="btn btn-success" @onclick="SubmitOrder">Submit Order</button>
}
</div>
@code {
private IReadOnlyList<GuitarDto>? _guitars;
private Dictionary<Guid, GuitarDto> _guitarsById = new();
private readonly List<CreateOrderLine> _lines = [];
private readonly LineDraft _draft = new();
private string _customerName = "Web Customer";
private decimal _subtotal, _tax, _total;
protected override async Task OnInitializedAsync()
{
_guitars = await Inventory.GetGuitarsAsync();
_guitarsById = _guitars.ToDictionary(g => g.Id);
}
void AddLine()
{
if (!_draft.IsValid) return;
_lines.Add(new CreateOrderLine(_draft.ProductId!.Value, _draft.Quantity));
_draft.Reset();
RecalcTotals();
}
void RemoveLine(CreateOrderLine l)
{
_lines.Remove(l);
RecalcTotals();
}
void RecalcTotals()
{
_subtotal = _lines.Sum(l => _guitarsById[l.ProductId].Price * l.Quantity);
_tax = Math.Round(_subtotal * 0.05m, 2);
_total = _subtotal + _tax;
}
async Task SubmitOrder()
{
var req = new CreateOrderRequest(_customerName, _lines);
var resp = await Orders.SubmitOrderAsync(req);
if (resp.IsSuccessStatusCode)
Nav.NavigateTo("/orders");
else
await JS.InvokeVoidAsync("alert", $"Order failed – {resp.StatusCode}");
}
private class LineDraft
{
public Guid? ProductId { get; set; }
public int Quantity { get; set; } = 1;
public bool IsValid => ProductId.HasValue && Quantity > 0;
public void Reset()
{
ProductId = null;
Quantity = 1;
}
}
}
Here’s our new Create Order page.
With these three Razor components in place, the UI now consumes the full Orders API—and, transitively, the Inventory API—all without hard-coding a single URL.
With our new service in place, let’s orchestrate them. Let’s look at the final Program.cs
for the AppHost project:
var builder = DistributedApplication.CreateBuilder(args);
var password = builder.AddParameter("password", secret: true);
var server = builder.AddSqlServer("server", password, 1433)
.WithDataVolume("guitar-data")
.WithLifetime(ContainerLifetime.Persistent);
var inventoryDb = server.AddDatabase("guitardb");
var orderDb = server.AddDatabase("ordersdb");
var cache = builder.AddRedis("cache")
.WithRedisInsight()
.WithLifetime(ContainerLifetime.Persistent);
var inventoryApi = builder.AddProject<Projects.Api>("inventory")
.WithReference(inventoryDb)
.WithReference(cache)
.WaitFor(inventoryDb);
var ordersApi = builder.AddProject<Projects.OrdersApi>("orders")
.WithReference(orderDb)
.WithReference(inventoryApi)
.WaitFor(orderDb);
builder.AddProject<Projects.Frontend>("frontend")
.WithReference(inventoryApi)
.WithReference(ordersApi)
.WaitFor(inventoryApi)
.WaitFor(ordersApi)
.WithExternalHttpEndpoints();
builder.Build().Run();
When you run the project through AppHost
, the orchestrator spins up SQL Server, the Inventory API and the Orders API—all in the correct order.
Each project receives environment variables pointing at its dependencies. Because we called AddServiceDefaults
in both our APIs, they automatically read these variables.
For example, the inventory service reads ConnectionStrings__guitardb
to configure EF Core, and the orders service reads Services__inventory__https
to configure its HTTP client.
With multiple services, the built-in health endpoints are even more important. Our AppHost
uses these endpoints to decide when a service is ready. If the inventory service fails its health probe, the orders service waits until it is healthy. If a downstream call repeatedly fails, the circuit breaker configured by AddServiceDefaults
prevents overwhelming the dependency.
Let’s fire up our full solution and observe what happens. From the terminal, run:
dotnet run --project GuitarShop.AppHost
Aspire builds each project, creates a SQL Server container, launches the inventory and orders services, and opens the Developer Dashboard. On the Resources page you’ll see what we’ve built so far.
Clicking on orders
reveals its environment variables—notice the Services__inventory
entry pointing to the actual endpoints.
Let’s place an order from the frontend. Open the Traces tab and you’ll see a span for the incoming POST /orders
request, a child span for the outgoing GET /guitars/{id}
call to inventory. This confirms that our instrumentation is working—the entire chain is captured and visualized.
In this post, things got real: we added our first real orchestration scenario to Dave’s Guitar Shop. We built a new orders service alongside our existing inventory service, used Aspire’s service defaults to add telemetry and resilience, and orchestrated everything through the AppHost. The AppHost now declares separate databases for inventory and orders, and injects connection strings into the services.
In the final part of this series, we’ll take our project of our laptops and to the cloud. We’ll see how Aspire’s Azure Container Apps integration maps our SQL and Redis resources to Azure offerings and how the same AppHost definition can be used to deploy our entire guitar shop with a single az containerapp up
command.
Stay tuned and see you soon!
Dave Brock is a software engineer, writer, speaker, open-source contributor and former Microsoft MVP. With a focus on Microsoft technologies, Dave enjoys advocating for modern and sustainable cloud-based solutions.