.NET Aspire is a unified, cloud-ready software development stack designed to efficiently handle orchestration, configuration, service integration, and observability in distributed applications. You can use Aspire to simplify the entire lifecycle of distributed applications—from development to deployment and monitoring—while promoting consistent architecture and operational practices that can help make your applications scalable, reliable, and easy to maintain. Additionally, with a vast set of tools at your disposal, by using Aspire, you can streamline the communication between services and infrastructure components throughout the application development process.
If you're going to work with the code examples discussed in this article, you need the following installed on 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 on your computer, you can download it from here: https://visualstudio.microsoft.com/downloads/.
There is a common misconception that Aspire is used only for building microservices-based applications. In fact, you can use Aspire to build distributed applications that can comprise several services and external dependencies. For example, you can have a frontend that uses Blazor, one or more backend services that use Web APIs, one or more queues that use RabbitMQ, databases such as SQL Server, PostgreSQL, and external APIs.
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. You can use .NET Aspire to connect to an external service or component, typically available as a NuGet package, enabling you to connect to a database, cache, queue, message broker, storage device, or cloud platform.
Using .NET Aspire integration, you can describe what happens in the AppHost at runtime without writing boilerplate code to define connection strings, health checks, telemetry, etc., simply by installing the NuGet package and referencing it. Aspire will connect them together automatically. In this section, we will discuss how Aspire integrations work and provide implementations you can apply directly to your own cloud-compatible solutions.
The Need for Integrations
In a monolithic application, you can manage dependencies easily—by referencing libraries, injecting them into services, etc. However, things become more complex when you're working with a distributed system. For example, consider an e-commerce application that comprises several components and services needed to retrieve service or component health information, implement graceful failures, and capture and store logs and traces for observability.
If you don't have a proper integration strategy in place, you'll need to hard-code health-check logic throughout the application. Moreover, if you don't use integrations, your application will exhibit inconsistent logging and tracing patterns. Your application will be fragile and difficult to test and maintain. .NET Aspire simplifies these issues by providing support for integrations using a declarative, composable model.
Types of Integrations
Aspire integrations can be roughly categorized into two categories; hosting integrations that provide a way to describe resources in the AppHost, such as containers or cloud services, and client integrations that provide a means of connecting to libraries, such as Redis, with support for dependency injection, configuration, health checks, telemetry, and testability.
.NET Aspire includes built-in support for integrations with popular cloud-native services using one or more of the following:
- Client libraries: You can connect your .NET Aspire application to external services and components, such as Azure Service Bus, Redis, or SQL Server or PostgreSQL databases, using configurable client libraries.
- Built-in support for telemetry: You can leverage the built-in support for logging, metrics, and tracing in your .NET Aspire application.
- Register services using dependency injection: You can register services with the dependency injection container and use them in your .NET Aspire application.
Hosting Integrations
Hosting integrations are used to provide infrastructure support in a .NET Aspire application via the AppHost project. Using hosting integrations enables you to connect your .NET Aspire application to external resources like databases, caches, message brokers, storage devices, and services.
Hosting integrations in .NET Aspire extend the IDistributedApplicationBuilder interface thereby enabling the AppHost project to express resources within the app model. Hosting integrations can be used to create a resource by spinning up a docker container or configuring a cloud-hosted service and creating environment variables that represent the endpoints, ports, and connection strings of the resource. You can use hosting integrations to inject the configuration of the resource into any project that depends on it.
Note that hosting integrations are not limited to .NET projects. You can use a hosting integration in a Python application or a Node.js-based container, if the application or service has access to the injected environment variables from Aspire.
The InventoryManagement.AppHost project is where you can configure and orchestrate the application components. Here's how a typical AppHost project is organized:
InventoryManagement/
├── InventoryManagement.AppHost/ (Service orchestration)
├── InventoryManagement.ServiceDefaults/ (Shared configuration)
├── InventoryManagement.Web/ (Web application)
├── InventoryManagement.Api/ (API service)
└── InventoryManagement.Database/ (Database initialization)
Client Integrations
Client integrations use dependency injection (DI) to define configuration schema and create health checks, resiliency, and telemetry. Note that client integration libraries are prefixed with the word “Aspire” followed by the full package name—for example, Aspire.StackExchange.Redis.
The following snippet shows the Program.cs file of a typical AppHost project:
var builder =
DistributedApplication.CreateBuilder(args);
var database = builder
.AddPostgres("postgres")
.AddDatabase("inventorydb");
var redis = builder
.AddRedis("redis");
var messageQueue = builder.AddRabbitMq("rabbitmq");
builder.AddProject<
Projects.InventoryManagement_Web>("webfrontend")
.WithExternalHttpEndpoints()
.WithHttpHealthCheck("/health")
.WithReference(cache)
.WithReference(database)
.WithReference(redis)
.WaitFor(cache)
.WithReference(apiService)
.WaitFor(apiService);
builder.Build().Run();
SQL Server Integration
Aspire provides support for SQL Server integration in the same way it supports PostgreSQL integration. The first step in configuring a SQL Server project is to define the resource—SQL Server—as an AppHost and create a new database in SQL Server. The database can then be referenced from your consuming project. Note that you can use a connection string to provision a SQL Server container and also connect to an existing SQL Server instance.
The following code snippet shows how you can configure SQL Server integration in a .NET Aspire application.
var builder =
DistributedApplication.CreateBuilder(args);
var sqlServer = builder.AddSqlServer("sql")
.AddDatabase("inventorydb");
var api = builder.AddProject
<Projects.InventoryManagement_ApiService>
("apiservice").WithReference(sqlServer);
builder.Build().Run();
Redis Integration
Caching is a technique that can be used to store relatively stale data for faster retrieval when needed by the application. Redis is an open-source, high performance, in-memory data store available for commercial and non-commercial use to store and retrieve data in your applications.
Redis supports several data structures such as hashes, lists, sets, sorted sets, bitmaps, etc. Used primarily as a database, cache, or message broker, you'll notice only negligible performance overhead when reading or writing data using Redis. Redis is an excellent choice if your application requires a large amount of data to be stored and retrieved, and memory availability is not an issue.
Redis is often used for Aspire Integration since it serves as not only a great cache, but also as a performant data store. Aspire can create a local Redis container or connect to an already existing Redis instance and the corresponding client integration provides your application DI-friendly access to Redis with additional telemetry and health checking, all in a single integration layer.
The following code snippet shows how you can integrate Redis into your .NET Aspire application.
var builder =
DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.AddDatabase("inventorydb");
var redis = builder.AddRedis("cache")
.WithDataVolume();
var api = builder.AddProject
<Projects.InventoryManagement_ApiService>
("apiservice")
.WithReference(postgres)
.WithReference(redis);
var web = builder.AddProject
<Projects.InventoryManagement_Web>("web")
.WithReference(api)
.WithReference(redis);
builder.Build().Run();
RabbitMQ Integration
RabbitMQ is an open-source, high-performance messaging broker that takes advantage of the Open Telecom Platform and implements AMQP (an acronym for Advanced Message Queuing Protocol) for communication across applications, processes, and servers.
RabbitMQ fits into the Aspire context when you want to produce or dispatch messages to a message queue without pausing to wait for a response from the message consumer. When you create an instance of RabbitMQ in Aspire, it automatically provisions a RabbitMQ resource and automatically injects configuration settings into services that produce or consume messages to and from RabbitMQ, including health checks and lifecycle behavior.
The following code snippet shows how you can configure RabbitMQ integration in a .NET Aspire project.
var builder =
DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.AddDatabase("inventorydb");
var rabbitmq = builder.AddRabbitMQ("messaging");
var api = builder.AddProject
<Projects.InventoryManagement_ApiService>
("apiservice")
.WithReference(postgres)
.WithReference(rabbitmq);
var worker = builder.AddProject
<Projects.InventoryManagement_Worker>("worker")
.WithReference(rabbitmq);
var web = builder.AddProject
<Projects.InventoryManagement_Web>("web")
.WithReference(api);
builder.Build().Run();
Implementing a Real-life Application Using .NET Aspire
The term “inventory management system” refers to an application that supports a business in the management of its inventory. It allows you to gain greater visibility into product availability and purchasing trends, while capabilities such as reorder alerts and stock monitoring will assist with controlling costs and keeping your operation running smoothly. You can use it to track units of merchandise being held, the location of inventory items, and how much inventory flows in and out of your warehouse facility. Additionally, an inventory management system helps minimize stock outages, avoid excess stock, and enhance business efficiency.
For the purposes of this discussion, we will focus primarily on the product and purchase order modules, including the product, warehouse, inventory, purchase order, and purchase order detail tables. These core tables will enable you to model product master data, the quantities of product held in stock, and purchasing activities without adding unnecessary complexity to your overall inventory system. The sections that follow walk through implementing the application.
We'll start by creating the inventory system database. Listing 1 contains the complete database script for creating the product, warehouse, inventory, purchase order, and purchase order detail tables. Next, we'll create the solution structure for the application by creating the necessary projects in Visual Studio. You can create a project in Visual Studio 2026 in several ways. When you launch Visual Studio 2026, you'll see the “Getting started” window. You can choose “Continue without code” to launch the main screen of the Visual Studio 2026 IDE.
Listing 1: Create database tables
DROP TABLE IF EXISTS inventory CASCADE;
DROP TABLE IF EXISTS purchase_order CASCADE;
DROP TABLE IF EXISTS warehouse CASCADE;
DROP TABLE IF EXISTS product CASCADE;
DROP TABLE IF EXISTS purchase_order_detail CASCADE;
CREATE TABLE product (
product_id serial PRIMARY KEY,
product_code varchar(100) NOT NULL UNIQUE,
barcode varchar(100) UNIQUE,
product_name varchar(100) NOT NULL,
product_description varchar(2000),
product_category varchar(100),
reorder_quantity int CHECK
(reorder_quantity IS NULL
OR reorder_quantity > 0),
created_date timestamp(0)
NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_date timestamp(0)
);
CREATE TABLE warehouse (
warehouse_id serial PRIMARY KEY,
warehouse_name varchar(100) NOT NULL,
warehouse_address varchar(200),
is_refrigerated boolean
NOT NULL DEFAULT false,
created_date timestamp(0)
NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_date timestamp(0)
);
CREATE TABLE purchase_order (
purchase_order_id serial PRIMARY KEY,
order_number varchar(30) NOT NULL UNIQUE,
order_date date NOT NULL,
order_status varchar(30)
NOT NULL DEFAULT 'Open'
CHECK (order_status IN
('Open', 'PartiallyReceived',
'Closed', 'Cancelled')),
remarks varchar(500),
created_date timestamp(0)
NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_date timestamp(0)
);
CREATE TABLE inventory (
inventory_id serial PRIMARY KEY,
product_id int NOT NULL
REFERENCES product(product_id),
warehouse_id int NOT NULL
REFERENCES warehouse(warehouse_id),
quantity_available int
NOT NULL DEFAULT 0 CHECK
(quantity_available >= 0),
minimum_stock_level int
CHECK (minimum_stock_level IS NULL
OR minimum_stock_level >= 0),
maximum_stock_level int
CHECK (maximum_stock_level IS NULL
maximum_stock_level >= 0),
reorder_point int CHECK
(reorder_point IS NULL OR reorder_point >= 0),
created_date timestamp(0)
NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_date timestamp(0),
UNIQUE (product_id, warehouse_id),
CHECK (minimum_stock_level
IS NULL OR maximum_stock_level
IS NULL OR minimum_stock_level
<= maximum_stock_level),
CHECK (reorder_point
IS NULL OR minimum_stock_level
IS NULL OR reorder_point >=
minimum_stock_level)
);
CREATE TABLE purchase_order_detail (
purchase_order_detail_id
serial PRIMARY KEY,
purchase_order_id int
NOT NULL REFERENCES purchase_order
(purchase_order_id),
product_id int
NOT NULL REFERENCES product(product_id),
warehouse_id int
NOT NULL REFERENCES warehouse(warehouse_id),
order_quantity int
NOT NULL CHECK (order_quantity > 0),
expected_date date,
actual_date date,
created_date timestamp(0)
NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_date timestamp(0),
CHECK (actual_date IS
NULL OR expected_date IS NULL
OR actual_date >= expected_date)
);
To create a new ASP.NET Core 10 Project in Visual Studio 2026:
- Start the Visual Studio 2026 IDE.
- In the Create a new project window, select Aspire Starter App, and click Next to move on as shown in Figure 1.

