Building distributed applications has historically been difficult because of the many disparate components in the distributed application stack, such as user interface components, services, and data stores that need to be managed, configured, and orchestrated. Thanks to the code-first development methodology of .NET Aspire, you can now build your entire application using this cloud-ready development stack within your familiar Visual Studio IDE environment.

This article examines .NET Aspire, its features and benefits, components, use cases, and then walks through the steps to create a shopping cart microservice application with .NET Aspire, implement distributed logging and tracing, and capture custom metrics before deploying the application to .NET Azure via the Azure Developer CLI (azd).

If you're going to work with the code examples discussed in this article, you need the following installed in your system:

  • Visual Studio 2026
  • .NET 10.0
  • ASP.NET 10.0 Runtime
  • Entity Framework Core
  • Azure CLI
  • Aspire CLI

If you don't already have Visual Studio 2026 installed in your computer, you can download it from here: https://visualstudio.microsoft.com/downloads/.

What Are We Building Here?

This article covers the core concepts of .NET Aspire—its features, benefits, and relevance to building and deploying distributed applications—along with the framework's project structure and integration model. From there, it walks through building a shopping cart microservice using .NET Aspire and EF Core, then concludes with guidance on deploying the application to Azure via the Azure Developer CLI and a discussion of best practices.

By the end of this article, you'll be able to build high-performing, scalable, and secure distributed applications using .NET Aspire and deploy them to Azure.

Understanding the Problem

Modern enterprise systems are no longer built as single monolithic binaries and deployed on a single server; instead, they are composed of a set of APIs and components, such as background services (worker processes), data storage (the database), the message broker that takes advantage of a queue to handle how messages are routed and delivered, and a front-end (user interface) to interact with these services.

In addition to offering compelling advantages, such as improved scalability and resilience, these distributed applications also pose challenges in service discovery, configuration, observability, and deployment. .NET Aspire addresses these challenges by providing you with an opinionated framework for building, deploying, orchestrating, monitoring, and observing distributed .NET applications.

What Is a Cloud-Native Application?

Cloud-native applications leverage the scalability, resilience, and automation capabilities of the cloud, enabling you to build, deploy, and run them faster. Cloud-native applications adhere to certain architectural principles that enable them to maximize performance, availability, and agility, as well as resilience, via container orchestrators, microservices, and auto-scaling. Unlike traditional monolithic applications, a cloud-native application is flexible and scalable—microservices can be built, deployed (often in containers), managed independently, and scaled as required.

These applications align with modern development standards and principles to meet ever-changing business needs. In addition, cloud-native applications leverage the cloud to help organizations reduce the total cost of infrastructure ownership in a rapidly changing marketplace.

Here are the key benefits of cloud-native applications:

  • Portability, flexibility, and fault tolerance
  • Improved resource utilization
  • Faster and seamless deployment
  • Automatic scaling
  • Enhanced cost efficiency

Introducing .NET Aspire

.NET Aspire is a modern, opinionated stack for building cloud-native, distributed, and observable .NET applications. It provides unified tooling, templates, packages, and libraries to simplify orchestration, configuration, and monitoring across multiple services and resources in a solution, making local and cloud deployment much easier and more consistent.

You can leverage .NET Aspire for building production-ready distributed apps, centered on a code-first application model called AppHost that defines services, resources, and their relationships. .NET Aspire enables seamless orchestration, local development, and deployment to Kubernetes, cloud platforms, or on-premises infrastructure.

When using .NET Aspire, you would not need to handle complex configuration files. Instead, you can define your services, databases, and dependencies in the code itself. .NET Aspire uses a code-first approach to define your application's architecture, providing support for type safety, version control, and refactoring.

Key Features

Here are the key features of .NET Aspire:

Orchestration

Orchestration is a process that helps connect application components, simplifies configuration, and reduces both the complexity and the time required to create an application. In a .NET Aspire solution, the AppHost is the project responsible for coordinating the services, containers, and resources that make up your distributed application, and for managing your application's configuration, dependencies, and observability. Additionally, if you're building a microservices-based application, this significantly reduces the time required to configure these services.

Service Discovery

In a distributed application, you'll often have a plethora of services talking to each other for exchanging data and information. Service Discovery automates the identification and location of services within a distributed application using a central registry, thereby eliminating the need for manually configured services and reducing human error associated with traditional configuration techniques. In a typical .NET Aspire application, the AppHost project is responsible for managing service discovery by dynamically injecting service-specific active configurations at runtime.

Unified Development Experience

.NET Aspire offers developers a streamlined way to build, run, debug, and deploy their distributed applications. With .NET Aspire, you can use a single unified toolset to eliminate complex configuration files and improve ease of use when debugging. It offers a unified suite of tools, templates, and integrations in a single, streamlined system allowing developers to focus on writing code rather than spending time on configuration management.

Resilience by Design

By design, applications built with .NET Aspire are resilient. They are designed to withstand outages and runtime failures due to retries, circuit breakers, and health checks built into all components of the application. The result is that your applications will continue to operate normally during periods of increased load or partial outages, ensuring business continuity and retaining the trust of your end users.

Aspire Dashboard

The Aspire dashboard is your application's graphical interface and management tool for tracking and managing your entire application. With system health, logging, tracing, and service dependency data displayed in an easily accessible interface, the Aspire dashboard enables users to quickly access real-time insights into their application and troubleshoot issues. By providing this data with a unified view, the Aspire dashboard helps users easily monitor their application in real time and receive alerts on operational performance metrics across all their connected services, enabling them to make informed decisions on performance tuning and capacity planning from the dashboard.

Cross-Platform Support

Because .NET Aspire is built on .NET, it can run on any operating system that supports .NET, including Windows, Linux, and macOS. As a result, if you're building an application using .NET Aspire, you would not have to be concerned about platform-specific dependencies or configurations since .NET Aspire applications run on any of these environments.

Integrations

Support for integrating pre-written code via NuGet packages helps establish and standardize the configuration and automatically injects the services into the application, enabling you to easily connect to and use them. When using .NET Aspire, you do not need to write custom code to connect to databases, messaging systems, caches, etc.

Instead, .NET Aspire comes with built-in integrations that make it easy to connect these services, thereby reducing development time and effort considerably. Essentially, each of these .NET Aspire integrations is available as a NuGet package. You can leverage the pre-built NuGet-based components for connecting to and working with Redis, SQL Server, PostgreSQL, Azure, and many more.

Tooling

.NET Aspire provides excellent tooling support, thereby streamlining application development and scaling through standardized project templates and components, such as AppHost and ServiceDefaults, configured at project creation. It comes with a collection of several project templates and tooling experiences for you to use in Visual Studio and VS Code, and even in the .NET CLI to help you create .NET Aspire projects from scratch or add .NET Aspire capabilities into your existing application.

Observability

By adding observability into the development process rather than later in the lifecycle, the development team will have visibility into their application almost immediately. As a result, this will improve debugging, increase reliability, and help with better architectural decisions throughout the entire application development lifecycle.

Each application built on the .NET Aspire platform will have built-in structured logging, metrics, and distributed tracing using OpenTelemetry, providing real-time visibility through the integrated Aspire dashboard. With this capability, you can identify and resolve performance bottlenecks early and optimize your cloud usage costs using data-driven insights.

Figure 1 shows a typical cloud-native .NET Aspire application.

Figure 1: A .NET Aspire Application
Figure 1: A .NET Aspire Application

Best Practices

Below are the key points you should adhere to when building your distributed applications with .NET Aspire.

  • Keep it simple. Should start with a very basic application and only build on top of it when necessary.
  • Use the patterns and follow the conventions. Use the patterns (i.e., treat the YourAppName.ServiceDefaults project as a pattern) and conventions (such as telemetry, health, service discovery, etc.) so that you can get the most out of .NET Aspire.
  • Test your application locally first. Use the Aspire dashboard to test your application before you deploy it.
  • Enforce your security policies. Use Key Vault, Managed Identities, and RBAC so that your application stays secure.
  • Monitor your application's performance. Continuously monitor how your application is performing in real-time using logging, metrics, and tracing.
  • Design for scalability. Design your applications to be stateless and scalable, which will help to handle more real-time traffic in the long term.

Comparing the .NET Aspire Application and the .NET Aspire Starter Application

When starting to create a new .NET Aspire project in Visual Studio, you will see that there are two types of project templates, i.e., the .NET Aspire Application and the .NET Aspire Starter Application templates. While the former is the fully-featured version, including everything needed to build production-level applications with complete orchestration and integration capabilities, the latter is a lightweight version, typically used to create a simplified .NET Aspire application with fewer projects and configurations.

The .NET Aspire Application project template is intended for building full-fledged cloud-native applications. When you create a new .NET Aspire Application, you'll observe four projects created for you: the ApiService, AppHost, ServiceDefaults, and Web projects. The .NET Aspire Starter Application template is typically intended for developers new to .NET Aspire who want to learn the fundamentals of designing .NET Aspire applications before moving on to creating complete, production-grade, full-scale applications.

Typical Use Cases of .NET Aspire

Although .NET Aspire is designed to streamline the development, deployment, and orchestration of distributed .NET applications, you should be aware of where it should be used and where it should not.

When to Use .NET Aspire

.NET Aspire is a good choice for multi-service, cloud-based applications. For example, assume you have an application comprising three services working together: an API service, a back-end service, and a front-end application. Additionally, all these services may share a single database and a cache.

If you're not using .NET Aspire, you may have to define how the three services are configured, i.e., create separate configurations for each service. If you're using .NET Aspire, you can define relationships between components within the application host, and the infrastructure will manage their configuration.

Another scenario where .NET Aspire is a good fit is when you want to leverage logging, tracing, and metrics across service definitions. In this case, .NET Aspire leverages best practices to provide you with a unified dashboard view of logs, traces, and metrics. Most importantly, .NET Aspire will eliminate much of the glue code and configuration that you generate and maintain.

When Not to Use .NET Aspire

Although .NET Aspire offers several benefits, in some cases using it can complicate your application rather than simplify it. For example, if you are creating a simple application (i.e., a Web API that has only one database), you are likely better off building your application using the standard ASP.NET Core project template. This is because these applications are often created as proof-of-concept, as a learning app, or other throwaway experimentation applications that do not require the additional structure provided by .NET Aspire. Using .NET Aspire in these applications will be overkill.

Using Integrations in .NET Aspire

In .NET Aspire, integrations are used to attach additional features such as health checks, logging, caching, etc. These integrations are available in .NET Aspire as pre-built NuGet packages. You can use integrations in your preferred IDE such as Visual Studio or Visual Studio Code, or via the .NET CLI. The following code snippet shows how you can integrate Redis cache into your .NET Aspire application.

dotnet add package Aspire.StackExchange.Redis

Note that the integration you've used must be registered in the AppHost project in your application as shown in the following piece of code.

builder.AddProject<ShoppingCart_ApplicationApiService>(
    "shoppingcart-api"
)
.WithReference(cache);

Add Aspire to an API Project in Visual Studio

You can create a Web API project and add .NET Aspire to it later. If you want to add .NET Aspire to a project in Visual Studio, you should follow the steps outlined in this section.

  1. You should install the .NET Aspire project templates by executing the following command:

Please provide the text you'd like me to analyze and reformat.

dotnet new install Aspire.ProjectTemplates

Please provide the text you'd like me to analyze and reformat.

  1. Next, install the Aspire CLI using the following command:
dotnet tool install --global aspire.cli

Please provide the text you'd like me to analyze and reformat.

  1. Now, select the project in the Solution Explorer Window.
  2. Finally, click Add.NET Aspire Orchestration Support...

**Getting Started with .NET Aspire in Visual Studio

You can create a project in Visual Studio 2026 in several ways, such as from the Visual Studio 2026 Developer Command Prompt or by launching the Visual Studio 2026 IDE. When you launch Visual Studio 2026, you'll see the Start window. You can choose “Continue without code” to launch the main screen of the Visual Studio 2026 IDE.

Now that you know the basics, let's start setting up the project. To create a new ASP.NET Core 10 Project in Visual Studio 2026:

  1. Start the Visual Studio 2026 IDE.
  2. In the Create a new project window, select Aspire Starter App from the list of the project templates displayed. Click Next to move on. Refer to Figure 2.
Figure 2: Creating a new project in .NET Aspire
Figure 2: Creating a new project in .NET Aspire
  1. Specify the project name as OMS and the path where it should be created in the Configure your new project window.
  2. If you want the solution file and project to be created in the same directory; you can optionally check the Place solution and project in the same directory checkbox. Click Next to move on.
  3. In the next screen, specify the target framework. Ensure that the “Configure for HTTPS” is checked while the checkbox “Use Redis for caching…” is unchecked because you won't use Redis in this example.
  4. Click Create to complete the process.

This creates a new .NET Aspire application in Visual Studio. At first glance, the Solution Explorer of this application looks like Figure 3.

Figure 3: The Solution Explorer Window ShoppingCartApplication
Figure 3: The Solution Explorer Window ShoppingCartApplication

To add .NET Aspire to your project in Visual Studio Code, you should run the following command at the terminal:

dotnet new aspire-host -o ShoppingCartApplication.AppHost

Understanding the .NET Aspire Project Structure

When you create a new .NET Aspire application in Visual Studio, the following projects are automatically created in it:

  • ApiService: A component that represents the back-end API provider, which handles tasks such as data access, business logic, and communication between the web application in the presentation layer and the database.
  • AppHost: Within a .NET Aspire application, this component represents an orchestrator that defines the architecture and dependencies of the project. It is responsible for coordinating project execution, managing dependencies, configuration, coordinating project execution and facilitating the integration between different components in the application.
  • ServiceDefaults: This component enables you to configure dependencies, facilitate better scalability, and easier maintenance of your application.
  • Web: This component takes advantage of Blazor to provide a responsive user interface, handling user interactions, and display data retrieved from back-end services.

By default, the AppHost is the starter application, i.e., when the application starts, the AppHost will be executed. Figure 4 shows how the .NET Aspire application looks in the web browser when you execute it.

Figure 4: The .NET Aspire application in execution
Figure 4: The .NET Aspire application in execution

Implementing a Real-life Application Using .NET Aspire and Deploying it in Azure

In this section, we'll implement a microservices-based application using .NET Aspire and deploy it in Azure. The primary objective of this application is to demonstrate how we can simplify the creation, deployment, and management of distributed .NET applications built using .NET Aspire, EF Core and C#. .NET Aspire provides a unified, code-first framework for defining services, resources, and configurations while centralizing orchestration, configuration, and observability into a single, streamlined model.

By building the application with .NET Aspire and deploying it to Azure, we can create consistent, observable, and resilient microservice-based distributed apps. Together, they make building cloud-native .NET systems faster, easier, and more reliable. Figure 5 demonstrates a typical distributed application deployed in Azure.

Figure 5: Demonstrating a Microservices-based application deployed in Azure
Figure 5: Demonstrating a Microservices-based application deployed in Azure

For the sake of simplicity, we'll implement only the Carts API in this example. To build this application, we'll follow the steps outlined below:

  1. Create the shopping cart database
  2. Create an empty .NET Aspire application
  3. Create the ShoppingCartSystem database
  4. Create an ASP.NET Core Web API application (backend)
  5. Create a Blazor application (frontend)
  6. Create the Cart microservice
  7. Specify the database connection string
  8. Install EntityFrameworkCore
  9. Create the model classes
  10. Create the data transfer objects
  11. Create the dataContext
  12. Create the cart repository
  13. Create the cart controller

