In this blog post, I will demonstrate how to use .NET Aspire and Microsoft Orleans together to build an application. To know more about these technologies, please refer to my previous blog posts: .NET Aspire and Aspirate (Aspir8) and Microsoft Orleans.
Prerequisites
- .NET 8.0
- .NET Aspire workload
- Docker Desktop
- Visual Studio Code
- Node
DEMO App
The DEMO application is consists of a Backend API built with Microsoft ASP.NET Core Minimal API featuring a single endpoint that provides information on countries of the world, including their total populations. It also includes a Frontend UI build using Next.Js, that connects to the backend API to access the countries' data.
I kept the app simple, as main focus is to demonstrate Microsoft Orleans and .NET Aspire working in action.
Let's begin
# To create a sln file named orleans-aspire
dotnet new sln -n orleans-aspire
# To create a minimal api project named orleans-aspire.ApiService
dotnet new webapi -n orleans-aspire.ApiService
# To create a next.js app named orleans-aspire.Web with all default selection
npx create-next-app@latest orleans-aspire.Web
# To create Aspire Host project named orleans-aspire.AppHost
dotnet new aspire-apphost -n orleans-aspire.AppHost
# To create Aspire ServiceDefaults project named orleans-aspire.ServiceDefaults
dotnet new aspire-servicedefaults -n orleans-aspire.ServiceDefaults
# Add orleans-aspire.ApiService, orleans-aspire.AppHost and orleans-aspire.ServiceDefaults to orleans-aspire.sln
dotnet sln orleans-aspire.sln add ./orleans-aspire.AppHost/orleans-aspire.AppHost.csproj
dotnet sln orleans-aspire.sln add ./orleans-aspire.ServiceDefaults/orleans-aspire.ServiceDefaults.csproj
dotnet sln orleans-aspire.sln add ./orleans-aspire.ApiService/orleans-aspire.ApiService.csproj
After performing all the steps mentioned above, your solution structure should look similar to
orleans-aspire.ApiService
The following NuGet package needs to be added to ApiService project. You can do this using CLI or by right-clicking on the specific project in the Solution Explorer.
- Microsoft.Orleans.Server (Collection of Microsoft Orleans libraries and files needed on the server.)
- OrleansDashboard (A developer dashboard for Microsoft Orleans)
- Microsoft.Orleans.Clustering.Redis (Microsoft Orleans Clustering implementation that uses Redis)
- Aspire.StackExchange.Redis (A generic RedisĀ® client that integrates with Aspire, including health checks, logging, and telemetry.)
- Swashbuckle.AspNetCore (Swagger tools for documenting APIs built on ASP.NET Core)
- Microsoft.AspNetCore.OpenApi (Provides APIs for annotating route handler endpoints in ASP.NET Core with OpenAPI annotations.)
The complete code for the ApiService is contained in Program.cs, including Models, Grain, Grain Interface, API endpoints, country data, Orleans configuration, and other configurations. For a production application, it is advisable to consider a different structure that adheres to best practices.
# Program.cs
using System.Collections.Immutable;
var builder = WebApplication.CreateBuilder(args);
builder.AddKeyedRedisClient("country-redis");
builder.UseOrleans(siloBuilder =>{
siloBuilder.UseDashboard();
});
// Add service defaults
builder.AddServiceDefaults();
// Add services to the container.
builder.Services.AddProblemDetails();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// CORS
builder.Services.AddCors();
var app = builder.Build();
app.MapDefaultEndpoints();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors(static builder => builder
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
// Configure the HTTP request pipeline.
app.UseExceptionHandler();
app.MapGet("/countries", async (IGrainFactory factory) =>
{
var countryGrain = factory.GetGrain<ICountryDetailsGrain>(Guid.Empty);
return await countryGrain.GetCountryAsync();
})
.WithName("GetCountries")
.WithOpenApi();
app.Run();
// Models
[Immutable]
[GenerateSerializer]
public record class CountryDetails(
string Name,
string Code,
int Population);
// Grain Interface
public interface ICountryDetailsGrain : IGrainWithGuidKey
{
Task<ImmutableArray<CountryDetails>> GetCountryAsync();
}
// Grain
public class CountryDetailsGrain : ICountryDetailsGrain
{
// Population data is generated by ChatGPT 4o Mini.
private readonly ImmutableArray<CountryDetails> _data = ImmutableArray.Create<CountryDetails>(
new ("Afghanistan", "AF", 42000000 ),
new ("Albania", "AL", 2800000 ),
new ("Algeria", "DZ", 46000000 ),
new ("Andorra", "AD", 80000 ),
new ("Angola", "AO", 35000000 ),
new ("Antigua and Barbuda", "AG", 100000 ),
new ("Argentina", "AR", 46000000 ),
new ("Armenia", "AM", 3000000 ),
new ("Australia", "AU", 26000000 ),
new ("Austria", "AT", 9000000 ),
new ("Azerbaijan", "AZ", 10000000 ),
new ("Bahamas", "BS", 400000 ),
new ("Bahrain", "BH", 1700000 ),
new ("Bangladesh", "BD", 170000000 ),
new ("Barbados", "BB", 300000 ),
new ("Belarus", "BY", 9400000 ),
new ("Belgium", "BE", 11500000 ),
new ("Belize", "BZ", 500000 ),
new ("Benin", "BJ", 12000000 ),
new ("Bhutan", "BT", 800000 ),
new ("Bolivia", "BO", 12000000 ),
new ("Bosnia and Herzegovina", "BA", 3300000 ),
new ("Botswana", "BW", 2500000 ),
new ("Brazil", "BR", 215000000 ),
new ("Brunei", "BN", 450000 ),
new ("Bulgaria", "BG", 6900000 ),
new ("Burkina Faso", "BF", 22000000 ),
new ("Burundi", "BI", 13000000 ),
new ("Cabo Verde", "CV", 600000 ),
new ("Cambodia", "KH", 18000000 ),
new ("Cameroon", "CM", 30000000 ),
new ("Canada", "CA", 40000000 ),
new ("Central African Republic", "CF", 5000000 ),
new ("Chad", "TD", 18000000 ),
new ("Chile", "CL", 20000000 ),
new ("China", "CN", 1400000000 ),
new ("Colombia", "CO", 53000000 ),
new ("Comoros", "KM", 800000 ),
new ("Congo, Democratic Republic", "CD", 95000000 ),
new ("Congo, Republic of the", "CG", 5000000 ),
new ("Costa Rica", "CR", 5000000 ),
new ("Croatia", "HR", 4000000 ),
new ("Cuba", "CU", 11000000 ),
new ("Cyprus", "CY", 1200000 ),
new ("Czech Republic", "CZ", 11000000 ),
new ("Denmark", "DK", 6000000 ),
new ("Djibouti", "DJ", 1000000 ),
new ("Dominica", "DM", 75000 ),
new ("Dominican Republic", "DO", 11000000 ),
new ("East Timor", "EM", 1500000 ),
new ("Ecuador", "EC", 19000000 ),
new ("Egypt", "EG", 110000000 ),
new ("El Salvador", "SV", 7000000 ),
new ("Equatorial Guinea", "GQ", 1700000 ),
new ("Eritrea", "ER", 6000000 ),
new ("Estonia", "EE", 1300000 ),
new ("Eswatini", "SZ", 1200000 ),
new ("Ethiopia", "ET", 120000000 ),
new ("Fiji", "FJ", 950000 ),
new ("Finland", "FI", 5500000 ),
new ("France", "FR", 68000000 ),
new ("Gabon", "GA", 2500000 ),
new ("Gambia", "GM", 2600000 ),
new ("Georgia", "GE", 3700000 ),
new ("Germany", "DE", 83000000 ),
new ("Ghana", "GH", 35000000 ),
new ("Greece", "GR", 10000000 ),
new ("Grenada", "GD", 110000 ),
new ("Guatemala", "GT", 20000000 ),
new ("Guinea", "GN", 14000000 ),
new ("Guinea-Bissau", "GW", 2000000 ),
new ("Guyana", "GY", 800000 ),
new ("Haiti", "HT", 12000000 ),
new ("Honduras", "HN", 11000000 ),
new ("Hungary", "HU", 9600000 ),
new ("Iceland", "IS", 400000 ),
new ("India", "IN", 1500000000 ),
new ("Indonesia", "ID", 275000000 ),
new ("Iran", "IR", 90000000 ),
new ("Iraq", "IQ", 45000000 ),
new ("Ireland", "IE", 5200000 ),
new ("Israel", "IL", 9000000 ),
new ("Italy", "IT", 59000000 ),
new ("Jamaica", "JM", 3000000 ),
new ("Japan", "JP", 123000000 ),
new ("Jordan", "JO", 11000000 ),
new ("Kazakhstan", "KZ", 20000000 ),
new ("Kenya", "KE", 58000000 ),
new ("Kiribati", "KI", 120000 ),
new ("Korea, North", "KP", 26000000 ),
new ("Korea, South", "KR", 52000000 ),
new ("Kosovo", "XK", 2000000 ),
new ("Kuwait", "KW", 4300000 ),
new ("Kyrgyzstan", "KG", 6000000 ),
new ("Laos", "LA", 8000000 ),
new ("Latvia", "LV", 1900000 ),
new ("Lebanon", "LB", 7000000 ),
new ("Lesotho", "LS", 2300000 ),
new ("Liberia", "LR", 5500000 ),
new ("Libya", "LY", 7000000 ),
new ("Liechtenstein", "LI", 40000 ),
new ("Lithuania", "LT", 2700000 ),
new ("Luxembourg", "LU", 700000 ),
new ("Madagascar", "MG", 30000000 ),
new ("Malawi", "MW", 22000000 ),
new ("Malaysia", "MY", 34000000 ),
new ("Maldives", "MV", 550000 ),
new ("Mali", "ML", 21000000 ),
new ("Malta", "MT", 530000 ),
new ("Marshall Islands", "MH", 60000 ),
new ("Mauritania", "MR", 4800000 ),
new ("Mauritius", "MU", 1300000 ),
new ("Mexico", "MX", 130000000 ),
new ("Micronesia", "FM", 120000 ),
new ("Moldova", "MD", 2600000 ),
new ("Monaco", "MC", 40000 ),
new ("Mongolia", "MN", 3500000 ),
new ("Montenegro", "ME", 700000 ),
new ("Morocco", "MA", 37000000 ),
new ("Mozambique", "MZ", 35000000 ),
new ("Myanmar", "MM", 56000000 ),
new ("Namibia", "NA", 2500000 ),
new ("Nauru", "NR", 11000 ),
new ("Nepal", "NP", 30000000 ),
new ("Netherlands", "NL", 18000000 ),
new ("New Zealand", "NZ", 5000000 ),
new ("Nicaragua", "NI", 7000000 ),
new ("Niger", "NE", 26000000 ),
new ("Nigeria", "NG", 230000000 ),
new ("North Macedonia", "MK", 2100000 ),
new ("Norway", "NO", 5700000 ),
new ("Oman", "OM", 5100000 ),
new ("Pakistan", "PK", 240000000 ),
new ("Palau", "PW", 18000 ),
new ("Panama", "PA", 4500000 ),
new ("Papua New Guinea", "PG", 9000000 ),
new ("Paraguay", "PY", 7200000 ),
new ("Peru", "PE", 35000000 ),
new ("Philippines", "PH", 115000000 ),
new ("Poland", "PL", 38000000 ),
new ("Portugal", "PT", 10000000 ),
new ("Qatar", "QA", 2900000 ),
new ("Romania", "RO", 19000000 ),
new ("Russia", "RU", 145000000 ),
new ("Rwanda", "RW", 13000000 ),
new ("Saint Kitts and Nevis", "KN", 55000 ),
new ("Saint Lucia", "LC", 200000 ),
new ("Saint Vincent and the Grenadines", "VC", 110000 ),
new ("Samoa", "WS", 200000 ),
new ("San Marino", "SM", 35000 ),
new ("Sao Tome and Principe", "ST", 220000 ),
new ("Saudi Arabia", "SA", 37000000 ),
new ("Senegal", "SN", 17000000 ),
new ("Serbia", "RS", 7000000 ),
new ("Seychelles", "SC", 100000 ),
new ("Sierra Leone", "SL", 8000000 ),
new ("Singapore", "SG", 5800000 ),
new ("Slovakia", "SK", 5400000 ),
new ("Slovenia", "SI", 2100000 ),
new ("Solomon Islands", "SB", 800000 ),
new ("Somalia", "SO", 16000000 ),
new ("South Africa", "ZA", 61000000 ),
new ("South Sudan", "SS", 12000000 ),
new ("Spain", "ES", 47000000 ),
new ("Sri Lanka", "LK", 22000000 ),
new ("Sudan", "SD", 46000000 ),
new ("Suriname", "SR", 600000 ),
new ("Sweden", "SE", 10500000 ),
new ("Switzerland", "CH", 8700000 ),
new ("Syria", "SY", 19000000 ),
new ("Taiwan", "TW", 24000000 ),
new ("Tajikistan", "TJ", 9500000 ),
new ("Tanzania", "TZ", 64000000 ),
new ("Thailand", "TH", 70000000 ),
new ("Timor-Leste", "TL", 1500000 ),
new ("Togo", "TG", 8500000 ),
new ("Tonga", "TO", 100000 ),
new ("Trinidad and Tobago", "TT", 1400000 ),
new ("Tunisia", "TN", 12000000 ),
new ("Turkey", "TR", 88000000 ),
new ("Turkmenistan", "TM", 6300000 ),
new ("Tuvalu", "TV", 11000 ),
new ("Uganda", "UG", 46000000 ),
new ("Ukraine", "UA", 41000000 ),
new ("United Arab Emirates", "AE", 9000000 ),
new ("United Kingdom", "GB", 68000000 ),
new ("United States", "US", 340000000 ),
new ("Uruguay", "UY", 3500000 ),
new ("Uzbekistan", "UZ", 35000000 ),
new ("Vanuatu", "VU", 320000 ),
new ("Vatican City", "VA", 800 ),
new ("Venezuela", "VE", 34000000 ),
new ("Vietnam", "VN", 98000000 ),
new ("Yemen", "YE", 33000000 ),
new ("Zambia", "ZM", 19000000 ),
new ("Zimbabwe", "ZW", 17000000 ));
public Task<ImmutableArray<CountryDetails>> GetCountryAsync() => Task.FromResult(_data);
}
orleans-aspire.AppHost
The following NuGet package needs to be added to AppHost project. You can do this using CLI or by right-clicking on the specific project in the Solution Explorer.
- Aspire.Hosting.NodeJs (It provides support to work with Node.Js and Npm apps)
- Aspire.Hosting.Orleans (Provides extension methods and resource definitions for a .NET Aspire AppHost to configure an Orleans cluster.)
- Aspire.Hosting.Redis (Provides extension methods and resource definitions for a .NET Aspire AppHost to configure a Redis resource.)
Additionally, ensure that the orleans-aspire.ApiService project is referenced in the orleans-aspire.AppHost project. The following code snippet illustrates the contents of the Program.cs file in the orleans-aspire.AppHost.
# Program.cs
using Projects;
var builder = DistributedApplication.CreateBuilder(args);
// Redis as infrastructure dependency
var redis = builder.AddRedis("country-redis");
// Orleans clustering
var orleans = builder.AddOrleans("country-app")
.WithClustering(redis);
// configure Orleans-aspire-ApiService
var apiService = builder.AddProject<Projects.orleans_aspire_ApiService>("countryapi")
.WithReference(orleans);
.WithReplicas(2);
// configure Orleans-aspire-web
builder.AddNpmApp("nextjs", "../orleans-aspire.Web", "dev")
.WithReference(apiService)
.WithEnvironment("BROWSER", "none")
.WithHttpEndpoint(env: "PORT")
.WithExternalHttpEndpoints();
builder.Build().Run();
Orleans-aspire.ServiceDefaults
To support Orleans Metrics and Tracing, I have made few changes in the Extensions.cs. Refer. following code snippet.
# Extensions.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace Microsoft.Extensions.Hosting;
// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution.
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
public static class Extensions
{
public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder)
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery();
});
return builder;
}
public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder)
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter("Microsoft.Orleans"); // Added for Orleans
})
.WithTracing(tracing =>
{
tracing.AddAspNetCoreInstrumentation()
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
//.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation()
.AddSource("Microsoft.Orleans.Application") // Added for Orleans
.AddSource("Microsoft.Orleans.Runtime"); // Added for Orleans
});
builder.AddOpenTelemetryExporters();
return builder;
}
private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder)
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
//{
// builder.Services.AddOpenTelemetry()
// .UseAzureMonitor();
//}
return builder;
}
public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder)
{
builder.Services.AddHealthChecks()
// Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks("/health");
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks("/alive", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
return app;
}
}
Orleans-aspire.Web
In the Next.js frontend UI, I have developed a CountryList.tsx component to display data. If you examine the code closely, you will notice a call to api/countries. It is important to understand how this operates, considering that it is not an actual backend API endpoint. To make it work, I made few modifications to the next.config.mjs file. Please refer to the code snippets below for details.
# CountryList.tsx
'use client'
import React, { useState, useEffect } from 'react';
import Loading from './Loading';
interface Country {
name: string
population: number;
code: string;
}
const CountryList: React.FC = () => {
const [countries, setCountries] = useState<Country[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchCountries = async () => {
try {
const response = await fetch('api/countries');
const data: Country[] = await response.json();
setCountries(data);
} catch (error) {
console.error('Error fetching countries:', error);
} finally {
setLoading(false);
}
};
fetchCountries();
}, []);
if (loading) {
return <Loading />;
}
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8 text-center text-blue-400">Countries of the World</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{countries.map((country) => (
<div key={country.code} className="bg-gray-800 rounded-lg shadow-md p-4 hover:bg-gray-700 transition-colors duration-200">
<h2 className="text-xl font-semibold mb-2 text-blue-300">{country.name}</h2>
<p className="text-gray-300">
Population: {country.population.toLocaleString()}
</p>
</div>
))}
</div>
</div>
</div>
);
};
export default CountryList;
# next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
async rewrites() {
return [
{
source: '/api/:path*',
destination: (process.env.services__countryapi__http__0 ||process.env.services__countryapi__https__0)+ '/:path*' ,
},
];
}
}
export default nextConfig;
Another modification I made to page.tsx was to call CountryList.tsx. Refer. below code snippet.
# page.tsx
import CountryList from '../components/CountryList';
export default function Home() {
return (
<main>
<CountryList/>
</main>
);
}
Run the App
# Run this command from orleans-aspire.Apphost folder
dotnet run
Building...
info: Aspire.Hosting.DistributedApplication[0]
Aspire version: 8.2.0+75fdcff28495bdd643f6323133a7d411df71ab70
info: Aspire.Hosting.DistributedApplication[0]
Distributed application starting.
info: Aspire.Hosting.DistributedApplication[0]
Application host directory is: /Users/ajaymishra/Source/Practice/demo/orleans-aspire.AppHost
info: Aspire.Hosting.DistributedApplication[0]
Now listening on: https://localhost:17258
info: Aspire.Hosting.DistributedApplication[0]
Login to the dashboard at https://localhost:17258/login?t=f44cbae9dd7b6df551fc1d9c266b8974
info: Aspire.Hosting.DistributedApplication[0]
Distributed application started. Press Ctrl+C to shut down.
After clicking on url for Login to the dashboard at, you can launch the Aspire dashboard as
NOTE: You might be wondering why there are two entries for orleans-aspirate.ApiService. If refer back to AppHost's Program.cs file, you will see that .WithReplicas(2) is specified, which explains this. Isn't that a useful feature?
From the Aspire dashboard, you can access Next.js Frontend UI, Backend API, Swagger UI including log Console & Structure, Traces & Metrics.
To access the Orleans dashboard, you can access at http://localhost:8080
Since, we have defined Redis in the Program.cs file for Orleans clustering within the AppHost, it is managed automatically by Docker Desktop and is also displayed as a resource on the Aspire dashboard.