.NET Aspire: Using .NET 10 Aspire Architecture for Application Implementation

In this article we will see the .NET Aspire for the application development. The application implemented is online shopping portal based on independant services. MS-Online Shop is a cloud-ready shopping application built with .NET Aspire, Blazor, YARP API Gateway, SQL Server, Redis, and small focused services for identity, products, orders, and payments. The application supports customer and seller workflows, JWT authentication, product categories and manufacturers, Indian rupee pricing, and page-level light/dark theme support.

Need of Docker for This Application

Docker is needed because this application does not run only one web project. It also needs backing infrastructure such as SQL Server and Redis. .NET Aspire can start and connect these resources automatically, but container resources require a container runtime. On a developer machine, Docker Desktop or another compatible container runtime provides that environment.

In this solution Docker is mainly used for:

  • SQL Server container: Aspire creates one SQL Server resource named sql. Inside it, the application uses separate logical databases: identitydb, productsdb, ordersdb, and paymentsdb.
  • Redis container: Aspire creates a Redis resource named redis. ProductService uses Redis for output caching.
  • Consistent local setup: every developer can run the same infrastructure without manually installing SQL Server and Redis on the host machine.
  • Persistent data: WithDataVolume() on SQL Server stores database files in a Docker volume, so data can survive container restart/recreation during development.
  • Aspire dashboard integration: Docker-backed resources appear in the Aspire dashboard with health status, endpoints, logs, and dependencies.

The .NET projects themselves are still normal .NET projects. Docker is especially important for the infrastructure resources managed by AppHost.

.NET Aspire in Detail

.NET Aspire is a stack for building observable, cloud-ready, distributed .NET applications. It helps define how projects, containers, databases, caches, parameters, and service dependencies work together. Instead of starting each service manually and copying ports or connection strings, Aspire creates an application model and runs it through the AppHost project.

Important Aspire concepts used in this solution are:

  • AppHost: the executable orchestration project. It describes all resources and starts the distributed application.
  • Resources: items in the distributed application graph, such as projects, SQL Server, Redis, and databases.
  • Project resources: backend services and frontend projects added with AddProject<...>().
  • Container resources: infrastructure services such as SQL Server and Redis created through Aspire hosting packages.
  • Parameters: configuration values supplied by Aspire, often for secrets or environment-specific values. This app uses a jwt-signing-key parameter.
  • References: relationships created with WithReference(...). They allow one service to receive connection strings or discover another service by name.
  • Wait conditions: WaitFor(...) tells Aspire to start dependent projects only after required resources are available.
  • Environment variables: AppHost injects Jwt__Key into services so the same JWT signing configuration is used consistently.
  • Dashboard: Aspire shows the running app graph, endpoints, health, logs, and traces in one place.
  • Logs: logs from each service can be viewed from the Aspire dashboard, making local debugging easier.
  • Traces: OpenTelemetry integration helps follow a request across service boundaries, such as Web to Gateway to OrderService to PaymentService.
  • Metrics: runtime and HTTP metrics help understand application behavior and performance.
  • Service discovery: services can use logical names such as http://paymentservice instead of fixed localhost ports.

Packages Used in the Solution

The solution uses standard ASP.NET Core, Entity Framework Core, YARP, OpenTelemetry, Redis, SQL Server, QuestPDF, and .NET Aspire packages. The following table summarizes the major package references and why they are used.

PackageUsed InUse
Aspire.Hosting.SqlServerAppHostDefines and runs SQL Server as an Aspire-managed resource.
Aspire.Hosting.RedisAppHostDefines and runs Redis as an Aspire-managed resource.
Aspire.Microsoft.EntityFrameworkCore.SqlServerIdentityService, ProductService, OrderService, PaymentServiceRegisters SQL Server DbContext connections using Aspire-provided connection strings.
Aspire.StackExchange.Redis.OutputCachingProductServiceConnects ASP.NET Core output caching to Aspire-managed Redis.
Microsoft.AspNetCore.Authentication.JwtBearerIdentityService, ProductService, OrderServiceCreates and validates JWT bearer authentication tokens.
Microsoft.AspNetCore.Identity.EntityFrameworkCoreIdentityServiceStores ASP.NET Core Identity users, roles, passwords, and claims in SQL Server.
Microsoft.AspNetCore.OpenApiGateway and servicesProvides OpenAPI endpoint support during development.
Microsoft.EntityFrameworkCore.DesignData servicesSupports EF Core migrations and design-time tooling.
Microsoft.Extensions.ServiceDiscoveryOrderService, ServiceDefaultsEnables logical service name resolution inside the Aspire application.
Microsoft.Extensions.ServiceDiscovery.YarpApiGatewayAllows YARP destinations such as http://productservice to resolve through Aspire service discovery.
Microsoft.Extensions.Http.ResilienceOrderService, ServiceDefaultsAdds resilience patterns for HTTP calls, such as retries/timeouts where configured by defaults.
Yarp.ReverseProxyApiGatewayImplements the reverse proxy used as the single backend API entry point.
QuestPDFOrderServiceGenerates invoice PDF documents.
OpenTelemetry.*ServiceDefaultsEnables logs, traces, and metrics for observability.

Frontend npm Packages

The Web project also uses npm packages for CSS generation. The file src/AspireShopping.Web/package.json defines scripts for building and watching Tailwind CSS.

Package / ScriptUse
tailwindcssProvides the Tailwind CSS engine used to generate the application stylesheet.
@tailwindcss/cliProvides the command-line tool used by npm run build:css.
ensure:css-depsRuns the local dependency check script before CSS build/watch commands.
build:cssBuilds Styles/app.tailwind.css into wwwroot/app.css and minifies it.
watch:cssWatches Tailwind CSS input and regenerates wwwroot/app.css during development.

.NET Aspire Packages and Their Uses

The Aspire-specific packages are important because they connect normal .NET code with the Aspire application model.

  • Aspire.AppHost.Sdk: used by the AppHost project. It enables the distributed application builder APIs and generates project metadata used by Aspire.
  • Aspire.Hosting.SqlServer: gives AppHost methods such as AddSqlServer() and AddDatabase(). It lets Aspire create SQL Server and pass database connection strings to services.
  • Aspire.Hosting.Redis: gives AppHost AddRedis(). It lets Redis be represented as a first-class Aspire resource.
  • Aspire.Microsoft.EntityFrameworkCore.SqlServer: gives service projects AddSqlServerDbContext<TContext>(). This connects EF Core DbContexts to Aspire-managed SQL databases by name.
  • Aspire.StackExchange.Redis.OutputCaching: connects ASP.NET Core output caching with Redis in an Aspire-friendly way.

Why .NET Aspire?

.NET Aspire is designed for building distributed applications in .NET. Instead of manually starting every service, database, cache, and gateway, Aspire lets us describe the complete application topology in one AppHost project.

The main advantages are:

Single orchestration point

AppHost starts the web app, gateway, services, SQL Server, and Redis together. This is one of the biggest benefits of .NET Aspire. In a distributed application, starting each project manually can become difficult because every service has dependencies. For example, ProductService needs SQL Server and Redis, OrderService needs ProductService and PaymentService, and the Web project needs the API Gateway. AppHost describes all these dependencies in one place and starts the complete application as one connected system.

Service discovery

Services can call each other by logical names such as http://productservice instead of hard-coded ports. This removes the need to copy localhost ports into configuration files. In this solution, OrderService can call ProductService or PaymentService by service name. Aspire resolves the real endpoint at runtime. This makes the application easier to run locally and easier to move toward cloud deployment because services are not tightly bound to fixed machine-specific URLs.

Runtime connection strings

Aspire creates the SQL Server and Redis resources and injects connection strings into projects. The application code only asks for named resources such as productsdb, ordersdb, paymentsdb, identitydb, or redis. AppHost creates those resources and passes the correct runtime connection information to the projects. This keeps connection strings out of application code and avoids manual port/password configuration during local development.

Dashboard visibility

The Aspire dashboard shows resources, health, logs, traces, and endpoints in one place. This is very useful for troubleshooting. If login fails, product search is slow, or checkout does not complete, the developer can inspect the related service logs and traces from the dashboard. The dashboard also shows whether SQL Server, Redis, and the service projects are healthy and which endpoints are currently available.

Local development closer to production

Backing services run as containers and can be persisted using data volumes. Instead of using fake in-memory dependencies, the application runs with real SQL Server and Redis containers. This gives developers a more realistic environment while still staying local. In this solution, SQL Server uses WithDataVolume(), so database files can survive container restarts and the developer does not lose data every time the application is restarted.

Cleaner configuration

Common secrets such as the JWT signing key can be passed into multiple services from AppHost. IdentityService generates JWT tokens, while ProductService and OrderService validate them. All these services must use the same signing key. AppHost defines the key once as a secret parameter and injects it into the required services as Jwt__Key. This reduces duplicated configuration and helps avoid login/token validation problems caused by mismatched keys.

Solution Architecture

The solution follows a simple distributed architecture. The Blazor Web project is the user interface. It talks to the API Gateway, and the gateway routes requests to the correct backend service. Each backend service owns its own database. ProductService also uses Redis for output caching. You can see this diagram when you run the Aspire Application. The Browser shows this graph.

Aspire dashboard diagram showing web, gateway, services, SQL Server databases, and Redis

Figure 1: Aspire dashboard view of the running distributed application.

The Solution Architecture Diagram

The Figure 2 shows the solution architecture diagram



 Figure 2: The Application Architectuture

Implementation based on the  Figure

Figure 2 shows how the application is composed at runtime. Aspire AppHost creates SQL Server, Redis, four logical databases, four backend services, the API Gateway, and the Blazor Web frontend. The Web project exposes the user-facing endpoint. API calls are sent to the gateway. The gateway forwards /api/auth to IdentityService, /api/products to ProductService, /api/orders to OrderService, and /api/payments to PaymentService.

OrderService demonstrates service-to-service communication. During checkout it talks to ProductService to reserve product stock and talks to PaymentService to process payment. If payment fails, OrderService can compensate by releasing stock. This keeps the responsibility of each service clear.

Services and Their Uses

ProjectDetailed Use
AspireShopping.WebThis is the Blazor frontend used by customers and sellers. Customers use it to browse products, search, add items to cart, checkout, view orders, and download invoices. Sellers use it to add products with category and manufacturer dropdowns. It also contains the UI customizations such as MS-Online Shop branding, Indian rupee currency, larger input height, and page-wide light/dark theme support. It does not directly call each microservice; it calls the API Gateway.
AspireShopping.ApiGatewayThis project is the single API entry point for the frontend. It uses YARP Reverse Proxy. Instead of the Web project knowing every backend URL, the gateway accepts paths such as /api/auth, /api/products, /api/orders, and /api/payments, then forwards each request to the correct service using Aspire service discovery.
AspireShopping.IdentityServiceThis service owns authentication and identity data. It uses ASP.NET Core Identity with SQL Server. It handles registration, login, password validation, role assignment, role seeding, and JWT token generation. It owns the identitydb database.
AspireShopping.ProductServiceThis service owns the product catalog and product stock. It supports product browsing, seller product creation, search behavior, stock reservation, and stock release. It uses productsdb for product data and Redis for output caching.
AspireShopping.OrderServiceThis service owns checkout and order history. It coordinates ProductService and PaymentService. During checkout it reserves stock, requests payment, stores the final order, and generates invoices. If payment fails, it can release/reserve compensation through ProductService. It owns ordersdb.
AspireShopping.PaymentServiceThis service simulates payment processing. It stores payment attempts, approval/decline status, card last four digits, amount, transaction ID, and processing time in paymentsdb. It allows the checkout flow to be tested without a real payment provider.
AspireShopping.ContractsThis class library holds shared DTOs and role constants used by the Web app and services. Keeping request/response contracts in one project avoids duplicate model definitions and keeps APIs consistent.
AspireShopping.ServiceDefaultsThis shared project contains common infrastructure registration used by multiple executable projects. It adds health checks, service discovery, OpenTelemetry tracing/metrics/logging behavior, and default endpoints so every service does not need to repeat the same setup code.
AspireShopping.AppHostThis is the Aspire orchestration project. It does not contain business APIs. It describes what runs: projects, SQL Server, Redis, databases, dependencies, parameters, external endpoints, and startup order. Running AppHost runs the whole distributed application.

Why the Gateway Project Is Needed?

The Gateway project is needed because a distributed application has multiple backend services. Without a gateway, the Blazor Web project would need to know the address and port of IdentityService, ProductService, OrderService, and PaymentService. That would make the frontend tightly coupled to backend deployment details.

In this solution the gateway really does the following:

  • Provides one stable API entry point for the Web project.
  • Routes /api/auth/{**catch-all} to IdentityService.
  • Routes /api/products/{**catch-all} to ProductService.
  • Routes /api/orders/{**catch-all} to OrderService.
  • Routes /api/payments/{**catch-all} to PaymentService.
  • Uses YARP and Aspire service discovery so destinations such as http://orderservice resolve at runtime.
  • Keeps the frontend simpler because all backend calls can go through one gateway base URL.

How the Gateway Works in This Application?

In MS-Online Shop, the API Gateway works as the single backend entry point between the Blazor Web application and all backend services. The Web project does not directly call IdentityService, ProductService, OrderService, or PaymentService. Instead, the Web project sends API requests to ApiGateway, and ApiGateway forwards each request to the correct backend service.

The gateway works like a traffic controller. It receives an incoming request, checks the URL path, matches that path with a configured YARP route, finds the backend cluster for that route, resolves the real service endpoint using Aspire service discovery, forwards the request, and then returns the backend response to the Web application.

The request flow is shown in the Figure 2:

For example, when the Web app sends a request to /api/products/search, the gateway checks the route configuration in src/AspireShopping.ApiGateway/appsettings.json. The path matches this route pattern:

"Path": "/api/products/{**catch-all}"

That route points to the products-cluster. The cluster destination is configured as:

"products-cluster": {
  "Destinations": {
    "destination1": {
      "Address": "http://productservice"
    }
  }
}

So the actual routing decision becomes:

/api/products/search -> products-route -> products-cluster -> http://productservice

The gateway uses logical service names such as http://productservice and http://orderservice. These are not hard-coded localhost ports. They are Aspire service discovery names. Aspire knows the real endpoints because AppHost registers each project with a name:

builder.AddProject<Projects.AspireShopping_ProductService>("productservice")
builder.AddProject<Projects.AspireShopping_OrderService>("orderservice")
builder.AddProject<Projects.AspireShopping_PaymentService>("paymentservice")
builder.AddProject<Projects.AspireShopping_IdentityService>("identityservice")

The gateway is connected to these services in AppHost using WithReference(...). This tells Aspire that ApiGateway needs to discover and call those backend services. AppHost also uses WaitFor(...) so the gateway waits until required backend services are available.

var apiGateway = builder.AddProject<Projects.AspireShopping_ApiGateway>("apigateway")
    .WithExternalHttpEndpoints()
    .WithReference(identityService)
    .WithReference(productService)
    .WithReference(orderService)
    .WithReference(paymentService)
    .WaitFor(identityService)
    .WaitFor(productService)
    .WaitFor(orderService)
    .WaitFor(paymentService);

The gateway project enables this behavior in Program.cs with YARP Reverse Proxy:

builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
    .AddServiceDiscoveryDestinationResolver();

LoadFromConfig(...) tells YARP to read routes and clusters from appsettings.json. AddServiceDiscoveryDestinationResolver() tells YARP to resolve service names through Aspire service discovery. Because of this, the gateway can use http://identityservice, http://productservice, http://orderservice, and http://paymentservice instead of fixed URLs.

Incoming API pathGateway routeClusterBackend service
/api/auth/...auth-routeauth-clusterhttp://identityservice
/api/products/...products-routeproducts-clusterhttp://productservice
/api/orders/...orders-routeorders-clusterhttp://orderservice
/api/payments/...payments-routepayments-clusterhttp://paymentservice

The main benefit is that the Web app stays simple. It only needs to communicate with the gateway. The gateway hides the internal service layout and allows backend services to change ports or runtime addresses without changing frontend code.

ApiGateway appsettings.json Explained