Create the Shopping Cart System Database

Create a new database called ShoppingCartSystem using the following script:

CREATE DATABASE ShoppingCartSystem;

Next, create the Product, Customer, Order, OrderItem, and the Cart database tables inside the ShoppingCartSystem database using the database script given in Listing 1.

Listing 1: The database script of the ShoppingCart database

CREATE TABLE Customer (
    Customer_Id INT IDENTITY(1,1) PRIMARY KEY,
    FirstName NVARCHAR(100) NOT NULL,
    LastName NVARCHAR(100) NOT NULL,
    Email NVARCHAR(255) NOT NULL,
    Phone NVARCHAR(50) NULL,
    CreatedAt DATETIME2 NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE Product (
    Product_Id INT IDENTITY(1,1) PRIMARY KEY,
    ProductName NVARCHAR(200) NOT NULL,
    Price DECIMAL(18,2) NOT NULL,
    Category NVARCHAR(50) NULL,
    Sku NVARCHAR(50) NULL,
    StockQuantity INT NOT NULL,
    IsActive BIT NOT NULL,
    CreatedAt DATETIME2 NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE Cart (
    Cart_Id INT IDENTITY(1,1) PRIMARY KEY,
    Customer_Id INT NULL,
    CreatedAt DATETIME2 NOT NULL DEFAULT CURRENT_TIMESTAMP,
    LastUpdatedAt DATETIME2 NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT UQ_Cart_Customer UNIQUE (Customer_Id),
    CONSTRAINT FK_Cart_Customer FOREIGN KEY (Customer_Id)
        REFERENCES Customer(Customer_Id)
);

CREATE TABLE CartItem (
    CartItem_Id INT IDENTITY(1,1) PRIMARY KEY,
    Cart_Id INT NOT NULL,
    Product_Id INT NOT NULL,
    Quantity INT NOT NULL,
    UnitPrice DECIMAL(18,2) NOT NULL,
    AddedAt DATETIME2 NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT UQ_CartItem_Cart_Product UNIQUE (Cart_Id, Product_Id),
    CONSTRAINT FK_CartItem_Cart FOREIGN KEY (Cart_Id)
        REFERENCES Cart(Cart_Id) ON DELETE CASCADE,
    CONSTRAINT FK_CartItem_Product FOREIGN KEY (Product_Id)
        REFERENCES Product(Product_Id)
);

CREATE TABLE [Order] (
    Order_Id INT IDENTITY(1,1) PRIMARY KEY,
    OrderNumber NVARCHAR(50) NOT NULL UNIQUE,
    Customer_Id INT NOT NULL,
    OrderDate DATETIME2 NOT NULL DEFAULT CURRENT_TIMESTAMP,
    OrderStatus NVARCHAR(50) NOT NULL,
    TaxAmount DECIMAL(18,2) NOT NULL,
    ShippingAmount DECIMAL(18,2) NOT NULL,
    TotalAmount DECIMAL(18,2) NOT NULL,
    ShippingAddress NVARCHAR(500) NULL,
    BillingAddress NVARCHAR(500) NULL,
    CreatedAt DATETIME2 NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT FK_Order_Customer FOREIGN KEY (Customer_Id)
        REFERENCES Customer(Customer_Id)
);

CREATE TABLE OrderItem (
    OrderItem_Id INT IDENTITY(1,1) PRIMARY KEY,
    Order_Id INT NOT NULL,
    Product_Id INT NOT NULL,
    UnitPrice DECIMAL(18,2) NOT NULL,
    Quantity INT NOT NULL,
    CONSTRAINT FK_OrderItem_Order FOREIGN KEY (Order_Id)
        REFERENCES [Order](Order_Id) ON DELETE CASCADE,
    CONSTRAINT FK_OrderItem_Product FOREIGN KEY (Product_Id)
        REFERENCES Product(Product_Id)
);

CREATE TABLE Payment (
    Payment_Id INT IDENTITY(1,1) PRIMARY KEY,
    Order_Id INT NOT NULL,
    Amount DECIMAL(18,2) NOT NULL,
    PaymentMethod NVARCHAR(50) NOT NULL,
    PaymentStatus NVARCHAR(50) NOT NULL,
    PaymentDate DATETIME2 NOT NULL,
    CreatedAt DATETIME2 NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT FK_Payment_Order FOREIGN KEY (Order_Id)
        REFERENCES [Order](Order_Id)
);

Create an ASP.NET Core Web API Application (Backend)

When you launch Visual Studio 2026, you'll see the Start window. You can choose “Continue without code” to launch the main screen of the Visual Studio 2026 IDE.

To create a new ASP.NET Core 10 project:

  1. Start the Visual Studio 2026 IDE.
  2. In the Create a new project window, select ASP.NET Core Web API and click Next to move on.
  3. Specify the project name as ShoppingCartApplication.ApiService and the path where it should be created in the Configure your new project window. You can also specify the solution name here.
  4. If you want the solution file and project to be created in the same directory, you can optionally check the Place solution and project in the same directory checkbox. Click Next to move on.
  5. In the next screen, specify the target framework and authentication type as well. Ensure that the Configure for HTTPS checkbox is checked while the Enable Docker Support checkbox is unchecked because you won't use containerization in this example.
  6. Ensure that the Enable OpenAPI support and Use controllers checkboxes are checked.
  7. Leave the Do not use top-level statements checkbox unchecked.
  8. Click Create to complete the process.

Since you'll be deploying this application to Azure, you'll need an Azure subscription. If you don't already have one, you can get it here: https://portal.azure.com

Create a Blazor Application (Frontend)

To create a new ASP.NET Core Blazor Project in Visual Studio 2026:

  1. Start the Visual Studio 2026 IDE.
  2. In the Create a new project window, select BlazorWebApp (Figure 6) and click Next to move on.
Figure 6: Create a new Blazor Web App in Visual Studio
Figure 6: Create a new Blazor Web App in Visual Studio
  1. Specify the project name as ShoppingCartApplication.Web and the path where it should be created in the Configure your new project window. You can also specify the solution name for your project here.
  2. If you want the solution file and project to be created in the same directory, you can optionally check the Place solution and project in the same directory checkbox. Click Next to move on.
  3. Specify the target framework and authentication type. Ensure that the Configure for HTTPS checkbox is checked and the Do not use top-level statements checkbox is unchecked because you won't use it in this example.
  4. If you want the solution file and project to be created in the same directory, you can optionally check the Place solution and project in the same directory checkbox.
  5. Click Next to move on.
  6. Click Create to complete the process.

Once you've added the Web API and Blazor applications to your solution, the Program.cs file of the AppHost project will be updated with the following code automatically to connect the ASP.NET Core Blazor Web Application and the ASP.NET Core Web API projects as shown in the code snippet given below:

Please provide the text you'd like me to analyze and reformat.

var builder = DistributedApplication.CreateBuilder(args);

builder.AddProject<ShoppingCart_ApplicationApiService>("shoppingcart-applicationapiservice");
builder.AddProject<ShoppingCartApplication_Web>("shoppingcartapplication-web");

builder.Build().Run();

Figure 7 shows what the Solution Explorer window will look like now.

Figure 7: The Solution Explorer window showing all four projects
Figure 7: The Solution Explorer window showing all four projects

Running the Application Locally

When you execute the AppHost project locally on your computer, it will load all the dependencies, and you can see the Aspire dashboard as shown in Figure 8.

Figure 8: Demonstrating the Aspire dashboard
Figure 8: Demonstrating the Aspire dashboard

If the .NET Aspire project templates are missing from the Visual Studio IDE on your computer, you can install them manually by executing the following command at the terminal:

dotnet new install 
Aspire.ProjectTemplates –force

Explore the Aspire Dashboard

The Aspire dashboard is a web-based tool that provides a real-time, comprehensive overview of your projects' status, dependencies, and performance metrics, that is, metrics, traces, and resource usage information. By providing you with a unified view of your application, the Aspire dashboard can help you to quickly identify issues and optimize your development workflow. It is launched in the web browser, along with other projects in the solution, when you start the AppHost project. In this example, the ShoppingCart.ApplicationApiService and the ShoppingCartApplication.Web projects will be automatically launched when you invoke the AppHost project in your solution.

The information presented in the Aspire dashboard can be organized into the following sections:

  • Resources: This section contains important information about every .NET project in your .NET Aspire configuration. It enables you to view the status of your application, the endpoint addresses used by your application, and the environmental variables loaded by your application.
  • Console: This section enables you to display the textual output sent to the console, which can be used for tracking events and displaying status messages.
  • Structured: In this section, the content is organized into a tabular format, enabling you to filter and search the content, and expand the log details if need be.
  • Traces: This section helps you to trace the complete paths of all requests sent to your application starting from the origin to the endpoint that produces the response. You can take advantage of this feature to determine how long it takes to serve requests sent to your application.
  • Metrics: This section contains instrumentation and metering for your application and provides optional filters based on the available dimensions of the reported metrics.

Create the Cart Microservice

In this example, we will build the Cart microservice application or the Cart API. The Cart microservice application will consist of the following files:

  • Cart.cs: This represents the cart model that contains domain-specific data and (optionally) business logic.
  • ICartRepository.cs: This represents the ICartRepository interface that contains the declaration of the operations supported by the cart repository.
  • CartRepository.cs: This represents the product repository class that implements the members of the ICartRepository interface.
  • CartDbContext.cs: This represents the cart data context used to perform CRUD operations for the Cart database table.
  • appsettings.json: This represents the application's settings file where you can configure the database connection string, logging metadata, etc.
  • Program.cs: This file contains the startup code, dependency injection configuration settings for the services used in the application, middleware used by the application, etc.

When you create an ASP.NET Core application, a file named Program.cs is automatically created that contains the startup code required by the application. This is where the services required by your application are configured. You can specify dependency injection (DI), configuration, middleware, and much more information in this file.

Install Entity Framework Core

So far, so good. The next step is to install the necessary NuGet package(s) for working with Entity Framework Core and SQL Server. To install these packages into your project, right-click on the solution and then select Manage NuGet Packages for Solution….

Once the window pops up, search for the NuGet packages to add to your project. To do this, type in Microsoft.EntityFrameworkCore, Microsoft.EntityFrameworkCore.Design, Microsoft.EntityFrameworkCore.Tools, and Microsoft.EntityFrameworkCore.SqlServer in the search box and install them one after the other. Alternatively, you can type the commands shown below at the NuGet Package Manager command prompt:

PM> Install-Package Microsoft.EntityFrameworkCore
PM> Install-Package Microsoft.EntityFrameworkCore.Design
PM> Install-Package Microsoft.EntityFrameworkCore.Tools
PM> Install-Package Microsoft.EntityFrameworkCore.SqlServer

Alternatively, you can install these packages by executing the following commands at the Windows shell:

dotnet add package Microsoft.EntityFrameworkCore

dotnet add package Microsoft.EntityFrameworkCore.Design

dotnet add package Microsoft.EntityFrameworkCore.Tools

dotnet add package Microsoft.EntityFrameworkCore.SqlServer

If you don't have .NET Aspire project templates installed on your computer, you can install them using the following command:

dotnet new install Aspire.ProjectTemplates –force

Create the Model Classes

First, create two solution folders—one of them named Models and the other DataAccess. While the former would contain one or more model classes, the latter will have the data context and repository interfaces and classes. Note that you can always create multiple data context classes in the same project. If your data context class contains many entity references, it is a good practice to split the data context amongst multiple data context classes rather than have one large data context class.

We'll create the following model classes in this example:

  • Customer: The Customer model represents the actual user of the application, that is, the buyer who purchases the items added to the cart and makes payment using any of the supported payment methods.
  • Product: The Product model represents an item that can be added to the cart and can be purchased.
  • Cart: The Cart model represents an in-progress shopping basket that contains one or more items added by the customer.
  • CartItem: The CartItem model represents a single line item within a cart.

Create new files named Customer.cs, Product.cs, Cart.cs, and CartItem.cs with each of them corresponding to the respective model classes inside the Models folder and add code like that shown in Listing 2.

Listing 2: The model classes

public class Customer
{
    public int Customer_Id { get; set; }
    public string FirstName { get; set; } = default!;
    public string LastName { get; set; } = default!;
    public string Email { get; set; } = default!;
    public string? Phone { get; set; }
    public DateTime CreatedAt { get; set; }

    public ICollection<Cart> Carts { get; set; } = new List<Cart>();
}

public class Product
{
    public int Product_Id { get; set; }
    public string ProductName { get; set; } = default!;
    public decimal Price { get; set; }
    public string? Category { get; set; }
    public string? Sku { get; set; }
    public int StockQuantity { get; set; }
    public bool IsActive { get; set; }
    public DateTime CreatedAt { get; set; }

    public ICollection<CartItem> CartItems { get; set; } = new List<CartItem>();
}

public class Cart
{
    public int Cart_Id { get; set; }
    public int? Customer_Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime LastUpdatedAt { get; set; }

    public Customer? Customer { get; set; }
    public ICollection<CartItem> CartItems { get; set; } = new List<CartItem>();
}

public class CartItem
{
    public int CartItem_Id { get; set; }
    public int Cart_Id { get; set; }
    public int Product_Id { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    public DateTime AddedAt { get; set; }
    public Cart Cart { get; set; } = default!;
    public Product Product { get; set; } = default!;
}

Create the Data Transfer Objects

A data transfer object (DTO) is a lightweight data structure used to pass data amongst various layers or components of an application. It contains only data—there is no business logic or validation logic, and you cannot have any complex relationships in it either. You should use DTOs for data transport (i.e., transfer data across API boundaries, external communications) and domain models for writing your business logic, validation, rules, etc.

In this example, we'll be using the following DTOs:

  • CartDto: This represents a complete shopping cart view with all items added by the customer.
  • CartItemDto: This represents a single line item in the shopping cart.

The following code snippet illustrates the CartDto and CartItemDto types.

public record CartItemDto(
    int CartItem_Id,
    int Product_Id,
    string ProductName,
    int Quantity,
    decimal UnitPrice,
    decimal LineTotal
);

public record CartDto(
    int Cart_Id,
    int? Customer_Id,
    DateTime CreatedAt,
    DateTime LastUpdatedAt,
    IReadOnlyCollection<CartItemDto> Items,
    decimal Total
);

Create the Data Context

In Entity Framework Core (EF Core), a data context is a component used by an application to interact with the database and manage database connections, and to query and persist data in the database. Let us now create the data context class to enable our application to interact with the database to perform CRUD operations.

To do this, create a new class named CartDbContext that extends the DbContext class of EF Core and write the following code in there.

public class CartDbContext : DbContext
{
    public CartDbContext(DbContextOptions<CartDbContext> options) 
        : base(options) 
    { 
    }

    public DbSet<Customer> Customer => Set<Customer>();
    public DbSet<Product> Product => Set<Product>();
    public DbSet<Cart> Cart => Set<Cart>();
    public DbSet<CartItem> CartItem => Set<CartItem>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Not yet implemented
    }
}

You can specify your database connection string in the OnConfiguring overloaded method of the CartDbContext class. The following code shows how you can override the OnConfiguring method in your data context class to connect to the database.

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseSqlServer("YourConnectionString");
    base.OnConfiguring(optionsBuilder);
}

You can also specify the connection string appsettings.json file and read it in the Program.cs file to establish a database connection as shown in the code snippet given below:

builder.Services.AddDbContext<CartDbContext>(
    options => options.UseSqlServer(
        builder.Configuration.GetConnectionString("DefaultConnection")
    )
);

In this implementation we will store the database connection settings in the appsettings.json file and read it in the Program.cs file to establish a database connection. It should be noted that if the connection string has already been specified in the Program.cs file, you typically do not need to specify the OnConfiguring method in your data context class. However, you can still choose to implement this method to add additional configuration logging, command timeout, lazy-loading, etc., on top of what the built-in DI container provides you.

Note that your custom data context class (the CartDbContext class in this example), must expose a public constructor that accepts an instance of type DbContextOptions<CartDbContext> as an argument. This is needed to enable the runtime to pass the context configuration using a call to the AddDbContext() method to your custom DbContext class. The following code snippet illustrates how you can define a public constructor for your data context class.

public CartDbContext
{
    public CartDbContext(DbContextOptions<CartDbContext> options, IConfiguration configuration)
        : base(options)
    {
        _configuration = configuration;
    }
}

In this example, we've not used a service class because the CRUD operations in this example are simple with straightforward business rules. Additionally, we've a thin controller layer in this example—we're not using any complex validations or authentication/authorization rules, nor cross-cutting concerns such as caching, error handling, tracing, or logging.

Listing 3 shows the complete source code of the CartDbContext class.

Listing 3: The CartDbContext class

public class CartDbContext : DbContext
{
    public CartDbContext(DbContextOptions<CartDbContext> options) : base(options)
    {
    }

    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Cart> Carts => Set<Cart>();
    public DbSet<CartItem> CartItems => Set<CartItem>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        ConfigureCustomer(modelBuilder.Entity<Customer>());
        ConfigureProduct(modelBuilder.Entity<Product>());
        ConfigureCart(modelBuilder.Entity<Cart>());
        ConfigureCartItem(modelBuilder.Entity<CartItem>());
        SeedProducts(modelBuilder);
        base.OnModelCreating(modelBuilder);
    }

    private static void ConfigureCustomer(EntityTypeBuilder<Customer> entity)
    {
        entity.ToTable("Customer");
        entity.HasKey(e => e.Customer_Id);
        entity.Property(e => e.FirstName)
            .HasMaxLength(100)
            .IsRequired();
        entity.Property(e => e.LastName)
            .HasMaxLength(100)
            .IsRequired();
        entity.Property(e => e.Email)
            .HasMaxLength(255)
            .IsRequired();
        entity.Property(e => e.Phone)
            .HasMaxLength(50);
        entity.Property(e => e.CreatedAt)
            .HasDefaultValueSql("CURRENT_TIMESTAMP");
    }

    private static void ConfigureProduct(EntityTypeBuilder<Product> entity)
    {
        entity.ToTable("Product");
        entity.HasKey(e => e.Product_Id);
        entity.Property(e => e.ProductName)
            .HasMaxLength(200)
            .IsRequired();
        entity.Property(e => e.Category)
            .HasMaxLength(50);
        entity.Property(e => e.Sku)
            .HasMaxLength(50);
        entity.Property(e => e.Price)
            .HasColumnType("decimal(18,2)");
        entity.Property(e => e.CreatedAt)
            .HasDefaultValueSql("CURRENT_TIMESTAMP");
        entity.Property(e => e.IsActive)
            .HasDefaultValue(true);
    }

    private static void ConfigureCart(EntityTypeBuilder<Cart> entity)
    {
        entity.ToTable("Cart");
        entity.HasKey(e => e.Cart_Id);
        entity.Property(e => e.CreatedAt)
            .HasDefaultValueSql("CURRENT_TIMESTAMP");
        entity.Property(e => e.LastUpdatedAt)
            .HasDefaultValueSql("CURRENT_TIMESTAMP");
        entity.HasOne(e => e.Customer)
            .WithMany(c => c.Carts)
            .HasForeignKey(e => e.Customer_Id);
        entity.HasIndex(e => e.Customer_Id)
            .IsUnique()
            .HasFilter("[Customer_Id] IS NOT NULL");
    }

    private static void ConfigureCartItem(EntityTypeBuilder<CartItem> entity)
    {
        entity.ToTable("CartItem");
        entity.HasKey(e => e.CartItem_Id);
        entity.Property(e => e.UnitPrice)
            .HasColumnType("decimal(18,2)");
        entity.Property(e => e.AddedAt)
            .HasDefaultValueSql("CURRENT_TIMESTAMP");
        entity.HasOne(e => e.Cart)
            .WithMany(c => c.CartItems)
            .HasForeignKey(e => e.Cart_Id)
            .OnDelete(DeleteBehavior.Cascade);
        entity.HasOne(e => e.Product)
            .WithMany(p => p.CartItems)
            .HasForeignKey(e => e.Product_Id);
        entity.HasIndex(e => new { e.Cart_Id, e.Product_Id })
            .IsUnique();
    }
}

