Introduction
The first articles in this series mainly looked at the process of migrating software applications. Today we'll cover a number of additional aspects driven by the changes the IT landscape has undergone over the last 10 to 15 years. In the past, core applications were typically hosted on dedicated servers — often running Windows (Server). Today, deployment to cloud environments or on-premise Kubernetes clusters plays a much bigger role. As a result, topics such as:
seamless deployment (think DevOps and DevSecOps),
(horizontal) scaling,
telemetry and monitoring,
have become more and more important. Modern development stacks like .NET Core (see the previous post) now offer many features to support these processes and requirements that simply didn't exist in older versions. Migrating a legacy application can therefore never be a straight 1:1 port to current SDKs and technologies; these increasingly important, current-day requirements need to be considered as well.
In what follows, I'll share some experiences and approaches we collected over the last year across a range of migration projects. These included, among others:
rebuilding a controller-based ASP.NET system with front end and back end for a collecting society, with Kubernetes and ASP.NET Core as the target environment,
modernising a remote-control website along with its associated services and VPN infrastructure (used to control remote devices via API and VNC), again migrated to the .NET (Core) platform,
migrating a Xamarin.Forms app for device control (over UDP broadcast and HTTP REST API) to .NET MAUI with support for iOS, Android and Windows UWP.
The order in which the approaches are presented below is arbitrary and doesn't claim to be exhaustive. How relevant each one is depends very much on the type of application.
Replacing file-based logging with OpenTelemetry
In modern software development, effective logging is essential — for monitoring applications, diagnosing problems and tuning performance. Traditionally, many applications relied on file-based logging, with log data written to disk on the local server. As distributed systems grow more complex and architectures shift to the cloud, this model runs into its limits.
OpenTelemetry has emerged as a standardised, platform-independent solution that lets you collect metrics, logs and traces from a wide range of sources. Integrating OpenTelemetry into ASP.NET Core applications is straightforward and offers a number of advantages over both traditional file-based logging and proprietary logging solutions:
Centralised monitoring: OpenTelemetry makes it possible to collect and analyse log data from many services and components in one central place. That makes monitoring and troubleshooting much easier in complex, distributed systems.
Standardisation: as an open-source project, OpenTelemetry provides a single standard for telemetry data. This promotes interoperability across tools and platforms and helps avoid vendor lock-in.
Scalability: OpenTelemetry is designed for use in large-scale, scalable applications. Depending on the back end, it can process large volumes of telemetry data efficiently without weighing down the application itself.
Better insights: with support for distributed tracing, developers can follow the execution of a request as it travels across services. This gives deeper insight into system behaviour and helps you find bottlenecks quickly.
Cloud-native support: OpenTelemetry was designed specifically for cloud-based environments and integrates smoothly with modern cloud technologies and services.
Lower maintenance effort: because OpenTelemetry is provided as a managed service or as easily updated libraries, you spend less time maintaining and updating it than you would with bespoke logging solutions.
There are a number of options for building a capable back end:
For development, the Aspire Dashboard is usually enough.
Open source: OpenTelemetry Collector, Prometheus, Loki, Grafana or the ELK Stack (Elasticsearch / Kibana).
Commercial providers such as Honeycomb, Datadog, SigNoz, Aspecto and others.
Replacing "classic" ConfigurationManager-based configuration with the IConfiguration interface
Moving from the traditional .NET ConfigurationManager to the IConfiguration interface in ASP.NET Core turned out to be a clear win, particularly in our Rancher RKE2 K8s environment. The reasons can be summarised as follows:
Flexible configuration sources:
IConfigurationsupports a wide range of configuration sources — JSON, XML and INI files, environment variables, command-line arguments and custom sources. That allows configurations to be shaped dynamically and flexibly without having to touch the code.Easy integration of environment variables: in cloud environments it's common (and sensible) to control configuration via environment variables.
IConfigurationintegrates these seamlessly, which makes it easy to adapt the application to different environments.Dependency-injection support:
IConfigurationis fully integrated with ASP.NET Core's dependency-injection system. That encourages loose coupling and makes testing and maintenance easier.Hierarchical configurations: with
IConfiguration, configuration values can be organised hierarchically. That makes complex configurations easier to read.Runtime updates: certain configuration sources can be set up to be refreshed at runtime. That's particularly useful in environments where downtime needs to be kept to a minimum.
Cloud-native orientation:
IConfigurationis designed for modern, cloud-native applications and supports the principles of the 12-Factor App methodology, which in turn improves scalability and portability.
Benefits of configuration via environment variables in cloud-based environments:
Environment-specific configuration: environment variables make it easy to vary configurations between development, test and production environments without changing the application code.
Security: sensitive data such as connection strings or API keys can be managed securely via environment variables rather than being kept in configuration files or in the code.
Simple scaling and rollout: in cloud environments and container orchestrators such as Kubernetes or Docker Swarm, environment variables can be managed centrally and pushed automatically to new instances.
Fewer configuration errors: because environment variables live outside the application, the risk of configuration errors during deployment is reduced.
Continuous integration and deployment (CI/CD): environment variables fit naturally into CI/CD pipelines, which makes automated deployments to different environments much easier.
ASP.NET HealthChecks: use and benefits
ASP.NET HealthChecks are another useful feature that should be added to any migrated ASP.NET application — they make it straightforward to monitor the application's availability. They let developers and system administrators confirm that the services and resources their application depends on are working as expected, both at a general level (disk space, TLS certificate validity, …) and through very specific, application-tailored checks.
1. What are ASP.NET HealthChecks?
ASP.NET HealthChecks are partly predefined and partly custom-built (as C# classes) mechanisms for verifying the state of different parts of an application — databases, APIs, caches or other external services. They report on whether a given required resource is available and functioning. These checks are particularly important in distributed systems and microservice architectures with many dependencies.
Health checks are registered as middleware in ASP.NET Core and can be queried via an endpoint (typically /health). They return standardised information that monitoring and alerting tools can consume — for example for liveness and readiness probes in Kubernetes. You can get either an aggregated single-word output (Healthy, Degraded, Unhealthy) or a more detailed JSON response.
2. Benefits of ASP.NET HealthChecks
Early problem detection: HealthChecks help you find issues before they affect end users. Monitoring can regularly verify that all components of the application are working correctly.
Easy integration: ASP.NET HealthChecks are easy to add to an existing application — even retrofitted — because they're part of the ASP.NET Core framework and can be driven via simple configuration.
Automated monitoring and alerting: HealthCheck results can be fed into monitoring systems such as Prometheus, Grafana or Azure Application Insights. That allows immediate notifications when something goes wrong.
Customisable and extensible: ASP.NET Core ships with a wide range of predefined HealthChecks, and developers can implement their own checks tailored to their application's needs.
3. Predefined HealthChecks in ASP.NET Core
ASP.NET Core comes with a number of predefined HealthChecks for common scenarios. A few examples:
SQL Server HealthCheck: verifies that a SQL Server database server is reachable and responsive by running a simple query. Useful for making sure the database connection is healthy.
Redis HealthCheck: checks that a Redis cache is reachable. Important if your application uses Redis as a store or cache and needs to be sure data can be retrieved quickly and reliably.
Uri HealthCheck: verifies whether an external API or web service is reachable. This check can be used to confirm that all external dependencies of your application are available.
Disk Storage HealthCheck: watches the free space on a given drive to make sure your application has enough room to operate correctly.
Memory HealthCheck: checks that your application stays within its configured memory limits, helping to avoid out-of-memory failures.
4. Implementing your own HealthChecks
To use HealthChecks in ASP.NET Core, they need to be configured in Program.cs:
services.AddHealthChecks()
.AddSqlServer(Configuration.GetConnectionString("DefaultConnection"))
.AddRedis(Configuration["RedisConnectionString"])
.AddCheck<CustomHealthCheck>("custom_check");
:
app.UseEndpoints(endpoints =>
{
endpoints.MapHealthChecks("/health");
});
Here a SQL Server and a Redis HealthCheck are added, along with a custom HealthCheck that can run additional verifications.
5. HealthChecks UI: the visualisation layer
The ASP.NET Core HealthChecks UI is an extension that provides a user-friendly interface for monitoring the state of your HealthChecks in real time. It lists all registered HealthChecks and their results, letting you see at a glance whether and where there are any issues.
Installing the HealthChecks UI:
To use the HealthChecks UI, add the corresponding packages to your project:
dotnet add package AspNetCore.HealthChecks.UI
dotnet add package AspNetCore.HealthChecks.UI.InMemory.Storage
The UI can then be configured in Program.cs:
services.AddHealthChecksUI()
.AddInMemoryStorage();
:
app.UseEndpoints(endpoints =>
{
endpoints.MapHealthChecksUI();
});
The UI is served under /healthchecks-ui and provides a visual overview of the current state of each check.
Using an overview dashboard
Applications migrated with the help of a modern tech stack tend not to be monolithic any more — they consist of various components or services. Each component generally exposes a number of endpoints: not just business endpoints, but also the HealthCheck endpoints mentioned above, plus often other administrative endpoints such as statistics and telemetry data.
To give administrators or DevOps a quick overview, an application-specific dashboard is, in our experience, a real help: it shows the status of each component at a glance and surfaces the administrative links per component so they can be opened directly. You can, of course, get similar information out of tools like ArgoCD or Grafana — but those usually contain far too much for a simple overview.
That's why we built a small, flexibly configurable dashboard application of our own.
Reworking APIs — and possibly replacing controller-based APIs with Minimal APIs or FastEndpoints
Replacing controller-based ASP.NET APIs with Minimal APIs is more of a refactoring exercise. Despite the extra up-front effort, the benefits are worth weighing up:
Less boilerplate: Minimal APIs reduce the amount of code you have to write considerably. Endpoints can be defined directly in
Program.cs, without separate controller classes or extensive routing configuration.Better performance: with less overhead and a simpler middleware pipeline, Minimal APIs often perform better than traditional MVC controllers. They also support native AOT compilation (unlike the MVC approach) — which can deliver a substantial performance boost.
Simplicity and faster development: they make it easier to get small services or microservices off the ground quickly, since less setup and configuration are required.
Direct control over routing and middleware: developers get more flexibility when defining routes and can use middleware more selectively.
Faster application startup: fewer initialised components mean shorter startup times.
Another alternative is the FastEndpoints library, with advantages over both MVC controllers and Minimal APIs:
Structured organisation: FastEndpoints offers a clear separation between request, processing and response through the use of dedicated endpoint classes. This encourages clean, maintainable code.
Additional functionality without the overhead: the library adds features such as automated validation without bringing the overhead of MVC controllers along for the ride.
Better performance: FastEndpoints is optimised for high performance and can be faster than both MVC controllers and Minimal APIs, since it avoids unnecessary abstractions.
Built-in validation and filters: built-in support for request validation, authentication and authorisation makes it easier to build secure APIs.
A consistent development experience: thanks to its endpoint-class model, FastEndpoints encourages consistent coding standards across a team or project.
Flexibility and customisation: developers can easily extend and adapt the pipeline, which is often more complicated with MVC controllers.
Better support for API versioning and documentation: FastEndpoints makes it easier to implement API versioning and to integrate tools such as Swagger for API documentation.
Overall, we'd only recommend migrating to FastEndpoints or Minimal APIs if you can guarantee a consistent approach across all sub-projects. That saves developers who work across several (sub-)projects the mental cost of constantly switching paradigms.
Using Problem Details per RFC 9457
Using Problem Details (not only) in ASP.NET Core makes error handling and error presentation in web APIs much easier. Problem Details is a standardised format per RFC 9457 (which replaces RFC 7807) for error responses, providing consistent, machine-readable information about the issues that occurred.
Standardised error responses: implementing Problem Details means that clients receive error information in a uniform format, which simplifies handling and display of errors.
Consistent, detailed error information: Problem Details allows additional details such as
type,title,status,detailandinstanceto be included in error responses. That makes debugging and troubleshooting easier.Better security: with
app.UseExceptionHandler()you can catch and handle exceptions centrally, without leaking sensitive information to the client.Customisable error handling: by registering
builder.Services.AddProblemDetails(), developers can customise the way errors are surfaced — for example by adding custom error codes or extra metadata.Support for various HTTP status codes:
app.UseStatusCodePages()ensures that even HTTP status codes without a response body (such as 404 or 500) return an informative response in the Problem Details format.
Configuration details:
- builder.Services.AddProblemDetails():
Service registration: this method adds the services required to use Problem Details.
Configuration options: developers can set options such as whether to include exception details, or customise error responses by environment (development, staging, production).
Example:
builder.Services.AddProblemDetails(options =>
{
options.IncludeExceptionDetails = (context, exception) =>
{
// Only include exception details in the development environment
var env = context.RequestServices.GetRequiredService<IHostEnvironment>();
return env.IsDevelopment();
};
});
- app.UseExceptionHandler():
Global exception handling: this middleware catches any unhandled exceptions that occur in the application.
Integration with Problem Details: in combination with Problem Details, detailed error information can be returned without exposing internal implementation details.
Security aspect: prevents stack traces or sensitive information from being leaked to the client.
- app.UseStatusCodePages():
Handling status codes without a body: this middleware generates responses for HTTP status codes that normally have no content.
Consistent error presentation: ensures that errors such as 404 (Not Found) or 401 (Unauthorized) also return informative responses in the Problem Details format.
Example configuration:
app.UseStatusCodePages(context =>
{
var problemDetailsFactory = context.HttpContext.RequestServices.GetRequiredService<ProblemDetailsFactory>();
var problemDetails = problemDetailsFactory.CreateProblemDetails(context.HttpContext, context.HttpContext.Response.StatusCode);
return context.HttpContext.Response.WriteAsJsonAsync(problemDetails);
});
Summary of the benefits:
Improved developer productivity: thanks to central error handling and a standardised format, developers spend less time on bespoke error-handling code.
Better user experience: clients receive clear, consistent error messages, which makes interacting with the API easier.
Simpler monitoring and logging: uniform error responses make monitoring and analysis in production environments more straightforward.
Flexibility and extensibility: the configuration can be tailored to the application's specific needs, for example by adding custom properties to Problem Details.
Complete example implementation:
builder.Services.AddProblemDetails(options =>
{
options.IncludeExceptionDetails = (context, exception) =>
{
// Only include exception details in the development environment
var env = context.RequestServices.GetRequiredService<IHostEnvironment>();
return env.IsDevelopment();
};
});
// Further service registrations
builder.Services.AddControllers();
var app = builder.Build();
// Use ExceptionHandler and StatusCodePages
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Replacing "heavyweight" DB servers
Another consideration when migrating into a container-based environment is making the best possible use of resources. In .NET applications we migrated to .NET (Core) over the past year, the data store was often MS SQL Server, hosted on dedicated hardware or in its own VM. Given its rather high resource appetite, it's worth asking whether it can be replaced with a more lightweight database such as PostgreSQL, MySQL or MariaDB. Often, the "power" of MS SQL Server, with its countless features, isn't really needed for every application. In our experience, small to medium-sized database models can be migrated with reasonable effort, especially when an ORM layer such as Entity Framework is in use. The result:
Lower RAM requirements (MS SQL 1–4 GB, MariaDB 512 MB, PostgreSQL 1 GB).
Less disk space for the DB server (MS SQL ~3 GB, MariaDB 200–660 MB, PostgreSQL ~300 MB).
Lower CPU usage.
More throughput at comparable configuration levels.
Taking advantage of new testing options, especially for integration tests
ASP.NET Core also offers considerably better options for integration tests than the "classic" .NET Framework did. Unlike unit tests, which exercise individual parts of your application, integration tests check how various components — routing, middleware, controllers, data-access layers — work together.
A proven approach for integration tests in ASP.NET Core is the Microsoft.AspNetCore.Mvc.Testing library. It provides the WebApplicationFactory<TEntryPoint> class, which can be used to spin up a test server that accepts HTTP requests against your API.
Below is a step-by-step guide, with C# example code, showing how integration tests can be set up and executed for an ASP.NET Core API.
Step 1: Setting up the test project
Create a new test project:
Add a new xUnit test project to the solution. This can be done via Visual Studio or the .NET CLI:
dotnet new xunit -o YourApi.Tests
Add project references:
Add a project reference to the API project so the test project can access its
ProgramorStartupclass.Install the required NuGet packages:
Install the necessary packages in the test project:
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Microsoft.NET.Test.Sdk
Step 2: Integration tests with WebApplicationFactory
Example code:
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Xunit;
using Microsoft.AspNetCore.Mvc.Testing;
using YourApiNamespace; // Replace this with the namespace of your API
namespace YourApi.Tests
{
public class IntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public IntegrationTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task Get_Endpoint_Returns_Success()
{
// Arrange
var url = "/api/values"; // Adjust the endpoint as needed
// Act
var response = await _client.GetAsync(url);
// Assert
response.EnsureSuccessStatusCode(); // Confirms the status code is 2xx
var responseString = await response.Content.ReadAsStringAsync();
Assert.NotNull(responseString);
// Additional assertions can be added here
}
[Fact]
public async Task Post_Endpoint_Returns_Created()
{
// Arrange
var url = "/api/values";
var jsonContent = "{\"name\":\"Test Value\"}";
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync(url, content);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var responseString = await response.Content.ReadAsStringAsync();
Assert.NotNull(responseString);
// Additional assertions can be added here
}
}
}
Notes:
IClassFixture<WebApplicationFactory
>: - This interface provides a shared
WebApplicationFactoryinstance across all tests. - For .NET 6 and earlier, replace
ProgramwithStartup.
- This interface provides a shared
CreateClient():
- Creates an
HttpClientthat sends requests to the in-memory test server.
- Creates an
Test methods ([Fact]):
- Use
[Fact]to mark test methods. - Each method should follow the Arrange, Act and Assert pattern.
- Use
Step 3: Customising WebApplicationFactory (optional)
If, for example, you want to use an in-memory database, you can extend WebApplicationFactory like this:
Example code:
using System.Linq;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using YourApiNamespace;
namespace YourApi.Tests
{
public class CustomWebApplicationFactory<TProgram>
: WebApplicationFactory<TProgram> where TProgram : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove the existing DbContext registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<YourDbContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
// Add an in-memory database context
services.AddDbContext<YourDbContext>(options =>
{
options.UseInMemoryDatabase("InMemoryDbForTesting");
});
// Build the service provider
var serviceProvider = services.BuildServiceProvider();
// Initialise the database
using (var scope = serviceProvider.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<YourDbContext>();
db.Database.EnsureCreated();
// Optional database seeding method
// SeedDatabase(db);
}
});
}
}
}
Notes:
CustomWebApplicationFactory:
- Lets you customise the configuration of the test server.
- Removes the existing database configuration and replaces it with an in-memory database.
YourDbContext:
- Replace with your own DbContext.
Step 4: Using CustomWebApplicationFactory in tests
Example code:
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Xunit;
using YourApiNamespace;
namespace YourApi.Tests
{
public class IntegrationTests : IClassFixture<CustomWebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public IntegrationTests(CustomWebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task Get_Endpoint_Returns_Success()
{
// Arrange
var url = "/api/values";
// Act
var response = await _client.GetAsync(url);
// Assert
response.EnsureSuccessStatusCode();
var responseString = await response.Content.ReadAsStringAsync();
Assert.NotNull(responseString);
}
// Further test methods...
}
}
Step 5: Running the tests
The tests can be run with the following command:
dotnet test
Alternatively, you can use the Test Explorer in Visual Studio.
Additional notes
Authentication:
- If the API uses authentication, you may need to mock it for tests.
Test-data seeding:
- Before each test, the in-memory database should be populated with the required test data.
Environment variables:
- You may want to set the environment to "Testing" to load specific configurations (via the
ASPNETCORE_ENVIRONMENTenvironment variable).
- You may want to set the environment to "Testing" to load specific configurations (via the
Complete example code
CustomWebApplicationFactory.cs
using System.Linq;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using YourApiNamespace;
namespace YourApi.Tests
{
public class CustomWebApplicationFactory<TProgram>
: WebApplicationFactory<TProgram> where TProgram : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove the existing DbContext registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<YourDbContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
// Add an in-memory database context
services.AddDbContext<YourDbContext>(options =>
{
options.UseInMemoryDatabase("InMemoryDbForTesting");
});
// Build the service provider
var serviceProvider = services.BuildServiceProvider();
// Initialise the database
using (var scope = serviceProvider.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<YourDbContext>();
db.Database.EnsureCreated();
// Optional database seeding method
// SeedDatabase(db);
}
});
}
}
}
IntegrationTests.cs
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Xunit;
using YourApiNamespace;
namespace YourApi.Tests
{
public class IntegrationTests : IClassFixture<CustomWebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public IntegrationTests(CustomWebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task Get_Endpoint_Returns_Success()
{
// Arrange
var url = "/api/values";
// Act
var response = await _client.GetAsync(url);
// Assert
response.EnsureSuccessStatusCode();
var responseString = await response.Content.ReadAsStringAsync();
Assert.NotNull(responseString);
}
// Further test methods...
}
}
Conclusion
Integration tests using WebApplicationFactory and HttpClient are a great way to test the full request-response pipeline and make sure all components work together seamlessly.
Further reading: