A worker that imports shipments can pass its build and still behave differently on .NET 11. A blank tenant override can replace its default queue name. A failed import can make the host rethrow the worker's exception. If two scoped writers also fail to close, disposal now reports both errors.
What Changed Between .NET 10 and .NET 11?
The same console project produced these results against the .NET 10.0.15 and .NET 11.0.0 shared frameworks. They cover three of the changes in Microsoft's .NET 11 compatibility index.
| Case | .NET 10 | .NET 11 |
|---|---|---|
| A tenant configuration overrides the queue name with an empty string | Queue resolves to orders | Queue resolves to an empty string |
| Two scoped writers throw when disposed | Only the second writer attempts cleanup. IOException propagates. | Both writers attempt cleanup. AggregateException propagates. |
| One BackgroundService throws and the host uses StopHost | RunAsync completes after shutdown | RunAsync rethrows the worker exception |
To try each case, create a console app with dotnet new console -n ServiceChecks.
Replace its project file with the following and install both runtimes and their targeting packs.
The framework reference makes each run use that target's copy of the libraries.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0;net11.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
For each example, replace Program.cs in that directory and run dotnet run -f net10.0 and dotnet run -f net11.0.
Selecting the .NET 11 SDK lets you build both targets.
If your application references Microsoft.Extensions.* packages directly, check which package versions it restores for each one.
An Empty Queue Name Now Wins
Suppose a job defaults to the orders queue and can read overrides from a tenant's configuration.
One tenant leaves the field blank.
AddConfiguration(overrides) adds the tenant's configuration after the default.
using Microsoft.Extensions.Configuration;
var overrides = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Queue"] = ""
}).Build();
var effective = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Queue"] = "orders"
}).AddConfiguration(overrides).Build();
Console.WriteLine($"Queue = '{effective["Queue"]}'");
On .NET 10 this prints Queue = 'orders'.
On .NET 11 it prints Queue = '' because chained configuration preserves the empty string.
Omit the tenant's Queue key to inherit orders.
If the worker requires a queue, validate the resolved name at startup, before it tries to import the first shipment.
A Failed Dispose Can Reveal a Second Failure
The batch resolves two writers from one scope.
The scope disposes them in reverse order, so the writer named second closes first.
On .NET 10, if closing it throws, the other writer never gets its turn.
using Microsoft.Extensions.DependencyInjection;
using var services = new ServiceCollection()
.AddScoped(_ => new Writer("first"))
.AddScoped(_ => new Writer("second"))
.BuildServiceProvider();
var scope = services.CreateScope();
_ = scope.ServiceProvider.GetServices<Writer>().ToArray();
try
{
scope.Dispose();
}
catch (Exception error)
{
Console.WriteLine($"Dispose threw {error.GetType().Name}");
}
sealed class Writer(string name) : IDisposable
{
public void Dispose()
{
Console.WriteLine($"Closing {name}");
throw new IOException(name);
}
}
.NET 10 prints Closing second followed by Dispose threw IOException.
.NET 11 also prints Closing first, then reports AggregateException.
If your shutdown handler catches only IOException, it will miss that aggregate.
Its InnerExceptions collection contains the error from each writer.
When only one writer fails, the scope still throws that exception directly.
What Does a Failed Worker Report to Its Supervisor?
Since .NET 6, the default StopHost setting has shut down the host when a background service throws.
In .NET 11, the host rethrows the worker's exception to the caller awaiting RunAsync after shutdown.
Clearing the logging providers lets you see what reaches the caller without also printing the host's error log.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Services.AddHostedService<FailedJob>();
try
{
await builder.Build().RunAsync();
Console.WriteLine("RunAsync completed");
}
catch (InvalidOperationException error)
{
Console.WriteLine($"RunAsync threw: {error.Message}");
}
sealed class FailedJob : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Yield();
throw new InvalidOperationException("Shipment import failed");
}
}
The .NET 10 run prints RunAsync completed.
The .NET 11 run prints RunAsync threw: Shipment import failed.
The .NET 11 process still exits successfully here because the catch block prints the exception and returns normally. If a scheduler should retry the failed import, let the exception escape or return the exit code the scheduler treats as a failure.
Summary
When you upgrade a service from .NET 10 to .NET 11, run one failed import under the scheduler you actually use. Check the resolved queue name and the error from each writer that failed to close. Then check the job status. If the process exits successfully, a scheduler that uses exit codes will mark the run as successful even when the log contains an exception.
Frequently Asked Questions
What happens when a BackgroundService fails in .NET 11?
With the default StopHost behavior, the host stops and RunAsync propagates the exception. In .NET 10 the host also stopped, but RunAsync could complete successfully. An application that allows the exception to escape Main normally exits with a failure code.
Does an empty string override a value from an earlier configuration provider?
In .NET 11 it does when the empty string comes from a configuration registered with AddConfiguration. A missing key in the chained configuration still allows the earlier value to win.
What exception does dependency injection throw when disposal fails?
In .NET 11 the built-in container attempts to dispose all captured disposable services in reverse capture order. It rethrows a single failure directly and throws AggregateException for multiple failures.