Create the Cart Repository

The cart repository will comprise two types:

  • ICartRepository: This interface represents the contract for cart operations without exposing the implementation details.
  • CartRepository: This class represents the concrete implementation of the cart repository contract.

The following code snippet shows how the operations in the cart repository contract are declared:

public interface ICartRepository
{
    Task<CartDto?> GetCartByCustomerAsync(
        int customerId,
        CancellationToken ct = default
    );

    Task<CartDto?> GetCartByIdAsync(
        int cartId,
        CancellationToken ct = default
    );

    Task<CartDto> AddItemAsync(
        int customerId,
        int productId,
        int quantity,
        CancellationToken ct = default
    );

    Task<CartDto?> RemoveItemAsync(
        int customerId,
        int productId,
        CancellationToken ct = default
    );

    Task<CartDto?> UpdateQuantityAsync(
        int customerId,
        int productId,
        int quantity,
        CancellationToken ct = default
    );

    Task<bool> ClearCartAsync(
        int customerId,
        CancellationToken ct = default
    );
}

Listing 4 shows the complete CartRepository class.

Listing 4: The CartRepository class

public class CartRepository : ICartRepository
{ 
    private readonly CartDbContext _db;  
    public CartRepository 
    (CartDbContext db) => _db = db;  
    public async Task<CartDto?>  
    GetCartByCustomerAsync(int customerId, 
    CancellationToken ct = default) 
    { 
        var cart = await _db.Cart 
            .Include(c => c.CartItems) 
                .ThenInclude(ci => ci.Product) 
            .FirstOrDefaultAsync 
            (c => c.Customer_Id == customerId, ct); 
        return cart is null ? null : MapCart(cart); 
    } 
    public async Task<CartDto?> GetCartByIdAsync(int cartId,  
       CancellationToken ct = default) 
    { 
        var cart = await _db.Cart 
            .Include(c => c.CartItems) 
            .ThenInclude(ci => ci.Product) 
            .FirstOrDefaultAsync 
            (c => c.Cart_Id == cartId, ct);  
        return cart is null ?  
        null : MapCart(cart); 
    }  
    public async Task<CartDto>  
    AddItemAsync(int customerId,  
    int productId, int quantity,  
    CancellationToken ct = default) 
    { 
        if (quantity <= 0) throw new  
           ArgumentOutOfRangeException(nameof(quantity));  
        var product =  
           await _db.Product.FirstOrDefaultAsync 
             (p => p.Product_Id ==  
             productId && p.IsActive, ct)  
             ?? throw new InvalidOperationException 
             ("Product not found or inactive."); 
 
        var cart = await _db.Cart 
            .Include(c => c.CartItems) 
            .FirstOrDefaultAsync 
            (c => c.Customer_Id == customerId, ct); 
 
        if (cart is null) 
        { 
            cart = new Cart 
            { 
                Customer_Id = customerId, 
                CreatedAt = DateTime.UtcNow, 
                LastUpdatedAt = DateTime.UtcNow 
            }; 
            _db.Cart.Add(cart); 
        }  
        var existingItem = cart.CartItems.FirstOrDefault( 
           ci => ci.Product_Id == productId);  
        if (existingItem is null) 
        { 
            cart.CartItems.Add(new CartItem 
            { 
                Product_Id = productId, 
                Quantity = quantity, 
                UnitPrice = product.Price, 
                AddedAt = DateTime.UtcNow 
            }); 
        } 
        else 
        { 
            existingItem.Quantity += quantity; 
            existingItem.UnitPrice = product.Price; 
        }  
        cart.LastUpdatedAt = DateTime.UtcNow;  
        await _db.SaveChangesAsync(ct); 
        await _db.Entry(cart).Collection 
           (c => c.CartItems).Query() 
           .Include(ci => ci.Product).LoadAsync(ct);
        return MapCart(cart); 
    } 
    public async Task<CartDto?>  
       RemoveItemAsync(int customerId,  
       int productId, CancellationToken ct = default) 
    { 
        var cart = await _db.Cart 
            .Include(c => c.CartItems) 
            .ThenInclude(ci => ci.Product) 
            .FirstOrDefaultAsync 
            (c => c.Customer_Id == customerId, ct); 
        if (cart is null) return null; 
        var item = cart.CartItems.FirstOrDefault 
           (ci => ci.Product_Id == productId); 
        if (item is null) return MapCart(cart); 
 
        _db.CartItem.Remove(item); 
        cart.LastUpdatedAt = DateTime.UtcNow; 
        await _db.SaveChangesAsync(ct); 
        return MapCart(cart); 
    } 
    public async Task<CartDto?>  
       UpdateQuantityAsync(int customerId,  
       int productId, int quantity,  
       CancellationToken ct = default) 
    { 
        if (quantity < 0)  
           throw new ArgumentOutOfRangeException 
           (nameof(quantity)); 
        var cart = await _db.Cart 
            .Include(c => c.CartItems) 
            .ThenInclude(ci => ci.Product) 
            .FirstOrDefaultAsync 
            (c => c.Customer_Id == customerId, ct); 
 
        if (cart is null) return null; 
        var item = cart.CartItems.FirstOrDefault 
           (ci => ci.Product_Id == productId); 
        if (item is null) return MapCart(cart); 
        if (quantity == 0) 
        { 
            _db.CartItem.Remove(item); 
        } 
        else 
        { 
            item.Quantity = quantity; 
        } 
 
        cart.LastUpdatedAt = DateTime.UtcNow; 
        await _db.SaveChangesAsync(ct); 
        return MapCart(cart); 
    } 
 