The file src/AspireShopping.ApiGateway/appsettings.json configures logging, allowed hosts, and reverse proxy routing.

  • Logging: sets default logging to Information and reduces ASP.NET Core framework logs to Warning.
  • AllowedHosts: uses *, which means the app accepts requests for any host header in this development/sample setup.
  • ReverseProxy: contains YARP configuration.
  • Routes: define which incoming URL paths should be matched.
  • ClusterId: connects a route to a backend cluster.
  • Match Path: uses catch-all route patterns so all child paths are forwarded, for example /api/products/search.
  • Clusters: define backend service destinations.
  • Destinations: use Aspire service discovery names such as http://identityservice, not fixed ports.

Because the gateway uses AddServiceDiscoveryDestinationResolver(), these logical destination names are resolved by Aspire at runtime.

Role and Use of the AppHost Project

The AppHost project is the starting point of the Aspire application. In a normal multi-project solution, you may need to start SQL Server, Redis, IdentityService, ProductService, OrderService, PaymentService, Gateway, and Web separately. AppHost replaces that manual process with one executable distributed application definition.

AppHost actually does these jobs:

  • Creates the Aspire distributed application builder.
  • Reads parameters such as the JWT signing key.
  • Creates SQL Server as a managed resource.
  • Creates Redis as a managed resource.
  • Creates logical SQL databases for each service.
  • Adds each project as an Aspire project resource.
  • Passes database and cache references to the projects that need them.
  • Injects the same JWT key into the services that issue or validate tokens.
  • Defines service-to-service references for discovery.
  • Defines startup ordering using WaitFor().
  • Exposes selected projects externally using WithExternalHttpEndpoints().
  • Builds and runs the complete application graph.

Complete Structure of the AppHost Project

The AppHost project is small but very important. Its structure is:

  • AspireShopping.AppHost.csproj: identifies the project as an Aspire AppHost using Aspire.AppHost.Sdk, references all runnable projects, and includes Aspire hosting packages for SQL Server and Redis.
  • AppHost.cs: contains the distributed application definition. This is where resources, projects, references, dependencies, and parameters are declared.
  • appsettings.json: stores AppHost-level configuration and parameters. The JWT signing key parameter is configured here for local development, but should be protected for production.
  • appsettings.Development.json: stores development-specific AppHost settings.
  • aspire.config.json: tells Aspire tooling which project is the AppHost project.
  • Properties/launchSettings.json: contains launch profiles for running/debugging AppHost locally.

When AppHost runs, Aspire reads this structure, builds the distributed application model, starts required containers, starts project resources, connects services through references, and opens the Aspire dashboard.

AppHost.cs Explained in Detail

The file src/AspireShopping.AppHost/AppHost.cs is the most important orchestration file. It describes the runtime topology of MS-Online Shop.

  • DistributedApplication.CreateBuilder(args): creates the Aspire application builder.
  • AddParameter("jwt-signing-key", secret: true): defines a secret parameter. It is later injected into IdentityService, ProductService, and OrderService as Jwt__Key.
  • AddSqlServer("sql"): creates a SQL Server resource named sql.
  • WithDataVolume(): attaches persistent Docker storage to SQL Server data.
  • WithLifetime(ContainerLifetime.Persistent): keeps the backing container persistent across runs.
  • AddDatabase(...): creates logical databases inside the SQL Server resource for each service.
  • AddRedis("redis"): creates a Redis resource used for caching.
  • AddProject<...>(): adds runnable .NET projects into the Aspire app model.
  • WithReference(database): passes named connection strings into service projects.
  • WithReference(service): enables service discovery between projects.
  • WaitFor(...): ensures dependencies are ready before dependent projects start.
  • WithExternalHttpEndpoints(): exposes selected resources, such as API Gateway and Web, to the host machine/browser.
  • builder.Build().Run(): builds and starts the whole distributed application.

aspire.config.json Explained

The file src/AspireShopping.AppHost/aspire.config.json is very small, but it helps Aspire tooling locate the AppHost project.

{
  "appHost": {
    "path": "AspireShopping.AppHost.csproj"
  }
}

The appHost.path value points to AspireShopping.AppHost.csproj. This tells Aspire CLI/tooling that this project is the orchestration project to run when launching the distributed application.

All Projects in the Solution Explained

AspireShopping.AppHost

This project is the Aspire orchestrator. It is not a customer-facing API and it does not contain shopping business logic. Its responsibility is to describe and run the whole distributed system. It creates SQL Server, Redis, identitydb, productsdb, ordersdb, paymentsdb, IdentityService, ProductService, OrderService, PaymentService, ApiGateway, and Web. It also passes the JWT parameter to the services that need it and uses WaitFor() so projects start only after their dependencies are available.

AspireShopping.ApiGateway

This project is the backend entry point. It uses YARP to route requests to backend services. The frontend can call one gateway address instead of calling every service directly. This is useful because the service URLs are resolved by Aspire service discovery, and the frontend does not need to know internal service names or ports.

AspireShopping.Web

This is the Blazor web application. It contains the screens users interact with, such as home/product browsing, cart, login, registration, seller product management, orders, and order details. UI-level changes such as larger input elements, full-page theme switching, rupee currency display, and MS-Online Shop branding are implemented here.

AspireShopping.IdentityService

This service manages users, passwords, roles, and tokens. It uses ASP.NET Core Identity and stores data in SQL Server through Entity Framework Core. It exposes authentication endpoints under /api/auth. It creates JWT tokens after successful registration/login so other services can authorize requests.

AspireShopping.ProductService

This service manages product data and stock. It owns the product database and is responsible for product creation by sellers, product browsing by customers, product search, stock reservation during checkout, and stock release if checkout fails. Redis caching is used to make product read operations faster.

AspireShopping.OrderService

This service manages order workflows. It receives checkout requests, calls ProductService to reserve stock, calls PaymentService to process payment, stores successful orders in ordersdb, and generates invoice PDFs. It is the coordination point for the buying process.

AspireShopping.PaymentService

This service simulates payment processing. It records payment attempts and returns payment status. The test behavior allows declined payment scenarios when the card last four digits are 0000. This keeps payment responsibility isolated from OrderService.

AspireShopping.Contracts

This shared project contains API contract types. Request and response DTOs are used by services and Web clients so the shape of data remains consistent. It also contains role constants used by authorization logic.

AspireShopping.ServiceDefaults

This shared Aspire project contains cross-cutting infrastructure configuration. Services call AddServiceDefaults() and MapDefaultEndpoints() instead of duplicating health check, discovery, telemetry, and resilience setup in every project.

IdentityService in Detail

IdentityService is responsible for authentication and authorization. It uses ASP.NET Core Identity with Entity Framework Core and SQL Server. On startup it applies database migrations and seeds the two application roles: Seller and Customer. It exposes two main endpoints: /api/auth/register and /api/auth/login.

During registration, the service validates the requested role, checks whether the email already exists, creates the user, assigns the selected role, and returns a JWT token. During login, it checks the password and returns a JWT token containing user identity and role claims. Other services validate the JWT using the same signing key injected by AppHost.

src/AspireShopping.IdentityService/Program.cs

using AspireShopping.Contracts;
using AspireShopping.IdentityService.Data;
using AspireShopping.IdentityService.Models;
using AspireShopping.IdentityService.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();

builder.AddSqlServerDbContext<ClsApplicationDbContext>("identitydb");

builder.Services
    .AddIdentityCore<ClsApplicationUser>(options =>
    {
        options.Password.RequiredLength = 6;
        options.Password.RequireNonAlphanumeric = false;
        options.Password.RequireUppercase = false;
        options.User.RequireUniqueEmail = true;
    })
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<ClsApplicationDbContext>()
    .AddDefaultTokenProviders();

builder.Services.AddDataProtection();
builder.Services.AddScoped<ClsJwtTokenService>();

var app = builder.Build();

app.MapDefaultEndpoints();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

// Apply migrations and seed the Seller/Customer roles on startup.
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<ClsApplicationDbContext>();
    await db.Database.MigrateAsync();

    var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
    foreach (var role in new[] { ClsRoles.Seller, ClsRoles.Customer })
    {
        if (!await roleManager.RoleExistsAsync(role))
        {
            await roleManager.CreateAsync(new IdentityRole(role));
        }
    }
}

var auth = app.MapGroup("/api/auth").WithTags("Auth");

auth.MapPost("/register", async (RegisterRequest request, UserManager<ClsApplicationUser> userManager, ClsJwtTokenService jwt) =>
{
    if (request.Role != ClsRoles.Seller && request.Role != ClsRoles.Customer)
    {
        return Results.BadRequest(new { error = $"Role must be '{ClsRoles.Seller}' or '{ClsRoles.Customer}'." });
    }

    var existing = await userManager.FindByEmailAsync(request.Email);
    if (existing is not null)
    {
        return Results.Conflict(new { error = "A user with this email already exists." });
    }

    var user = new ClsApplicationUser
    {
        UserName = request.Email,
        Email = request.Email,
        FullName = request.FullName
    };

    var createResult = await userManager.CreateAsync(user, request.Password);
    if (!createResult.Succeeded)
    {
        return Results.BadRequest(new { errors = createResult.Errors.Select(e => e.Description) });
    }

    await userManager.AddToRoleAsync(user, request.Role);

    var (token, expiresAt) = jwt.GenerateToken(user, request.Role);
    return Results.Ok(new AuthResponse(token, expiresAt, user.Id, user.FullName, user.Email!, request.Role));
})
.WithName("Register");

auth.MapPost("/login", async (LoginRequest request, UserManager<ClsApplicationUser> userManager, ClsJwtTokenService jwt) =>
{
    var user = await userManager.FindByEmailAsync(request.Email);
    if (user is null || !await userManager.CheckPasswordAsync(user, request.Password))
    {
        return Results.Unauthorized();
    }

    var roles = await userManager.GetRolesAsync(user);
    var role = roles.FirstOrDefault() ?? ClsRoles.Customer;

    var (token, expiresAt) = jwt.GenerateToken(user, role);
    return Results.Ok(new AuthResponse(token, expiresAt, user.Id, user.FullName, user.Email!, role));
})
.WithName("Login");

app.Run();

src/AspireShopping.IdentityService/Data/ApplicationDbContext.cs

using AspireShopping.IdentityService.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace AspireShopping.IdentityService.Data;

public class ClsApplicationDbContext(DbContextOptions<ClsApplicationDbContext> options)
    : IdentityDbContext<ClsApplicationUser>(options)
{
    /// <summary>Runs the default ASP.NET Identity model configuration.</summary>
    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
    }
}

src/AspireShopping.IdentityService/Models/ApplicationUser.cs

using Microsoft.AspNetCore.Identity;

namespace AspireShopping.IdentityService.Models;

public class ClsApplicationUser : IdentityUser
{
    public string FullName { get; set; } = string.Empty;
}

src/AspireShopping.IdentityService/Services/JwtTokenService.cs

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using AspireShopping.IdentityService.Models;
using Microsoft.IdentityModel.Tokens;

namespace AspireShopping.IdentityService.Services;