- Specify the project name as InventoryManagement and the path where it should be created in the Configure your new project window as shown in Figure 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.
- In the next screen, specify the target framework. Ensure that the “Configure for HTTPS” and “Use Redis for caching…” checkboxes are checked as shown in Figure 3.
- 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 will look similar to Figure 4.

In this example, the proposed solution comprises the following four projects:
- InventoryManagementSystem.AppHost
- InventoryManagementSystem.Api
- InventoryManagementSystem.Web
- InventoryManagementSystem.ServiceDefaults
The API project contains the entity mappings for each of the entities—product, warehouse, inventory, purchase_order, and purchase_order_detail—along with the repository types and controllers.
By default, the AppHost is the starter application: when the application starts, the AppHost is executed.
Explore the .NET Aspire Dashboard
The .NET Aspire Dashboard is a web-based tool that provides a real-time, comprehensive overview of your projects' status, dependencies, and performance metrics. 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 .NET 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 displays 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 also 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.
Install Entity Framework Core
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 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
In this example, we'll use the following model classes:
- Product
- Warehouse
- Inventory
- PurchaseOrder
- PurchaseOrderDetail
Here's a brief description of each of these model classes:
- Product: A product is what your company purchases, stores, and keeps track of in inventory. When an item is in inventory, it may have various identifiable attributes, including a product code, barcode, product description, product category, and more. These are all identified by the Product which serves as the main entity in the Catalog; it connects the Stock Records with the Purchase Order Line Item for the Inventory and Purchase of any particular Product.
- Warehouse: A warehouse is a storage facility or a physical location where you can store and manage your goods. It is described by attributes specific to the location, such as its address.
- Inventory: The inventory is the current quantity of a specific product available to a customer within a specific warehouse, along with the controls on this quantity—minimum stock, maximum stock, and reorder point. The purpose of the inventory is to determine whether you've sufficient stock to prevent stock-outs (or out-of-stock) and to trigger a reorder when stock falls below established thresholds.
- PurchaseOrder: The purchase order is the header/master record for a procurement transaction intended to replenish stock and contains overall information, such as the order number, order date, status, and comments. The purpose of the purchase order is to manage the supply chain of a supplier purchase order independently of the items on that order; therefore, the purchase order is the parent to all purchase order detail records.
- PurchaseOrderDetail: Each purchase order detail record represents the line items that make up a purchase order, connecting a purchase order to a specific product, warehouse, and quantity; it also captures the expected and actual delivery dates. The purchase order detail records capture the actual business intent of the purchase order: which products will be received, the quantity of each, and the warehouse to which they will be delivered.
Create new files named Product.cs, Warehouse.cs, Inventory.cs, PurchaseOrder.cs, and PurchaseOrderDetail.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: Creating the models
namespace InventoryManagement.ApiService.Models
{
public sealed class Inventory
{
public int InventoryId { get; set; }
public int ProductId { get; set; }
public int WarehouseId { get; set; }
public int QuantityAvailable { get; set; }
public int? MinimumStockLevel { get; set; }
public int? MaximumStockLevel { get; set; }
public int? ReorderPoint { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime? ModifiedDate { get; set; }
public Product Product { get; set; } = null!;
public Warehouse Warehouse { get; set; } = null!;
}
}
namespace InventoryManagement.ApiService.Models
{
public sealed class Product
{
public int ProductId { get; set; }
public string ProductCode { get; set; } = string.Empty;
public string? Barcode { get; set; }
public string ProductName { get; set; } = string.Empty;
public string? ProductDescription { get; set; }
public string? ProductCategory { get; set; }
public int? ReorderQuantity { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime? ModifiedDate { get; set; }
public ICollection<Inventory> Inventories
{ get; set; } = new List<Inventory>();
public ICollection<PurchaseOrderDetail>
PurchaseOrderDetails { get; set; } =
new List<PurchaseOrderDetail>();
}
}
namespace InventoryManagement.ApiService.Models
{
public sealed class PurchaseOrder
{
public int PurchaseOrderId { get; set; }
public string OrderNumber { get; set; } = string.Empty;
public DateTime OrderDate { get; set; }
public string OrderStatus { get; set; } = "Open";
public string? Remarks { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime? ModifiedDate { get; set; }
public ICollection<PurchaseOrderDetail>
PurchaseOrderDetails
{ get; set; } = new List<PurchaseOrderDetail>();
}
}
namespace InventoryManagement.ApiService.Models
{
public sealed class PurchaseOrderDetail
{
public int PurchaseOrderDetailId { get; set; }
public int PurchaseOrderId { get; set; }
public int ProductId { get; set; }
public int WarehouseId { get; set; }
public int OrderQuantity { get; set; }
public DateTime? ExpectedDate { get; set; }
public DateTime? ActualDate { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime? ModifiedDate { get; set; }
public PurchaseOrder PurchaseOrder
{ get; set; } = null!;
public Product Product
{ get; set; } = null!;
public Warehouse Warehouse
{ get; set; } = null!;
}
}
namespace InventoryManagement.ApiService.Models
{
public sealed class Warehouse
{
public int WarehouseId { get; set; }
public string WarehouseName
{ get; set; } = string.Empty;
public string? WarehouseAddress { get; set; }
public bool IsRefrigerated { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime? ModifiedDate { get; set; }
public ICollection<Inventory>
Inventories { get; set; } = new List<Inventory>();
public ICollection<PurchaseOrderDetail>
PurchaseOrderDetails
{ get; set; } = new List<PurchaseOrderDetail>();
}
}
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, manage database connections, and query and persist data. Next, create the data context class to enable the application to perform CRUD (Create, Read, Update, and Delete) operations against the database.
To do this, create a new class named InventoryDbContext that extends the DbContext class of EF Core and add the following code.
public class InventoryDbContext: DbContext
{
public InventoryDbContext
(DbContextOptions
<CartDbContext> options) :
base(options) { }
public DbSet<PurchaseOrder>
PurchaseOrders => Set<PurchaseOrder>();
protected override void
OnModelCreating(ModelBuilder modelBuilder)
{
//Not yet implemented
}
}
You can specify the table and column mapping information in the OnModelCreating overloaded method of the InventoryDbContext class. The following code shows how you can override the OnModelCreating method in your data context class to connect to the database.
protected override void
OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<PurchaseOrder>
(entity =>
{
entity.ToTable("purchase_order");
entity.HasKey(x => x.PurchaseOrderId);
entity.Property(x => x.PurchaseOrderId).
HasColumnName ("purchase_order_id");
entity.Property(x =>
x.OrderNumber).HasColumnName
("order_number").HasMaxLength(30).
IsRequired();
entity.Property(x =>
x.OrderDate)
.HasColumnName("order_date");
entity.Property(x => x.OrderStatus)
.HasColumnName
("order_status").HasMaxLength(30).
IsRequired();
entity.Property(x => x.Remarks)
.HasColumnName
("remarks").HasMaxLength(500);
entity.Property(x => x.CreatedDate).
HasColumnName("created_date");
entity.Property(x => x.ModifiedDate).
HasColumnName("modified_date");
entity.HasIndex(x =>
x.OrderNumber).IsUnique();
});
}
You can also specify the connection string in the appsettings.json file and read it in the Program.cs file to establish a database connection, as shown here:
builder.Services.AddDbContext
<CartDbContext>(options => options.UseSqlServer(
builder.Configuration.GetConnectionString
("DefaultConnection")));
Listing 3 shows the complete source code of the InventoryDbContext class.
Listing 3: The InventoryDbContext class
public class InventoryDbContext : DbContext
{
public InventoryDbContext
(DbContextOptions<InventoryDbContext> options)
: base(options)
{
}
public DbSet<PurchaseOrder>
PurchaseOrders => Set<PurchaseOrder>();
protected override void
OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<PurchaseOrder> (entity =>
{
entity.ToTable("purchase_order");
entity.HasKey(x => x.PurchaseOrderId);
entity.Property(x => x.PurchaseOrderId)
.HasColumnName("purchase_order_id");
entity.Property(x => x.OrderNumber)
.HasColumnName("order_number").
HasMaxLength(30).IsRequired();
entity.Property(x => x.OrderDate)
.HasColumnName("order_date");
entity.Property(x => x.OrderStatus)
.HasColumnName("order_status").
HasMaxLength(30).IsRequired();
entity.Property(x => x.Remarks)
.HasColumnName("remarks").
HasMaxLength(500);
entity.Property(x => x.CreatedDate)
.HasColumnName("created_date");
entity.Property(x => x.ModifiedDate)
.HasColumnName("modified_date");
entity.HasIndex
(x => x.OrderNumber).IsUnique();
});
}
}
Create the Purchase Order Repository
The purchase order repository will comprise two types:
- IPurchaseRepository: This interface represents the contract for purchase order operations without exposing the implementation details.
- PurchaseOrderRepository: This class represents the concrete implementation of the purchase order repository contract.
The following code snippet shows how the operations in the purchase order repository contract are declared:
using InventoryManagement.Api.Models;
namespace InventoryManagement.Api.Repositories
{
public interface IPurchaseOrderRepository
{
Task<IEnumerable<PurchaseOrder>>
GetAllAsync(CancellationToken
cancellationToken = default);
Task<PurchaseOrder?>
GetByIdAsync(int id, CancellationToken
cancellationToken = default);
Task<PurchaseOrder?>
GetByOrderNumberAsync(string orderNumber,
CancellationToken cancellationToken =
default);
Task<PurchaseOrder>
AddAsync(PurchaseOrder entity,
CancellationToken cancellationToken =
default);
Task<PurchaseOrder?>
UpdateAsync(PurchaseOrder entity,
CancellationToken
cancellationToken = default);
Task<bool> DeleteAsync(int id,
CancellationToken
cancellationToken = default);
Task<bool> ExistsAsync(int id,
CancellationToken cancellationToken =
default);
}
}
Listing 4 shows the complete PurchaseOrderRepository class.
Listing 4: PurchaseOrderRepository class
using Microsoft.EntityFrameworkCore;
using InventoryManagement.Api.Data;
using InventoryManagement.Api.Models;
namespace InventoryManagement.Api.Repositories
{
public class PurchaseOrderRepository :
IPurchaseOrderRepository
{
private readonly InventoryDbContext _context;
public PurchaseOrderRepository
(InventoryDbContext context)
{
_context = context;
}
public async Task<IEnumerable<PurchaseOrder>>
GetAllAsync(
CancellationToken cancellationToken = default)
{
return await _context.PurchaseOrders
.AsNoTracking()
.OrderByDescending(x => x.OrderDate)
.ThenBy(x => x.OrderNumber)
.ToListAsync(cancellationToken);
}
public async Task<PurchaseOrder?>
GetByIdAsync(int id, CancellationToken
cancellationToken = default)
{
return await _context.PurchaseOrders
.AsNoTracking()
.FirstOrDefaultAsync (x =>
x.PurchaseOrderId == id, cancellationToken);
}
public async Task<PurchaseOrder?> GetByOrderNumberAsync(
string orderNumber,
CancellationToken cancellationToken = default)
{
return await _context.PurchaseOrders
.AsNoTracking()
.FirstOrDefaultAsync(x => x.OrderNumber ==
orderNumber, cancellationToken);
}
public async Task<PurchaseOrder>
AddAsync(PurchaseOrder entity,
CancellationToken cancellationToken = default)
{
await _context.PurchaseOrders
.AddAsync(entity, cancellationToken);
await _context.SaveChangesAsync
(cancellationToken);
return entity;
}
public async Task<PurchaseOrder?>
UpdateAsync(PurchaseOrder entity,
CancellationToken cancellationToken = default)
{
var existingEntity = await _context.PurchaseOrders
.FirstOrDefaultAsync
(x => x.PurchaseOrderId ==
entity.PurchaseOrderId, cancellationToken);
if (existingEntity is null)
{
return null;
}
existingEntity.OrderNumber =
entity.OrderNumber;
existingEntity.OrderDate =
entity.OrderDate;
existingEntity.OrderStatus =
entity.OrderStatus;
existingEntity.Remarks =
entity.Remarks;
existingEntity.ModifiedDate =
entity.ModifiedDate;
await _context.SaveChangesAsync
(cancellationToken);
return existingEntity;
}
public async Task<bool> DeleteAsync(int id,
CancellationToken cancellationToken = default)
{
var existingEntity = await _context.PurchaseOrders
.FirstOrDefaultAsync
(x => x.PurchaseOrderId == id, cancellationToken);
if (existingEntity is null)
{
return false;
}
_context.PurchaseOrders.Remove(existingEntity);
await _context.SaveChangesAsync (cancellationToken);
return true;
}
public async Task<bool> ExistsAsync(int id,
CancellationToken cancellationToken = default)
{
return await _context.PurchaseOrders
.AnyAsync(x => x.PurchaseOrderId == id,
cancellationToken);
}
}
}
Create the PurchaseOrder Controller
The PurchaseOrderController in this example is a thin API layer that follows HTTP semantics and delegates control to the purchase order repository. Essentially, the PurchaseOrderController translates REST requests into repository calls and DTO responses, enabling clean, secure, and easy access to purchase order operations via RESTful APIs.
The PurchaseOrderController class is an ASP.NET Core Web API controller that extends the ControllerBase class and exposes RESTful endpoints for performing CRUD operations as shown in Listing 5.
Listing 5: PurchaseOrderController class
using Microsoft.AspNetCore.Mvc;
using InventoryManagement.Api.Models;
using InventoryManagement.Api.Repositories;
namespace InventoryManagement.Api.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class PurchaseOrderController : ControllerBase
{
private readonly IPurchaseOrderRepository _repository;
public PurchaseOrderController
(IPurchaseOrderRepository repository)
{
_repository = repository;
}
[HttpGet]
public async Task<ActionResult
<IEnumerable<PurchaseOrder>>>
GetAll(CancellationToken cancellationToken)
{
var items = await _repository
.GetAllAsync(cancellationToken);
return Ok(items);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<PurchaseOrder>>
GetById(int id, CancellationToken cancellationToken)
{
var item = await _repository
.GetByIdAsync(id, cancellationToken);
if (item is null)
{
return NotFound();
}
return Ok(item);
}
[HttpGet("by-order-number/{orderNumber}")]
public async Task<ActionResult<PurchaseOrder>>
GetByOrderNumber(string orderNumber,
CancellationToken cancellationToken)
{
var item = await _repository.GetByOrderNumberAsync
(orderNumber, cancellationToken);
if (item is null)
{
return NotFound();
}
return Ok(item);
}
[HttpPost]
public async Task<ActionResult<PurchaseOrder>>
Create([FromBody] PurchaseOrder purchaseOrder,
CancellationToken cancellationToken)
{
if (purchaseOrder is null)
{
return BadRequest();
}
purchaseOrder.CreatedDate = DateTime.UtcNow;
purchaseOrder.ModifiedDate = null;
var createdEntity = await _repository.AddAsync
(purchaseOrder, cancellationToken);
return CreatedAtAction(
nameof(GetById),
new { id = createdEntity.PurchaseOrderId },
createdEntity);
}
[HttpPut("{id:int}")]
public async Task<ActionResult <PurchaseOrder>>
Update(int id, [FromBody] PurchaseOrder purchaseOrder,
CancellationToken cancellationToken)
{
if (purchaseOrder is null || id !=
purchaseOrder.PurchaseOrderId)
{
return BadRequest();
}
var exists = await _repository.ExistsAsync(id,
cancellationToken);
if (!exists)
{
return NotFound();
}
purchaseOrder.ModifiedDate = DateTime.UtcNow;
var updatedEntity = await _repository.UpdateAsync
(purchaseOrder, cancellationToken);
if (updatedEntity is null)
{
return NotFound();
}
return Ok(updatedEntity);
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id,
CancellationToken cancellationToken)
{
var deleted = await _repository.DeleteAsync(id,
cancellationToken);
if (!deleted)
{
return NotFound();
}
return NoContent();
}
}
}
Listing 6 shows the Program.cs file of the API project that contains the necessary code to wire up the services, configure middleware, and sets up controllers, handles JSON cycle errors, and uses dependency injection to enable the API to connect to and work with PostgreSQL, OpenAPI, and the repositories.
Listing 6: The Program.cs file of the API project
using InventoryManagement.ApiService.Data;
using InventoryManagement.ApiService.Repositories;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.ReferenceHandler =
ReferenceHandler.IgnoreCycles;
options.JsonSerializerOptions.DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingNull;
});
builder.AddServiceDefaults();
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddEndpointsApiExplorer();
builder.AddNpgsqlDbContext
<InventoryDbContext>("inventorydb");
builder.Services.AddScoped <IProductRepository,
ProductRepository>();
builder.Services.AddScoped<IWarehouseRepository,
WarehouseRepository>();
builder.Services.AddScoped<IInventoryRepository,
InventoryRepository>();
builder.Services.AddScoped<IPurchaseOrderRepository,
PurchaseOrderRepository>();
builder.Services.AddScoped <IPurchaseOrderDetailRepository,
PurchaseOrderDetailRepository>();
var app = builder.Build();
app.UseExceptionHandler();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapDefaultEndpoints();
app.MapControllers();
app.Run();
Execute the Application
Figure 5 shows the application running in the web browser.
In another tab in the web browser, you'll observe that the dashboard of the application is launched. Figure 6 shows what the dashboard of the inventory management application looks like when viewed in the web browser.
When you click the Products button in the left navigation menu, you'll see the product data displayed in the web browser, as shown in Figure 7.
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. Distributed tracing provides a time-stamped view of each service a request passes through, 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 combine .NET Aspire with OpenTelemetry to allow the automation of distributed tracing (.NET) by capturing traces, metrics, and log entries and then presenting the information through the Aspire Dashboard. Additionally, you can use Azure Application Insights to extend your application's capabilities for monitoring, performance improvement and debugging.
There are three main types of data that are important for understanding the performance of a system: tracing data, metrics, and logs.
Traces
Tracing is a process that records details of all events, including method calls and exceptions raised. It captures data about the flow of a particular request or transaction across multiple components in your system, providing information such as the event type, method calls, and exceptions raised. Tracing data is very useful for understanding how a system works and for finding bottlenecks.
Metrics
Metrics consist of numerical data that provide insights into the performance of an application, such as the number of requests per second or the average response time. For example, you could collect the average number of milliseconds it took to render a web page over time.
Metrics are useful for evaluating performance, capacity planning, and other aspects of an application's health. The data captured represents specific events within defined time boundaries, such as average response times or throughput rates.
Logs
Logs are records of events that occur when an application is running, including information about how long a snippet of code or a method takes to execute, errors, warnings, and so on. Logs are typically used for debugging and troubleshooting problems in an application.
These historical records describe events in an application during a given period (such as user login and authentication). You can use logs to debug problems by reviewing what went wrong when an issue occurs.
Configure Observability in .NET Aspire
Observability improves operational visibility by capturing data and proactively monitoring distributed systems to surface relevant insights based on how an application performs in production over time.
You can read more about observability and open telemetry from this article: https://www.codemag.com/Article/2301061/An-Introduction-to-Distributed-Tracing-with-OpenTelemetry-in-.NET-7
.NET Aspire provides built-in support for observability. You can enable this support in your AppHost project using the AddOpenTelemetry() method, as shown here:
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();
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. The following code snippet shows how you can send traces and metrics to Application Insights.
builder.Services.AddOpenTelemetry()
.WithTracing(tracing =>
tracing.AddAzureMonitorTraceExporter
(o => o.ConnectionString =
"Specify your instrumentation key here;
IngestionEndpoint=
https://dc.services.visualstudio.com"))
.WithMetrics(metrics =>
metrics.AddAzureMonitorMetricExporter
(o => o.ConnectionString =
"Specify your instrumentation key here;
IngestionEndpoint=
https://dc.services.visualstudio.com"));
Note that you should install the Azure.Monitor.OpenTelemetry.Exporter NuGet package to run this code.
When deploying your local database to Azure, a new database will be created in Azure as a replica of your local database. If you want, you can specify a different name for the Azure database.
Configure Logging in .NET Aspire
The following snippet of auto-generated code in the Extensions.cs file of the ServiceDefaults project can be used to configure logging in your .NET Aspire application:
builder.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddMeter(
"Microsoft.AspNetCore.Hosting");
metrics.AddMeter(
"Microsoft.AspNetCore.Server.Kestrel");
// Add custom metrics
metrics.AddMeter(
"AspireDemo.CustomMetrics");
})
.WithTracing(tracing =>
{
tracing.AddSource(
"AspireDemo.CustomTracing");
});
Listing 7 shows how you can use the AddLogging method on the builder.Services to configure the logging providers.
Listing 7: Configuring the Logging Providers
public async Task
GetWebResourceRootReturnsOkStatusCodeWithLogging()
{
// Arrange
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.AspireApp_AppHost>();
builder.Services.ConfigureHttpClientDefaults(
clientBuilder =>
{
clientBuilder.AddStandardResilienceHandler();
});
builder.Services.AddLogging
(logging => logging.AddConsole() // Outputs logs to console
.AddFilter("Default", LogLevel.Information)
.AddFilter("Microsoft.AspNetCore", LogLevel.Warning)
AddFilter("Aspire.Hosting.Dcp", LogLevel.Warning));
await using var app =
await builder.BuildAsync();
await app.StartAsync();
// Act
var httpClient = app.CreateHttpClient("webfrontend");
using var cts = new CancellationTokenSource
(TimeSpan.FromSeconds(30));
await app.ResourceNotifications
.WaitForResourceHealthyAsync(
"webfrontend",
cts.Token);
var response =
await httpClient.GetAsync("/");
// Assert
Assert.Equal(
HttpStatusCode.OK,
response.StatusCode);
}
Next, create a new API controller named ObservabilityController in a file having the same name with a .cs extension. This controller will be used to aggregate database and telemetry data in a single place. Listing 8 shows the complete source code of the ObservabilityController class.
Listing 8: The ObservabilityController class
using InventoryManagement.ApiService.Data;
using InventoryManagement.ApiService.Models;
using InventoryManagement.ApiService.Telemetry;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace InventoryManagement.ApiService.Controllers;
[ApiController]
[Route("api/[controller]")]
public sealed class ObservabilityController : ControllerBase
{
private readonly InventoryDbContext _context;
private readonly ProductTelemetryState _telemetryState;
private readonly ILogger<ObservabilityController> _logger;
public ObservabilityController(
InventoryDbContext context,
ProductTelemetryState telemetryState,
ILogger<ObservabilityController> logger)
{
_context = context;
_telemetryState = telemetryState;
_logger = logger;
}
[HttpPost("products/page-view")]
public IActionResult TrackProductsPageView()
{
_telemetryState.IncrementProductsPageViews();
_telemetryState.AddLog("Information",
"Products page viewed");
return Accepted();
}
[HttpGet("products")]
public async Task<ActionResult
<ProductTelemetrySnapshotDto>>
GetProductsTelemetry(CancellationToken cancellationToken)
{
var totalProducts = await
_context.Products.CountAsync(cancellationToken);
var totalInventoryQuantity =
await _context.Inventories.SumAsync
(x => x.QuantityAvailable, cancellationToken);
var lowStockProducts = await _context.Inventories
.CountAsync(x =>
x.ReorderPoint.HasValue &&
x.QuantityAvailable <=
x.ReorderPoint.Value, cancellationToken);
var dto = new ProductTelemetrySnapshotDto
{
Summary = new ProductTelemetrySummaryDto
{
TotalProducts = totalProducts,
LowStockProducts = lowStockProducts,
TotalInventoryQuantity =
totalInventoryQuantity,
ProductsListedCount =
_telemetryState.ProductsListedCount,
ProductsRetrievedCount =
telemetryState.ProductsRetrievedCount,
ProductsCreatedCount =
_telemetryState.ProductsCreatedCount,
ProductsUpdatedCount =
_telemetryState.ProductsUpdatedCount,
ProductsDeletedCount =
_telemetryState.ProductsDeletedCount,
ProductsPageViews =
_telemetryState.ProductsPageViews,
GeneratedAtUtc = DateTime.UtcNow
},
Metrics =
[
new ProductTelemetryMetricDto
{
Name = "Total Products",
Value = totalProducts,
Unit = "count",
Description = "Current number of products"
},
new ProductTelemetryMetricDto
{
Name = "Low Stock Products",
Value = lowStockProducts,
Unit = "count",
Description = "Products at or below
reorder point"
},
new ProductTelemetryMetricDto
{
Name = "Inventory Quantity",
Value = totalInventoryQuantity,
Unit = "items",
Description = "Total available
inventory quantity"
},
new ProductTelemetryMetricDto
{
Name = "Products Listed",
Value = _telemetryState.ProductsListedCount,
Unit = "count",
Description = "Total product rows returned
by list requests"
},
new ProductTelemetryMetricDto
{
Name = "Products Retrieved",
Value = _telemetryState.ProductsRetrievedCount,
Unit = "count",
Description = "Total successful single-product
fetches"
},
new ProductTelemetryMetricDto
{
Name = "Products Created",
Value = _telemetryState.ProductsCreatedCount,
Unit = "count",
Description = "Total created products"
},
new ProductTelemetryMetricDto
{
Name = "Products Updated",
Value = _telemetryState.ProductsUpdatedCount,
Unit = "count",
Description = "Total updated products"
},
new ProductTelemetryMetricDto
{
Name = "Products Deleted",
Value = _telemetryState.ProductsDeletedCount,
Unit = "count",
Description = "Total deleted products"
},
new ProductTelemetryMetricDto
{
Name = "Products Page Views",
Value = _telemetryState.ProductsPageViews,
Unit = "count",
Description = "Observed UI visits to the
products page"
}
],
RecentLogs = _telemetryState.GetRecentLogs(),
RecentTraces = _telemetryState.GetRecentTraces()
};
_logger.LogInformation ("Generated product
telemetry snapshot at {GeneratedAtUtc}",
dto.Summary.GeneratedAtUtc);
return Ok(dto);
}
}
Figure 8 shows product telemetry data displayed in the web browser.
When you click the Traces tab, the traces will be displayed as shown in Figure 9.
Testing .NET Aspire Distributed Applications
Enforcing software quality involves several strategies, including testing. There are several ways in which you can test your application:
- Unit Testing—testing individual parts/units of code
- Integration Testing—testing how parts of your application work together
- Functional Testing—testing independent features in your application
- System Testing—testing that your application works according to both functional and non-functional requirements after the integration testing is complete
- User Acceptance Testing—testing that has been done on the whole app with real users
- Regression Testing—retesting the application to confirm that nothing has broken as a result of recent code changes
Integration tests are used to test an application on a much broader scope than unit tests. While unit tests are typically used to test individual code units in isolation, integration tests are used to test the application together with its components.
In this section, we'll examine how to implement unit and integration tests for our Aspire app. Unit testing focuses on testing each independent component in isolation, often using mocks and stubs for any dependencies. Integration testing, by contrast, examines how multiple components work together and typically uses real dependencies, such as a database or an API.
Install NuGet Packages
To implement unit and integration testing for our application, we'll need the xunit, Moq, and FluentAssertions packages. To install the required 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 xunit, Moq, and FluentAssertions in the search box and install them one after the other. You can also type the commands shown below at the NuGet Package Manager command prompt:
PM> Install-Package Aspire.Hosting.Testing
PM> Install-Package xunit
PM> Install-Package Moq
PM> Install-Package FluentAssertions
Alternatively, you can install these packages by executing the following commands at the Windows shell:
dotnet add package xunit
dotnet add package Moq
dotnet add package FluentAssertions
dotnet add package Aspire.Hosting.Testing
Understanding the Flow
When you test an Aspire application, the Aspire runtime will load the complete solution including the AppHost and all its resources in the memory. Figure 10 shows how a .NET Aspire test project starts the AppHost which in turn starts the application and its components and resources.
Here is how the complete flow works:
- When you run the test, the test project in turn starts the AppHost.
- The AppHost orchestrates all dependent app resources.
- Next, the AppHost runs other components such as the database, API, and any frontend applications.
- The test project sends an HTTP request to the frontend application (Blazor, React, etc.).
Unit Tests
Since we've selected the testing feature when creating the Aspire application, a test project will be created automatically. In the test project, create a new class named ProductRepositoryTests and add the following code.
private static InventoryDbContext
CreateContext(string dbName)
{
var options = new DbContextOptionsBuilder
<InventoryDbContext>()
.UseInMemoryDatabase(dbName)
.Options;
var context = new InventoryDbContext(options);
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
return context;
}
The CreateContext(string dbName) method builds an InventoryDbContext for unit tests using EF Core's in-memory database feature, configured by calling UseInMemoryDatabase(dbName). The method also calls EnsureDeleted, which drops any database left over from a previous test run, and EnsureCreated, which creates a fresh database for the current test.
In Listing 9 we'll add the following three test methods to the ProductRepositoryTests class.
Listing 9: ProductRepository Unit Tests: AddAsync, GetAllAsync, and UpdateAsync
[Fact]
public async Task AddAsync_Should_Add_Product()
{
await using var context = CreateContext(nameof(
AddAsync_Should_Add_Product));
var repository =
new ProductRepository( context,
NullLogger<ProductRepository>.Instance,
new ProductTelemetryState());
var product = new Product
{
ProductCode = "PRD-100",
ProductName = "Keyboard",
ProductCategory = "Electronics",
CreatedDate = DateTime.UtcNow
};
var result = await repository.AddAsync(product);
Assert.NotNull(result);
Assert.Equal("PRD-100", result.ProductCode);
Assert.Single(context.Products);
}
[Fact]
public async Task GetAllAsync_Should_Return_Products_
Ordered_By_Name()
{
await using var context = CreateContext(nameof(
GetAllAsync_Should_Return_Products_
Ordered_By_Name));
context.Products.AddRange(
new Product
{
ProductCode = "PRD-200",
ProductName = "Mouse",
CreatedDate = DateTime.UtcNow
},
new Product
{
ProductCode = "PRD-201",
ProductName = "Adapter",
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var repository =
new ProductRepository(
context,
NullLogger<ProductRepository>
.Instance,
new ProductTelemetryState());
var result =
(await repository.GetAllAsync())
.ToList();
Assert.Equal(2, result.Count);
Assert.Equal(
"Adapter",
result[0].ProductName);
Assert.Equal(
"Mouse",
result[1].ProductName);
}
[Fact]
public async Task
UpdateAsync_Should_Return_Null_When_
Product_Does_Not_Exist()
{
await using var context =
CreateContext(nameof(
UpdateAsync_Should_Return_Null_
When_Product_Does_Not_Exist));
var repository =
new ProductRepository(
context,
NullLogger<ProductRepository>.Instance,
new ProductTelemetryState());
var product = new Product
{
ProductId = 999,
ProductCode = "PRD-999",
ProductName = "Unknown"
};
var result = await repository.UpdateAsync(product);
Assert.Null(result);
}
As you read Listing 9, the following is the purpose of each of the unit test methods in brief:
- The AddAsync_Should_Add_Product unit test method creates a new in-memory database context, instantiates the repository with the context, creates an instance of Product and then calls the AddAsync method to add the Product instance to the database. It also verifies if the returned product instance has the right data and the database contains one record.
- The GetAllAsync_Should_Return_Products_Ordered_By_Name unit test method seeds two product records into the in-memory database and calls the GetAllAsync method to get product data and then converts the data to a list. Lastly, it verifies if the data in the list contains both products and is ordered correctly by ProductName.
- The UpdateAsync_Should_Return_Null_When_Product_Does_Not_Exist method works by first creating an empty database—one that contains a product table without any records—and then calling UpdateAsync with a product instance whose ProductId does not exist in the database. Lastly, the method verifies that the returned value is null, since no matching product record exists for the nonexistent ProductId.
Integration Tests
Create a new file named IntegrationTests.cs and replace the auto-generated code with the code in **Listing 10.
Listing 10: IntegrationTests Class with a Test for the GET /api/product Endpoint
using InventoryManagement.ApiService.Models;
using Microsoft.Extensions.Logging;
using System.Net.Http.Json;
namespace InventoryManagement.Tests;
public class IntegrationTests
{
private static readonly
TimeSpan DefaultTimeout =
TimeSpan.FromSeconds(30);
[Fact]
public async Task
GetAll_Should_Return_Success_And_Seeded_Products()
{
// Arrange
var cancellationToken =
TestContext.Current.CancellationToken;
var appHost = await
DistributedApplicationTestingBuilder.CreateAsync
<Projects.InventoryManagement_AppHost>
(cancellationToken);
appHost.Services.AddLogging(logging =>
{
logging.SetMinimumLevel(LogLevel.Debug);
logging.AddFilter(
appHost.Environment.ApplicationName,
LogLevel.Debug);
logging.AddFilter("Aspire.", LogLevel.Debug);
});
appHost.Services.ConfigureHttpClientDefaults(
clientBuilder =>
{
clientBuilder.AddStandardResilienceHandler();
});
await using var app = await appHost.BuildAsync(
cancellationToken)
WaitAsync(DefaultTimeout,
cancellationToken);
await app.StartAsync(
cancellationToken).WaitAsync(
DefaultTimeout, cancellationToken);
// Act
var resourceNotificationService = app.Services
.GetRequiredService<ResourceNotificationService>();
await resourceNotificationService
.WaitForResourceAsync("apiservice",
KnownResourceStates.Running,
cancellationToken)
.WaitAsync(TimeSpan.FromSeconds(60),
cancellationToken);
var httpClient = app.CreateHttpClient("apiservice");
using var response = await httpClient.GetAsync(
"api/product", cancellationToken);
response.EnsureSuccessStatusCode();
var products = await response.Content.ReadFromJsonAsync
<List<Product>>(cancellationToken);
Assert.NotNull(products);
// Assert
Assert.Equal(HttpStatusCode.OK,
response.StatusCode);
}
}
**The test method in Listing 10 uses the Aspire.Hosting.Testing package, which allows for conducting complete end-to-end integration testing of your .NET Aspire distributed application. The integration test method validates whether the web client receives the expected result—a list of products.
Let's now look at how the integration test method works. The following code snippet creates a test instance of your Aspire AppHost project. It uses the DistributedApplicationTestingBuilder to create the full distributed app configuration, such as the API, web frontend, database, and so on.
var appHost = await
DistributedApplicationTestingBuilder
.CreateAsync
<Projects.InventoryManagement_AppHost>
(cancellationToken);
The following snippet configures logging for the app and Aspire components.
appHost.Services.AddLogging(logging =>
{
logging.SetMinimumLevel(LogLevel.Debug);
logging.AddFilter(
appHost.Environment.ApplicationName,
LogLevel.Debug);
logging.AddFilter
("Aspire.", LogLevel.Debug);
});
Next, the following snippet adds resilience handlers such as retry and circuit breaker to the HttpClient instance.
await using var app =
await appHost.BuildAsync
(cancellationToken).WaitAsync
(DefaultTimeout, cancellationToken);
await app.StartAsync(cancellationToken)
.WaitAsync(DefaultTimeout, cancellationToken);
Finally, it builds and starts the .NET Aspire distributed application.
Conclusion
In this article, we built a clean, modular, and testable .NET Aspire application using EF Core and C#. We also examined how to implement observability in the application and discussed how to test a .NET Aspire application using xUnit.
.NET Aspire is not just an orchestration system but a cloud-native control plane for developer experiences. The future will see a tighter feedback loop between services, observability and deployment, allowing developers to rapidly view all their configuration, logs, metrics, and traces in one place without bloating the application.
When combined with service-to-service resiliency, environment-specific configuration, secure secret management, and reproducible infrastructure provisioning, .NET Aspire will provide increased value to teams. Building upon the abilities of Aspire, teams can utilize not only local app startup but create a common toolset to build, observe, and deliver distributed systems in development and deployment environments.
In the future, Aspire will become the foundational layer for establishing an opinionated and consistent platform for building Microsoft .NET applications. This will simplify local development and eliminate many CI/CD, cloud deployment, and operational visibility patterns.
Takeaways
Here are the key takeaways at a glance:
- .NET Aspire helps you simplify the integration of local development resources into your application, thereby enabling a simple way to discover services across your entire distributed application.
- The App Host is where you can write your code to specify your dependencies and manage connectivity, thereby reducing boilerplate code and ensuring architectural consistency.
- In .NET Aspire, the Service Default project is where you specify centralized configuration for OpenTelemetry, health checks, logging, and service discovery.
- Creating integration points with external systems allows you to write code that is independent of the service you are calling, which simplifies the way you write service-to-service communications.
- Any service that connects to another service can use Aspire's service discovery capabilities, which means you will have all of the benefits of using a resilient, type-safe communication method without worrying about how you connect to the other service.
- With Aspire's support for observability, you can monitor and debug your distributed application using the Aspire Dashboard, your own telemetry endpoints, or through integrated tracing.