    public async Task<bool> ClearCartAsync 
       (int customerId, CancellationToken ct = default) 
    { 
        var cart = await _db.Cart 
            .Include(c => c.CartItems) 
            .FirstOrDefaultAsync 
            (c => c.Customer_Id == customerId, ct); 
 
        if (cart is null) return false; 
        _db.CartItem.RemoveRange(cart.CartItems); 
        cart.LastUpdatedAt = DateTime.UtcNow; 
        await _db.SaveChangesAsync(ct); 
        return true; 
    } 
     private static CartDto MapCart(Cart cart) 
    { 
        var items = cart.CartItems 
            .Select(ci => new CartItemDto( 
                ci.CartItem_Id, 
                ci.Product_Id, 
                ci.Product.ProductName, 
                ci.Quantity, 
                ci.UnitPrice, 
                ci.UnitPrice * ci.Quantity)) 
            .ToList(); 
        var total = items.Sum(i => i.LineTotal); 
        return new CartDto( 
            cart.Cart_Id, 
            cart.Customer_Id, 
            cart.CreatedAt, 
            cart.LastUpdatedAt, 
            items, 
            total); 
    } 
}

Create the Cart Controller

What Is Observability and Why Does it Matter?

Identifying issues in distributed systems, such as those in a microservices-based application, is challenging because a single request can flow through multiple services, and any of those services can fail. Observability provides insight into an application's state by capturing and displaying its internal state, through logging metrics and tracing. Observability can improve operational visibility by capturing data and proactively observing distributed systems to identify relevant data based on how an application performs in a production environment over time. Distributed tracing provides a time-stamped view of each service a request passes through and identifies errors along the way and provides other related information.

