Telerik blogs

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:

  • Said “hello, world” to .NET Aspire and learned why an application model beats a folder full of ad-hoc Docker Compose files
  • Poked around the Developer Dashboard to watch our app light up in real-time
  • Saw how Service Defaults added opinionated capabilities like OpenTelemetry, health probes and resilience across every service with one line of code
  • Plugged SQL Server and Redis into the mix

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 Orchestration Matters

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.

  • Lifecycle: Databases come online before the APIs that depend on them.
  • Configuration: Connection strings, secrets and URLs flow in through environment variables.
  • Resilience and telemetry: Opt in once with 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.

Revisiting Service Defaults

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.

Meet the Orders API

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:

  1. Accept an order from our Blazor app
  2. For each line item, ask the Inventory API whether the product exists and has stock
  3. If validation passes, calculate tax, persist the order and reply 201 Created with an order summary

Note: 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.

Build the Endpoint

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?

  1. We use a guard clause to reject an empty order.
  2. We use cross-service validation to check inventory, so we don’t just assume a product is in stock.
  3. We calculate tax.
  4. We use Entity Framework Core to write the order to a OrdersDb database.
  5. And we respond with a 201 Created with the new resource.

How Does Orders Talk to Inventory?

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.
  • As you likely noticed, 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.

Upgrade the Blazor Frontend

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();

Order List Page

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.

orderlist

Order Details 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:

orderdetails

Create Order Page

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&nbsp;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.

createorder

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.

Checking In on the AppHost

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.

Observing the End‑to‑end Flow

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.

resource graph

Clicking on orders reveals its environment variables—notice the Services__inventory entry pointing to the actual endpoints.

inventory

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.

entire chain

Wrapping Up (And What’s Next)

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-headshot-sq
About the Author

Dave Brock

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.

Related Posts

Comments

Comments are disabled in preview mode.