public class ClsJwtTokenService(IConfiguration configuration)
{
    /// <summary>Creates a signed JWT containing the user's identity and role claims.</summary>
    public (string Token, DateTimeOffset ExpiresAt) GenerateToken(ClsApplicationUser user, string role)
    {
        var key = configuration["Jwt:Key"]
            ?? throw new InvalidOperationException("Jwt:Key is not configured.");
        var issuer = configuration["Jwt:Issuer"] ?? "AspireShopping.IdentityService";
        var audience = configuration["Jwt:Audience"] ?? "AspireShopping";

        var expiresAt = DateTimeOffset.UtcNow.AddHours(8);

        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, user.Id),
            new(ClaimTypes.NameIdentifier, user.Id),
            new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty),
            new(ClaimTypes.Name, user.FullName),
            new(ClaimTypes.Role, role),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };

        var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
        var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: issuer,
            audience: audience,
            claims: claims,
            expires: expiresAt.UtcDateTime,
            signingCredentials: credentials);

        return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
    }
}

src/AspireShopping.IdentityService/appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Jwt": {
    "Issuer": "AspireShopping.IdentityService",
    "Audience": "AspireShopping",
    "Key": "REPLACE-WITH-YOUR-OWN-32-CHAR-JWT-SECRET"
  }
}

Complete ApiGateway Project Code

The API Gateway project uses YARP reverse proxy. Its main job is to provide one API entry point and route traffic to the correct backend service.

src/AspireShopping.ApiGateway/AspireShopping.ApiGateway.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
    <PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="10.10.0" />
    <PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\AspireShopping.ServiceDefaults\AspireShopping.ServiceDefaults.csproj" />
  </ItemGroup>

</Project>

src/AspireShopping.ApiGateway/Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

builder.Services.AddProblemDetails();

builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
    .AddServiceDiscoveryDestinationResolver();

var app = builder.Build();

app.MapDefaultEndpoints();

app.MapReverseProxy();

app.Run();

src/AspireShopping.ApiGateway/appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ReverseProxy": {
    "Routes": {
      "auth-route": {
        "ClusterId": "auth-cluster",
        "Match": {
          "Path": "/api/auth/{**catch-all}"
        }
      },
      "products-route": {
        "ClusterId": "products-cluster",
        "Match": {
          "Path": "/api/products/{**catch-all}"
        }
      },
      "orders-route": {
        "ClusterId": "orders-cluster",
        "Match": {
          "Path": "/api/orders/{**catch-all}"
        }
      },
      "payments-route": {
        "ClusterId": "payments-cluster",
        "Match": {
          "Path": "/api/payments/{**catch-all}"
        }
      }
    },
    "Clusters": {
      "auth-cluster": {
        "Destinations": {
          "destination1": {
            "Address": "http://identityservice"
          }
        }
      },
      "products-cluster": {
        "Destinations": {
          "destination1": {
            "Address": "http://productservice"
          }
        }
      },
      "orders-cluster": {
        "Destinations": {
          "destination1": {
            "Address": "http://orderservice"
          }
        }
      },
      "payments-cluster": {
        "Destinations": {
          "destination1": {
            "Address": "http://paymentservice"
          }
        }
      }
    }
  }
}

src/AspireShopping.ApiGateway/appsettings.Development.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  }
}

src/AspireShopping.ApiGateway/AspireShopping.ApiGateway.http

@AspireShopping.ApiGateway_HostAddress = http://localhost:5075

GET {{AspireShopping.ApiGateway_HostAddress}}/weatherforecast/
Accept: application/json

###

src/AspireShopping.ApiGateway/Properties/launchSettings.json