OpenTelemetry is an open-source distributed tracing framework for collecting, storing, and analyzing telemetry data (metrics, logs, and traces). It provides a high-level API for distributed tracing and metrics that can be used by applications in different environments, including monoliths, microservices and serverless applications. You can take advantage of OpenTelemetry to automate distributed tracing by capturing traces, metrics, and log entries, and then presenting the information through the Aspire dashboard. Additionally, you can use Azure Application Insights in your .NET Aspire application to extend your application's capabilities for monitoring, performance improvement, and debugging in an Azure environment.

You can learn more on observability and how you can work with it in .NET applications from this article: https://www.codemag.com/Article/2301061/An-Introduction-to-Distributed-Tracing-with-OpenTelemetry-in-.NET-7

Configure Observability in .NET Aspire

.NET Aspire provides built-in support for observability. You can enable this support in your AppHost project using the AddOpenTelemetry() method as shown in the code snippet below:

var builder = DistributedApplication.CreateBuilder(args);

// Add services
var apiService = builder.AddProject<ShoppingCart_ApplicationApiService>("shoppingcart-api");
var webService = builder.AddProject<ShoppingCartApplication_Web>("shoppingcart-web")
    .WithReference(apiService);

// Enable observability
builder.AddOpenTelemetry();
builder.Build().Run();

Note that the call to the AddOpenTelemetry() method sends traces and metrics data to the Aspire dashboard. Once you've configured your AppHost project as shown here, traces and metrics will be sent to the Aspire dashboard.

Integrate Telemetry with Azure Application Insights

You can integrate with Azure Application Insights as well. This code shows how you can send traces and metrics to Application Insights.

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing.AddAzureMonitorTraceExporter(
        a => a.ConnectionString = "Specify your connection string here."))
    .WithMetrics(metrics => metrics.AddAzureMonitorMetricExporter(
        a => a.ConnectionString = "Specify your connection string here."));

Note that you should install the Azure.Monitor.OpenTelemetry.Exporter NuGet package to run this piece of code.

When deploying your local database to Azure, a new database will be created in Azure that is a replica of your local database. If you want, you can change the database name, that is, specify a different name for your database.

Configure Logging in .NET Aspire

The following code snippet in the Extensions.cs file of the ServiceDefaults project can be used to configure logging in your .NET Aspire application:

builder.Logging.AddOpenTelemetry(logging =>
{
    logging.IncludeFormattedMessage = true;
    logging.IncludeScopes = true;
});

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics =>
    {
        metrics
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddRuntimeInstrumentation();
    })
    .WithTracing(tracing =>
    {
        tracing.AddSource(builder.Environment.ApplicationName)
            .AddAspNetCoreInstrumentation(tracing =>
            {
                tracing.Filter = context =>
                    !context.Request.Path.StartsWithSegments(HealthEndpointPath) &&
                    !context.Request.Path.StartsWithSegments(AlivenessEndpointPath);
            })
            .AddHttpClientInstrumentation();
    });

builder.AddOpenTelemetryExporters();

The preceding piece of code is autogenerated in the ConfigureOpenTelemetry method of the Extensions.cs file in the Service Defaults project when you create a new .NET Aspire project in Visual Studio.

This code shows how you can display metrics and traces in your Aspire dashboard:

var meter = new Meter("ShoppingCart.Metrics");

var totalRequests = meter.CreateCounter<long>("requests.total");

var latencyHistogram = meter.CreateHistogram<double>("request.latency", "ms");

totalRequests.Add(1);
latencyHistogram.Record(100);

Create the User Interface

