API Development with Aspire: A Journey Through AspireShop
While diving into API development with Aspire, I came across an intriguing sample project: AspireShop. At first glance, it seems like a simple demo, but upon closer inspection, it reveals a clever contrast between two distinct backend service styles: one using gRPC and the other using HTTP APIs. This appears to be a deliberate decision, likely meant to highlight two different approaches to backend development in .NET.
The AspireShop architecture consists of two backend services — BasketService and CatalogService — as well as a frontend. These two services could not be more different in their implementation. Let’s start with the CatalogService.
CatalogService: The HTTP API That Feels… Weird
CatalogService is implemented as an HTTP API. On paper, it sounds familiar — after all, we’ve seen this with ASP.NET MVC controllers for years. But the syntax and conventions have shifted dramatically. Gone are the controller classes and attribute-based routing we used to rely on. In their place, AspireShop uses minimal APIs with extension methods that map routes in what initially feels like a very strange way.
Here’s the pattern: You begin by creating a static class with a static method — an extension method that returns a RouteGroupBuilder. This extension method is then invoked from Program.cs, like so:
app.MapCatalogApi();
Within this method, you register endpoints:
group.MapGet("items/type/all", (CatalogDbContext catalogContext, int? before, int? after, int pageSize = 8)
=> GetCatalogItems(null, catalogContext, before, after, pageSize))
.Produces(StatusCodes.Status400BadRequest)
.Produces<CatalogItemsPage>();
These maps look familiar to what I know as Controller methods which act as REST API endpoints. Within the ASP.NET MVC approach you define a controller to encapsulate some noun and then implement methods to implement different verbs. Each method uses annotations to define how the method manifests as an HTTP API operation.
In this case we have all the elements they are just jumbled up (albeit to my old eyes). The route is pretty clear, as are what appear to be query string parameters. The author essentially passes off the execution path to another static method defined in the same extension method (yuck).
static async Task<IResult> GetCatalogItems(int? catalogBrandId, CatalogDbContext catalogContext, int? before, int? after, int pageSize)
{
if (before is > 0 && after is > 0)
{
return TypedResults.BadRequest($"Invalid paging parameters. Only one of {nameof(before)} or {nameof(after)} can be specified, not both.");
}
var itemsOnPage = await catalogContext.GetCatalogItemsCompiledAsync(catalogBrandId, before, after, pageSize);
var (firstId, nextId) = itemsOnPage switch
{
[] => (0, 0),
[var only] => (only.Id, only.Id),
[var first, .., var last] => (first.Id, last.Id)
};
return Results.Ok(new CatalogItemsPage(
firstId,
nextId,
itemsOnPage.Count < pageSize,
itemsOnPage.Take(pageSize)));
}
Sorry I am not a fan of a local static method define inside of an extension method. Like Holy Hell guys.
What I’d like to do is figure out how to define a class called “CatalogService” and inject an instance of CatalogService into the mapping so instead of the mapping calling this nested static method, I’m gonna call the nice method on my CatalogService class.
Refactoring Toward Sanity
What I’d like to do is define a proper class called CatalogService, inject it using dependency injection, and call clean, well-scoped methods on that service. That would allow me to write readable, maintainable code without nesting functions inside other functions like a Matryoshka doll of awkwardness.
Instead of:
group.MapGet(... => GetCatalogItems(...))
I want:
group.MapGet(... => catalogService.GetItems(...))
Now that would be cleaner IMHO.
BasketService: Sweet Relief with gRPC
Thankfully, AspireShop redeems itself with BasketService. This gRPC-based service is structured in a way that should feel immediately comfortable to anyone with a background in ASP.NET Core. It’s a real class. It gets registered cleanly:
app.MapGrpcService<BasketService>();
OK. My old .NET eyes are feeling happy again. This is a horse I can ride. For all intensive purposes this is the same thing as registering a scoped class in my dependency injection container.
It gets better. The BasketService uses dependency injection to inject an ILogger (YES!!!) and an IBasketRepository (SCORE!!!).
public class BasketService(IBasketRepository repository, ILogger<BasketService> logger)
: Basket.BasketBase
{
...
}
Ok, we are back on solid .NET ground guys. It’s okay. Things got a little dicey there but I am feeling good again.
The Basket Service implements pretty typicaly controller methods using Request / Response objects in an asynchronous fashion.
public override async Task<CustomerBasketResponse> GetBasketById(BasketRequest request, ServerCallContext context)
{
// Uncomment to force a delay for testing resiliency, etc.
//await Task.Delay(3000);
var data = await repository.GetBasketAsync(request.Id);
if (data is not null)
{
return MapToCustomerBasketResponse(data);
}
return new CustomerBasketResponse();
}
It looks and feels just like a traditional controller method. Why this structure is reserved for gRPC and not extended to HTTP APIs in AspireShop, I don’t know — but I plan to find out.
Final Thoughts
AspireShop is a fascinating case study in how .NET is evolving. I feel old. Seriously. It juxtaposes minimal API patterns — warts and all — against the familiar comfort of structured, class-based gRPC services. For newcomers or traditional ASP.NET developers like myself, it feels a bit disorienting. But with some effort, and a little refactoring, maybe I can bring some order back into your services.
My next step? Abstract the HTTP route mappings out of the static extension method hellscape and into something class-based and clean. If gRPC can do it, so can HTTP.