{
  "$schema": "https://json.schemastore.org/launchsettings.json",
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": false,
      "applicationUrl": "http://localhost:5075",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": false,
      "applicationUrl": "https://localhost:7135;http://localhost:5075",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

Complete AppHost Project Code

AppHost is the heart of the Aspire solution. It defines the distributed application graph: SQL Server, Redis, logical databases, services, gateway, frontend, dependencies, wait conditions, and shared JWT configuration.

Note: the JWT signing key shown below is redacted for public publishing. Use your own 32+ character key through secure configuration.

src/AspireShopping.AppHost/AspireShopping.AppHost.csproj

<Project Sdk="Aspire.AppHost.Sdk/13.5.4">

  <ItemGroup>
    <ProjectReference Include="..\AspireShopping.ApiGateway\AspireShopping.ApiGateway.csproj" />
    <ProjectReference Include="..\AspireShopping.ProductService\AspireShopping.ProductService.csproj" />
    <ProjectReference Include="..\AspireShopping.OrderService\AspireShopping.OrderService.csproj" />
    <ProjectReference Include="..\AspireShopping.PaymentService\AspireShopping.PaymentService.csproj" />
    <ProjectReference Include="..\AspireShopping.IdentityService\AspireShopping.IdentityService.csproj" />
    <ProjectReference Include="..\AspireShopping.Web\AspireShopping.Web.csproj" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Aspire.Hosting.Redis" Version="13.5.4" />
    <PackageReference Include="Aspire.Hosting.SqlServer" Version="13.5.4" />
  </ItemGroup>

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <AspireUseCliBundle>true</AspireUseCliBundle>
    <UserSecretsId>ef3dae18-02d6-4c3c-ad63-985286992d0c</UserSecretsId>
  </PropertyGroup>

</Project>

src/AspireShopping.AppHost/AppHost.cs

var builder = DistributedApplication.CreateBuilder(args);

// --- Shared secrets ---
// A single JWT signing key shared by IdentityService (issuer) and every resource-service
// (validator), injected as an environment variable so all instances agree on the secret.
var jwtKey = builder.AddParameter("jwt-signing-key", secret: true);

// --- Backing services ---
// Aspire creates the SQL Server container and generates the connection string at runtime.
// Projects receive database-specific connection strings through WithReference(...).
var sql = builder.AddSqlServer("sql")
    // Stores SQL Server data in a Docker volume so databases survive container restarts.
    .WithDataVolume()
    .WithLifetime(ContainerLifetime.Persistent);

// Logical databases hosted inside the shared SQL Server container.
var productsDb = sql.AddDatabase("productsdb");
var ordersDb = sql.AddDatabase("ordersdb");
var paymentsDb = sql.AddDatabase("paymentsdb");
var identityDb = sql.AddDatabase("identitydb");

// Redis is also containerized by Aspire; consumers receive its runtime connection string by name.
var redis = builder.AddRedis("redis")
    .WithLifetime(ContainerLifetime.Persistent);

// --- Identity / Auth ---
// IdentityService uses the identitydb SQL connection and the shared JWT signing key.
var identityService = builder.AddProject<Projects.AspireShopping_IdentityService>("identityservice")
    .WithReference(identityDb)
    .WaitFor(identityDb)
    .WithEnvironment("Jwt__Key", jwtKey);

// --- Application services ---
// ProductService uses productsdb for data storage and Redis for output caching.
var productService = builder.AddProject<Projects.AspireShopping_ProductService>("productservice")
    .WithReference(productsDb)
    .WaitFor(productsDb)
    .WithReference(redis)
    .WaitFor(redis)
    .WithEnvironment("Jwt__Key", jwtKey);

// PaymentService stores payment records in paymentsdb.
var paymentService = builder.AddProject<Projects.AspireShopping_PaymentService>("paymentservice")
    .WithReference(paymentsDb)
    .WaitFor(paymentsDb);

// OrderService stores orders in ordersdb and calls ProductService/PaymentService by service discovery.
var orderService = builder.AddProject<Projects.AspireShopping_OrderService>("orderservice")
    .WithReference(ordersDb)
    .WaitFor(ordersDb)
    .WithReference(productService)
    .WithReference(paymentService)
    .WaitFor(productService)
    .WaitFor(paymentService)
    .WithEnvironment("Jwt__Key", jwtKey);

// --- Gateway (public entry point, uses service discovery to route to the above) ---
var apiGateway = builder.AddProject<Projects.AspireShopping_ApiGateway>("apigateway")
    .WithExternalHttpEndpoints()
    .WithReference(identityService)
    .WithReference(productService)
    .WithReference(orderService)
    .WithReference(paymentService)
    .WaitFor(identityService)
    .WaitFor(productService)
    .WaitFor(orderService)
    .WaitFor(paymentService);

// --- Blazor web client (talks to everything through the gateway) ---
builder.AddProject<Projects.AspireShopping_Web>("web")
    .WithExternalHttpEndpoints()
    .WithReference(apiGateway)
    .WaitFor(apiGateway);

builder.Build().Run();

src/AspireShopping.AppHost/appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Aspire.Hosting.Dcp": "Warning"
    }
  },
  "Parameters": {
    "jwt-signing-key": "REPLACE-WITH-YOUR-OWN-32-CHAR-JWT-SECRET"
  }
}

src/AspireShopping.AppHost/appsettings.Development.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  }
}

src/AspireShopping.AppHost/aspire.config.json

{
  "appHost": {
    "path": "AspireShopping.AppHost.csproj"
  }
}

src/AspireShopping.AppHost/Properties/launchSettings.json

{
  "$schema": "https://json.schemastore.org/launchsettings.json",
  "profiles": {
    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "applicationUrl": "https://localhost:17002;http://localhost:15143",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "DOTNET_ENVIRONMENT": "Development",
        "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21011",
        "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22197"
      }
    },
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "applicationUrl": "http://localhost:15143",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "DOTNET_ENVIRONMENT": "Development",
        "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19049",
        "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20193"
      }
    }
  }
}

The Table Diagram with Relationship

Figure 3, shows the Table. diagram with Relationships across them



Figure 3: Tables used in the application

Complete Code of One Service: PaymentService

PaymentService is intentionally small and easy to understand. It stores every payment attempt in SQL Server. The sample payment logic approves most cards and declines cards whose last four digits are 0000. This makes it useful for testing successful and failed checkout paths without integrating a real payment provider.

src/AspireShopping.PaymentService/AspireShopping.PaymentService.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Aspire.Microsoft.EntityFrameworkCore.SqlServer" Version="13.5.4" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\AspireShopping.ServiceDefaults\AspireShopping.ServiceDefaults.csproj" />
    <ProjectReference Include="..\AspireShopping.Contracts\AspireShopping.Contracts.csproj" />
  </ItemGroup>

</Project>

src/AspireShopping.PaymentService/Program.cs

using AspireShopping.Contracts;
using AspireShopping.PaymentService.Data;
using AspireShopping.PaymentService.Models;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();

builder.AddSqlServerDbContext<ClsPaymentDbContext>("paymentsdb");

var app = builder.Build();

app.MapDefaultEndpoints();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<ClsPaymentDbContext>();
    await db.Database.MigrateAsync();
}

var payments = app.MapGroup("/api/payments").WithTags("Payments");