In this section, we'll focus on using Blazor to create an interface for interacting with the previously developed APIs. Blazor is a Microsoft open-source framework that allows developers to build interactive web applications in C# and .NET, making it one of the most versatile, elegant, and productive ways to build modern web applications today.

The shopping cart UI will be based on Blazor and will have a responsive design that displays the product catalog and a list of cart items. It should be able to provide real-time cart updates via the Cart API. Create an interface called IApiClient in the Web project to make it easier to communicate with the API and consume data.

public interface IApiClient
{
    Task<CartDto?> GetCartAsync(int customerId);

    Task<CartDto?> AddItemAsync(
        int customerId,
        int productId,
        int quantity);

    Task<CartDto?> UpdateItemAsync(
        int customerId,
        int productId,
        int quantity);

    Task RemoveItemAsync(
        int customerId,
        int productId);

    Task ClearCartAsync(int customerId);
}

The ApiClient class given in Listing 6 shows how you can implement this interface. The ApiClient class leverages .NET Aspire's support for service discovery, resilience, and observability while maintaining clean separation of concerns. The following code snippet shows how you can use dependency injection to inject an instance of type IApiClient into your user interface component, Cart.razor.

Listing 6: The ApiClient class

public class ApiClient : IApiClient 
{ 
    private readonly IHttpClientFactory _httpClientFactory; 
    private readonly ILogger<ApiClient> _logger; 
    private readonly JsonSerializerOptions _jsonOptions; 
    private readonly HttpClient _httpClient; 
    public ApiClient(IHttpClientFactory httpClientFactory,  
       ILogger<ApiClient> logger) 
    { 
        _httpClientFactory = httpClientFactory; 
        _httpClient = _httpClientFactory. 
            CreateClient("shoppingcart-api"); 
        _httpClient.BaseAddress = new Uri( 
            "http://localhost:5151/"); 
        _logger = logger; 
        _jsonOptions = new JsonSerializerOptions {  
           PropertyNameCaseInsensitive = true }; 
    }  
    public async Task<CartDto?> GetCartAsync(int customerId) 
    { 
        try 
        { 
            _logger.LogInformation("Fetching cart for  
              customer {CustomerId}", customerId);  
            var response = await _httpClient. 
              GetAsync($"api/cart/{customerId}"); 
            if (response.StatusCode == System.Net. 
              HttpStatusCode.NotFound) 
            { 
                return null; 
            }  
            response.EnsureSuccessStatusCode(); 
            return await response.Content. 
              ReadFromJsonAsync<CartDto>(_jsonOptions); 
        } 
        catch (HttpRequestException ex) 
        { 
            _logger.LogError(ex, "Failed to fetch cart for 
              customer {CustomerId}", customerId); 
            return null; 
        } 
    } 
 
    public async Task<CartDto?> AddItemAsync(int customerId,  
      int productId, int quantity) 
    { 
        try 
        { 
            var request = new { ProductId =  
              productId, Quantity = quantity }; 
 
            _logger.LogInformation("Adding item {ProductId}  
                x{Quantity} to cart for customer {CustomerId}", 
                productId, quantity, customerId);  
            var response = await _httpClient.PostAsJsonAsync( 
               $"api/cart/{customerId}/items", request); 
            response.EnsureSuccessStatusCode(); 
            return await response.Content. 
              ReadFromJsonAsync<CartDto>(_jsonOptions); 
        } 
        catch (HttpRequestException ex) 
        { 
            _logger.LogError(ex, "Failed to add item 
               {ProductId}to cart for customer {CustomerId}", 
               productId, customerId); 
            return null; 
        } 
    } 
    public async Task<CartDto?> UpdateItemAsync(
        int customerId,
        int productId, int quantity) 
    { 
        try 
        { 
            _logger.LogInformation("Updating item  
                {ProductId} to quantity {Quantity} for  
                customer {CustomerId}", 
                productId, quantity, customerId); 
 
            var response = await _httpClient.PutAsJsonAsync(
               $"api/cart/{customerId}/items/{productId}",
               quantity); 
            response.EnsureSuccessStatusCode(); 
 
            return await response.Content. 
               ReadFromJsonAsync<CartDto>(_jsonOptions); 
        } 
        catch (HttpRequestException ex) 
        { 
            _logger.LogError(ex, "Failed to update item  
               {ProductId} for customer {CustomerId}",  
               productId, customerId); 
            return null; 
        } 
    } 
    public async Task RemoveItemAsync(int customerId,  
       int productId) 
    { 
        try 
        { 
            _logger.LogInformation( 
               "Removing item {ProductId} from cart for
               customer {CustomerId}", 
               productId, customerId);  
            var response = await _httpClient.DeleteAsync( 
               $"api/cart/{customerId}/items/{productId}"); 
            response.EnsureSuccessStatusCode(); 
        } 
        catch (HttpRequestException ex) 
        { 
            _logger.LogError(ex, "Failed to remove item  
            {ProductId} for customer {CustomerId}", 
             productId, customerId); 
            throw; 
        } 
    }  
    public async Task ClearCartAsync(int customerId) 
    { 
        try 
        { 
            _logger.LogInformation("Clearing cart for
               customer {CustomerId}", customerId);  
            var response = await _httpClient.DeleteAsync( 
               $"api/cart/{customerId}"); 
            response.EnsureSuccessStatusCode(); 
        } 
        catch (HttpRequestException ex) 
        { 
            _logger.LogError(ex, "Failed to clear cart for
               customer {CustomerId}", customerId); 
            throw; 
        } 
    } 
}
@page "/cart/{CustomerId:int}"
@inject IApiClient ApiClient
@inject ILogger<Cart> Logger
@rendermode InteractiveServer
@* Remaining code follows...*@

Listing 7 shows the complete source code of the Program.cs file of the web project.

Listing 7: The Program.cs file of the web project

using ShoppingCartApplication.Web.Services;
using ShoppingCartApplication.ApiService.Dtos;

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

builder.Services.AddHttpClient("apiservice",
    client => client.BaseAddress = new Uri("http://apiservice/"));
builder.Services.AddScoped<ApiClient>();
builder.Services.AddScoped(sp =>
    sp.GetRequiredService<IHttpClientFactory>()
        .CreateClient("apiservice"));

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();
app.MapDefaultEndpoints();
app.Run();

Deploying the ShoppingCart Database to Azure

Azure SQL Database is a SQL database hosted on the Azure platform, with Azure taking responsibility for maintaining, upgrading, and patching the database. To deploy a database in Azure, create a new SQL database in the Azure portal and then run the required scripts.

To migrate your local database to Azure, you can also take advantage of the Azure Database Migration Service using the Azure SQL migration extension for Azure Data Studio, or the Azure portal. You can also deploy a SQL database using a data-tier package or DACPAC. Alternatively, you can also deploy a SQL database from SSMS.

Assuming you already have created an Azure account, follow the steps outlined below to deploy the ShoppingCart database from your local computer to Azure.

  1. Connect to your local database in the SQL Server Management Studio (SSMS).
  2. Right-click on the database to launch the context menu.
  3. Select TasksDeploy the database to SQL Azure.
  4. Specify the target connection (i.e., the Azure database server name, the login name, and the password) in the Deployment Settings page.
  5. Click Next.
  6. Verify the connection details in the Summary page.
  7. If the connection information is correct, click Finish to deploy your database to Azure.
  8. Click Close to exit the wizard.

If the database deployment process is successful, you can see the database in the Azure portal once you log in there. The sequence diagram showing the complete flow of the application is shown in Figure 9.

Figure 9: A sequence diagram shows the complete flow of the application.
Figure 9: A sequence diagram shows the complete flow of the application.

Install the Azure Developer CLI

If Chocolatey is installed on your computer, you can use it to install the Azure Developer CLI as shown below:

choco install azd

Alternatively, you can install Azure Developer CLI using winget from Powershell using the following command:

winget install microsoft.azd

Once the Azure Developer CLI has been installed, you can verify the installation using the following command:

azd version

Add a Deployment Package

You can create a deployment package to deploy your .NET Aspire application to Azure by executing the following command at the root directory of your Aspire solution:

aspire add azure-appcontainers

Deploying the ShoppingCart Application to Azure

To deploy the application in Azure, follow the steps outlined below:

  1. Launch a PowerShell Window in the administrator mode.
  2. Open the ShoppingCartApplication.AppHost project in the PowerShell window.
  3. Next, initialize Azure by executing the azd init command at the PowerShell window as shown in Figure 10.
Figure 10: Executing the azd init command
Figure 10: Executing the azd init command
  1. Now run the azd up command to deploy the app using the generated Azure.yaml and next-steps.md files.
  2. Next, select the Azure Subscription to use for this example as shown in Figure 11.

The azd init command is used to initialize your project with azd. It will then inspect the directory structure of your application, detect the AppHost project, and determine the type of the application.

Figure 11:
Figure 11:
Figure 12: Selecting the Azure Subscription to use
Figure 12: Selecting the Azure Subscription to use

The process will be completed once the services have been deployed to Azure. Figure 12 shows the shoppingcart-api and shoppingcart-web services deployed to Azure successfully.

Figure 13:
Figure 13:
Figure 14: The application is deployed in Azure successfully
Figure 14: The application is deployed in Azure successfully

Now that the application has been deployed to Azure successfully, you can access the web app and the api from the links provided.

The azd up command creates the necessary resources as defined in the manifest and then deploys your application to Azure.

Best Practices for Building Cloud Applications with .NET Aspire

Here are a few best practices you should keep in mind when building your cloud applications with .NET Aspire:

  • Design for scalability and resilience
  • Define all services and resources in AppHost
  • Conduct regular health checks
  • Use structured logging with correlation
  • Leverage built-in observability

Although .NET Aspire is a great tool for building and deploying your cloud-native applications, it is not a good choice for applications that have single ASP.NET Core Web API, have minimal dependencies, and don't require distributed orchestration, observability, and configuration.

Where Do We Go from Here?

In this article we've built a clean, modular, and testable cart microservice using .NET Aspire, EF Core and C#. We've also deployed this RESTful microservices-based application to Azure. Since this application is designed to support asynchronous operations, it is scalable and well suited for cloud-native deployments on Azure or even in other containerized environments.

We've used EF Core to handle persistence, and a repository abstraction to manage data access. We've used DTOs for passing data back and forth between the user interface and the API layer, and the action methods in the Cart API are asynchronous to support scalability.

Takeaways

Here are the key takeaways:

  • .NET Aspire is the technology of choice for building cloud-native distributed applications in .NET.
  • .NET Aspire provides you with a unified development stack that contains all tools you may need for configuration, integration, orchestration, and observability of your applications.
  • The built-in tools of .NET Aspire can help you manage dependencies, configure services, and orchestrate their workflows.
  • With .NET Aspire, building, deploying, managing, and troubleshooting cloud-native applications will be much faster and easier than ever before.
  • Deploying an application or a database to an Azure environment is a straightforward process if you've installed the right tools and your environment is properly configured.

Listing 5: The CartController

using Microsoft.AspNetCore.Mvc;  
  
[ApiController]   
[Route("api/[controller]")]  
 
public class CartController(ICartRepository repository, 
  ILogger<CartController> logger) : ControllerBase  
 {  
    private static readonly ActivitySource Activity = 
       new("ShoppingCartApplication.Cart");    
 
   [HttpGet("{customerId:int}")]  
    public async Task<ActionResult<CartDto>> 
        Get(int customerId)   
   {   
       using var activity = Activity
           .StartActivity("GetCart", ActivityKind.Server);
        activity?.SetTag("customer.id", customerId);  
  
        logger.LogInformation("Retrieving cart for customer
          {CustomerId}", customerId);  
  
       var cart = await    
          repository.GetByCustomerIdAsync(customerId);  
 
       if (cart == null)  
        {   
           return NotFound();  
        }  
 
 
       var dto = await repository.GetCartDtoAsync(cart);   
       return Ok(dto);  
    }  
 
 
    [HttpPost("{customerId:int}/items")]  
    public async Task<ActionResult<CartDto>> AddItem(  
       int customerId, [FromBody] 
           AddCartItemRequest request)  
    {  
        using var activity = Activity.StartActivity("AddItem", 
          ActivityKind.Server);  
        activity?.SetTag("customer.id", customerId);  
       activity?.SetTag("product.id", request.ProductId);  
 
 
       logger.LogInformation("Adding product {ProductId} 
          to cart for customer {CustomerId}",   
           request.ProductId, customerId);    
 
       var cart = await    
          repository.GetOrCreateCartAsync(customerId); 
        var product = await    
          repository.GetProductAsync(request.ProductId); 
  
        if (product == null || !product.IsActive ||   
          product.StockQuantity < request.Quantity)   
       {   
           return BadRequest("Invalid product or    
              insufficient stock");   
       }   
 
        var existingItem = cart.CartItems.FirstOrDefault(  
          ci => ci.Product_Id == request.ProductId);  
 
       if (existingItem != null)  
        {   
           existingItem.Quantity += request.Quantity;  
            await repository.UpdateItemAsync(cart.Cart_Id, 
           request.ProductId, existingItem.Quantity);  
        }  
 
       else   
       {  
            var item = new CartItem   
           {   
               Cart_Id = cart.Cart_Id,   
               Product_Id = request.ProductId,   
               Quantity = request.Quantity,   
               UnitPrice = product.Price,   
               AddedAt = DateTime.UtcNow   
           };  
 
           await repository.AddItemAsync(item);   
       }   
 
        cart = 
            await repository.GetByCustomerIdAsync(customerId);
       var dto = await repository.GetCartDtoAsync(cart!);  
       return Ok(dto);   
   }  
 
   [HttpPut("{customerId:int}/items/{productId:int}")]  
    public async Task<ActionResult<CartDto>> UpdateItem(int   
      customerId, int productId, [FromBody] int quantity)   
   {  
        logger.LogInformation("Updating product {ProductId}   
           quantity to {Quantity} for customer {CustomerId}",   
           productId, quantity, customerId);  
  
       var cart = await    
          epository.GetByCustomerIdAsync(customerId);  
 
       if (cart == null)  
        {   
           return NotFound();   
       }  
  
       await repository.UpdateItemAsync(cart.Cart_Id,   
           productId, quantity);  
  
       cart = await repository.GetByCustomerIdAsync(customerId);
       var dto = await repository.GetCartDtoAsync(cart!);  
        return Ok(dto);  
    }  
 
    [HttpDelete("{customerId:int}/items/{productId:int}")]  
    public async Task<ActionResult> RemoveItem(int customerId,
 
      int productId)   
   {  
        logger.LogInformation("Removing product {ProductId} 
           from cart for customer {CustomerId}",  
            productId, customerId);  
  
       var cart = await    
           epository.GetByCustomerIdAsync(customerId);  
 
       if (cart == null)  
       {   
           return NotFound();  
       }  
 
       await repository.RemoveItemAsync(
           cart.Cart_Id, productId);  
       return NoContent();   
   }  
 
 
   [HttpDelete("{customerId:int}")]   
   public async Task<ActionResult> Clear(int customerId)  
    {  
 
       logger.LogInformation("Clearing cart for customer    
          {CustomerId}", customerId);  
  
       var cart = await   
            epository.GetByCustomerIdAsync(customerId);  
 
       if (cart == null)  
       {  
            return NotFound();  
       }   
 
       await repository.ClearCartAsync(cart.Cart_Id);  
        return NoContent();  
    }  
 }  
 
public record AddCartItemRequest(int ProductId, int Quantity);