// Simulated payment gateway: declines when the test card ends in "0000",
// otherwise approves and issues a transaction id.
payments.MapPost("/process", async (ProcessPaymentRequest request, ClsPaymentDbContext db) =>
{
    var isDeclined = request.CardNumberLast4 == "0000";

    var payment = new ClsPayment
    {
        OrderId = request.OrderId,
        Amount = request.Amount,
        CardholderName = request.CardholderName,
        CardNumberLast4 = request.CardNumberLast4,
        PaymentMethod = request.PaymentMethod,
        Status = isDeclined ? PaymentStatus.Declined : PaymentStatus.Approved,
        TransactionId = isDeclined ? null : $"TXN-{Guid.NewGuid():N}"[..16].ToUpperInvariant(),
        ProcessedAt = DateTimeOffset.UtcNow
    };

    db.Payments.Add(payment);
    await db.SaveChangesAsync();

    return Results.Ok(ToDto(payment));
})
.WithName("ProcessPayment");

payments.MapGet("/order/{orderId:guid}", async (Guid orderId, ClsPaymentDbContext db) =>
{
    var payment = await db.Payments.AsNoTracking()
        .Where(p => p.OrderId == orderId)
        .OrderByDescending(p => p.ProcessedAt)
        .FirstOrDefaultAsync();

    return payment is null ? Results.NotFound() : Results.Ok(ToDto(payment));
})
.WithName("GetPaymentByOrderId");

app.Run();

// Maps the payment entity to the API response contract.
static PaymentResultDto ToDto(ClsPayment p) => new(p.Id, p.OrderId, p.Amount, p.Status, p.TransactionId, p.ProcessedAt);

src/AspireShopping.PaymentService/Data/PaymentDbContext.cs

using AspireShopping.PaymentService.Models;
using Microsoft.EntityFrameworkCore;

namespace AspireShopping.PaymentService.Data;

public class ClsPaymentDbContext(DbContextOptions<ClsPaymentDbContext> options) : DbContext(options)
{
    public DbSet<ClsPayment> Payments => Set<ClsPayment>();

    /// <summary>Configures payment indexes and decimal precision.</summary>
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ClsPayment>(entity =>
        {
            entity.HasIndex(p => p.OrderId);
            entity.Property(p => p.Amount).HasColumnType("decimal(18,2)");
        });
    }
}

src/AspireShopping.PaymentService/Models/Payment.cs

using System.ComponentModel.DataAnnotations;
using AspireShopping.Contracts;

namespace AspireShopping.PaymentService.Models;

public class ClsPayment
{
    public int Id { get; set; }

    [Required]
    public Guid OrderId { get; set; }

    [Range(0.01, double.MaxValue)]
    public decimal Amount { get; set; }

    public PaymentStatus Status { get; set; } = PaymentStatus.Pending;

    [MaxLength(50)]
    public string? TransactionId { get; set; }

    [MaxLength(100)]
    public string CardholderName { get; set; } = string.Empty;

    [MaxLength(4)]
    public string CardNumberLast4 { get; set; } = string.Empty;

    [MaxLength(50)]
    public string PaymentMethod { get; set; } = string.Empty;

    public DateTimeOffset ProcessedAt { get; set; } = DateTimeOffset.UtcNow;
}

src/AspireShopping.PaymentService/appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

src/AspireShopping.PaymentService/appsettings.Development.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  }
}

Conceptual Explanation of Other Services

ProductService

ProductService owns the product catalog. Sellers add products with category and manufacturer selections. Customers search and view products. The service also handles stock reservation and release so checkout can be coordinated safely by OrderService. Redis is used for output caching to improve response time for product reads.

OrderService

OrderService owns the checkout process. It receives order requests, reserves stock from ProductService, processes payment through PaymentService, stores the order in ordersdb, and can generate an invoice PDF. It behaves like an orchestration service because it coordinates multiple backend operations.

Web Project

The Web project is the Blazor frontend. It provides pages for browsing products, login, registration, seller product management, cart, orders, and order details. The UI has been customized to use the name MS-Online Shop, Indian rupee currency, larger form inputs, and a page-wide light/dark theme option.

Contracts Project

The Contracts project contains request and response DTOs shared between services and frontend clients. This avoids duplicating API models in every project and helps keep the service contracts consistent.

ServiceDefaults Project

ServiceDefaults contains shared startup behavior used by the service projects, including health checks, telemetry, service discovery, and resilience-friendly defaults. This keeps repeated infrastructure code out of every service.

How the Request Flow Works

  1. The user opens the Blazor Web application.
  2. The Web project sends API requests to the API Gateway.
  3. The gateway forwards requests based on URL path.
  4. IdentityService creates users and returns JWT tokens.
  5. ProductService manages catalog and stock.
  6. OrderService coordinates checkout.
  7. PaymentService records approval or decline.
  8. SQL Server stores service-owned data and Redis improves product response performance.
Code for thois article can be downloaded from this link.

Run the application, the Aspire Dashboard shows result as follows:

Browse the Web App, the Blazor Application will shows Product Catalog as follows:


 Click on the Register to register User Either as Csutomer or Saler


Once the User as a customer is created, then the prodfuct can be purchased.

Conclusion

This application is a practical example of using .NET Aspire for a distributed shopping system. Aspire simplifies local orchestration, service discovery, configuration, and observability. The architecture keeps services focused: IdentityService handles users and JWT, ProductService handles catalog and stock, OrderService handles checkout, PaymentService handles payments, and ApiGateway provides a single entry point for the frontend.

Popular posts from this blog

Azure AI Building RAG Application Solution: Using Azure AI Search Service and Creating Data Source, Index, and Indexer

ASP.NET Core 8 API: How to implement REPR Pattern Endpoints in ASP.NET Core 8 API

ASP.NET Core 7: Using PostgreSQL to store Identity Information