diff --git a/src/NServiceBus.AcceptanceTests/Core/Installers/InstallationOnlyComponent.cs b/src/NServiceBus.AcceptanceTests/Core/Installers/InstallationOnlyComponent.cs
deleted file mode 100644
index 3530475c48..0000000000
--- a/src/NServiceBus.AcceptanceTests/Core/Installers/InstallationOnlyComponent.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-namespace NServiceBus.AcceptanceTests.Core.Installers;
-
-using System.Threading;
-using System.Threading.Tasks;
-using AcceptanceTesting;
-using AcceptanceTesting.Customization;
-using AcceptanceTesting.Support;
-using Configuration.AdvancedExtensibility;
-using Installation;
-
-///
-/// Custom test component that uses the API instead of fully starting the endpoint.
-///
-public class InstallationOnlyComponent : IComponentBehavior
- where TConfigurationFactory : IEndpointConfigurationFactory, new()
-{
- public async Task CreateRunner(RunDescriptor run)
- {
- var configurationFactory = new TConfigurationFactory();
- var customizationConfiguration = configurationFactory.Get();
- customizationConfiguration.EndpointName = Conventions.EndpointNamingConvention(configurationFactory.GetType());
- var endpointConfiguration = await customizationConfiguration.GetConfiguration(run);
- RegisterScenarioContext(endpointConfiguration, run.ScenarioContext);
- var installer = Installer.CreateInstallerWithExternallyManagedContainer(endpointConfiguration, run.Services);
- return new InstallationRunner(installer, run);
- }
-
- static void RegisterScenarioContext(EndpointConfiguration endpointConfiguration, ScenarioContext scenarioContext)
- {
- var type = scenarioContext.GetType();
- var settings = endpointConfiguration.GetSettings();
-
- while (type != typeof(object))
- {
- var currentType = type;
- settings.Set(currentType.FullName, scenarioContext);
- type = type.BaseType;
- }
- }
-
- public class InstallationRunner(InstallerWithExternallyManagedContainer installer, RunDescriptor run) : ComponentRunner
- {
- public override string Name => "Installation only runner";
-
- public override Task Start(CancellationToken cancellationToken = default) => installer.Setup(run.ServiceProvider!, cancellationToken);
- }
-}
\ No newline at end of file
diff --git a/src/NServiceBus.AcceptanceTests/Core/Installers/ServiceProviderExtensions.cs b/src/NServiceBus.AcceptanceTests/Core/Installers/ServiceProviderExtensions.cs
new file mode 100644
index 0000000000..81e7469904
--- /dev/null
+++ b/src/NServiceBus.AcceptanceTests/Core/Installers/ServiceProviderExtensions.cs
@@ -0,0 +1,35 @@
+namespace NServiceBus.AcceptanceTests.Core.Installers;
+
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+static class ServiceProviderExtensions
+{
+ extension(IServiceProvider provider)
+ {
+ public async Task RunHostedServices(CancellationToken cancellationToken = default)
+ {
+ // We don't have host support in the acceptance tests, so we need to manually start/stop the services
+ var hostedServices = provider.GetServices().ToList();
+ foreach (var hostedService in hostedServices)
+ {
+ await hostedService.StartAsync(cancellationToken);
+ }
+
+ hostedServices.Reverse();
+ foreach (var hostedService in hostedServices)
+ {
+ await hostedService.StopAsync(cancellationToken);
+
+ if (hostedService is IAsyncDisposable asyncDisposable)
+ {
+ await asyncDisposable.DisposeAsync();
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoint.cs b/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoint.cs
index 0e042c3b51..a3410e4fdf 100644
--- a/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoint.cs
+++ b/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoint.cs
@@ -1,9 +1,10 @@
namespace NServiceBus.AcceptanceTests.Core.Installers;
+using System;
using System.Threading;
using System.Threading.Tasks;
using AcceptanceTesting;
-using EndpointTemplates;
+using Configuration.AdvancedExtensibility;
using FakeTransport;
using Features;
using Installation;
@@ -15,79 +16,74 @@ public class When_installing_endpoint : NServiceBusAcceptanceTest
[Test]
public async Task Should_only_execute_setup_and_complete()
{
- var context = await Scenario.Define()
- .WithComponent(new InstallationOnlyComponent())
+ var fakeTransport = new FakeTransport();
+ var endpointConfiguration = new EndpointConfiguration("EndpointWithInstaller");
+ endpointConfiguration.AssemblyScanner().Disable = true;
+ endpointConfiguration.UsePersistence();
+ endpointConfiguration.UseTransport(fakeTransport);
+ endpointConfiguration.UseSerialization();
+
+ endpointConfiguration.EnableFeature();
+
+ var context = await Scenario.Define(ctx =>
+ {
+ endpointConfiguration.GetSettings().Set(ctx);
+ })
+ .WithServices(services => services.AddNServiceBusEndpointInstaller(endpointConfiguration))
+ .WithServiceResolve(static async (provider, token) => await provider.RunHostedServices(token))
.Run();
using (Assert.EnterMultipleScope())
{
Assert.That(context.InstallerCalled, Is.True, "Should run installers");
Assert.That(context.FeatureSetupCalled, Is.True, "Should initialize Features");
- Assert.That(context.FeatureStartupTaskCalled, Is.False, "Should not start FeatureStartupTasks");
}
Assert.That(new[]
{
$"{nameof(TransportDefinition)}.{nameof(TransportDefinition.Initialize)}", $"{nameof(IMessageReceiver)}.{nameof(IMessageReceiver.Initialize)} for receiver Main",
- }, Is.EqualTo(context.TransportStartupSequence).AsCollection, "Should not start the receivers");
+ }, Is.EqualTo(fakeTransport.StartupSequence).AsCollection, "Should not start the receivers");
}
class Context : ScenarioContext
{
public bool InstallerCalled { get; set; }
public bool FeatureSetupCalled { get; set; }
- public bool FeatureStartupTaskCalled { get; set; }
- public FakeTransport.StartUpSequence TransportStartupSequence { get; set; }
- public void MaybeCompleted() => MarkAsCompleted(InstallerCalled, FeatureSetupCalled, TransportStartupSequence != null);
+ public void MaybeCompleted() => MarkAsCompleted(InstallerCalled, FeatureSetupCalled);
}
- class EndpointWithInstaller : EndpointConfigurationBuilder
+ class CustomInstaller(Context testContext) : INeedToInstallSomething
{
- public EndpointWithInstaller() =>
- EndpointSetup((c, r) =>
- {
- c.EnableFeature();
-
- // Register FakeTransport to track transport seam usage during installation
- var fakeTransport = new FakeTransport();
- c.UseTransport(fakeTransport);
- ((Context)r.ScenarioContext).TransportStartupSequence = fakeTransport.StartupSequence;
- });
-
- class CustomInstaller(Context testContext) : INeedToInstallSomething
+ public Task Install(string identity, CancellationToken cancellationToken = default)
{
- public Task Install(string identity, CancellationToken cancellationToken = default)
- {
- testContext.InstallerCalled = true;
- testContext.MaybeCompleted();
- return Task.CompletedTask;
- }
+ testContext.InstallerCalled = true;
+ testContext.MaybeCompleted();
+ return Task.CompletedTask;
}
+ }
- class CustomFeature : Feature
+ class CustomFeature : Feature
+ {
+ protected override void Setup(FeatureConfigurationContext context)
{
- protected override void Setup(FeatureConfigurationContext context)
- {
- context.AddInstaller();
+ context.AddInstaller();
- var testContext = context.Settings.Get();
- testContext.FeatureSetupCalled = true;
+ var testContext = context.Settings.Get();
+ testContext.FeatureSetupCalled = true;
- context.RegisterStartupTask(new CustomFeatureStartupTask(testContext));
- }
+ context.RegisterStartupTask(new CustomFeatureStartupTask(testContext));
+ }
- class CustomFeatureStartupTask(Context testContext) : FeatureStartupTask
+ class CustomFeatureStartupTask(Context testContext) : FeatureStartupTask
+ {
+ protected override Task OnStart(IMessageSession session, CancellationToken cancellationToken = default)
{
- protected override Task OnStart(IMessageSession session, CancellationToken cancellationToken = default)
- {
- testContext.FeatureStartupTaskCalled = true;
- testContext.MaybeCompleted();
- return Task.CompletedTask;
- }
-
- protected override Task OnStop(IMessageSession session, CancellationToken cancellationToken = default) => Task.CompletedTask;
+ testContext.MarkAsFailed(new InvalidOperationException("FeatureStartupTask should not be called"));
+ return Task.CompletedTask;
}
+
+ protected override Task OnStop(IMessageSession session, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}
}
\ No newline at end of file
diff --git a/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoints.cs b/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoints.cs
new file mode 100644
index 0000000000..7fb0e028ca
--- /dev/null
+++ b/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoints.cs
@@ -0,0 +1,114 @@
+namespace NServiceBus.AcceptanceTests.Core.Installers;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AcceptanceTesting;
+using Configuration.AdvancedExtensibility;
+using FakeTransport;
+using Features;
+using Installation;
+using NUnit.Framework;
+using Settings;
+using Transport;
+
+public class When_installing_endpoints : NServiceBusAcceptanceTest
+{
+ [Test]
+ public async Task Should_only_execute_setup_and_complete()
+ {
+ var transport1 = new FakeTransport();
+ var endpointConfiguration1 = CreateEndpointConfiguration(transport1, "EndpointWithInstaller1");
+
+ var transport2 = new FakeTransport();
+ EndpointConfiguration endpointConfiguration2 = CreateEndpointConfiguration(transport2, "EndpointWithInstaller2");
+
+ var context = await Scenario.Define(ctx =>
+ {
+ endpointConfiguration1.GetSettings().Set(ctx);
+ endpointConfiguration2.GetSettings().Set(ctx);
+ })
+ .WithServices(services =>
+ {
+ services.AddNServiceBusEndpointInstaller(endpointConfiguration1, "EndpointWithInstaller1");
+ services.AddNServiceBusEndpointInstaller(endpointConfiguration2, "EndpointWithInstaller2");
+ })
+ .WithServiceResolve(static async (provider, token) => await provider.RunHostedServices(token))
+ .Run();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(context.InstallerCalled.All(x => x.Value), Is.True, "Should run installers");
+ Assert.That(context.FeatureSetupCalled.All(x => x.Value), Is.True, "Should initialize Features");
+ }
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(new[]
+ {
+ $"{nameof(TransportDefinition)}.{nameof(TransportDefinition.Initialize)}", $"{nameof(IMessageReceiver)}.{nameof(IMessageReceiver.Initialize)} for receiver Main",
+ }, Is.EqualTo(transport1.StartupSequence).AsCollection, "Should not start the receivers");
+ Assert.That(new[]
+ {
+ $"{nameof(TransportDefinition)}.{nameof(TransportDefinition.Initialize)}", $"{nameof(IMessageReceiver)}.{nameof(IMessageReceiver.Initialize)} for receiver Main",
+ }, Is.EqualTo(transport2.StartupSequence).AsCollection, "Should not start the receivers");
+ }
+ }
+
+ static EndpointConfiguration CreateEndpointConfiguration(FakeTransport transport, string endpointName)
+ {
+ var endpointConfiguration = new EndpointConfiguration(endpointName);
+ endpointConfiguration.AssemblyScanner().Disable = true;
+ endpointConfiguration.UsePersistence();
+ endpointConfiguration.UseTransport(transport);
+ endpointConfiguration.UseSerialization();
+ endpointConfiguration.EnableFeature();
+ return endpointConfiguration;
+ }
+
+ class Context : ScenarioContext
+ {
+ public Dictionary InstallerCalled { get; set; } = [];
+ public Dictionary FeatureSetupCalled { get; set; } = [];
+
+ public void MaybeCompleted() => MarkAsCompleted(InstallerCalled.Count == 2, FeatureSetupCalled.Count == 2);
+ }
+
+ class CustomInstaller(Context testContext, IReadOnlySettings settings) : INeedToInstallSomething
+ {
+ public Task Install(string identity, CancellationToken cancellationToken = default)
+ {
+ testContext.InstallerCalled[settings.EndpointName()] = true;
+ testContext.MaybeCompleted();
+ return Task.CompletedTask;
+ }
+ }
+
+ class CustomFeature : Feature
+ {
+ protected override void Setup(FeatureConfigurationContext context)
+ {
+ context.AddInstaller();
+
+ var settings = context.Settings;
+
+ var testContext = settings.Get();
+ testContext.FeatureSetupCalled[settings.EndpointName()] = true;
+
+ context.RegisterStartupTask(new CustomFeatureStartupTask(testContext));
+ }
+
+ class CustomFeatureStartupTask(Context testContext) : FeatureStartupTask
+ {
+ protected override Task OnStart(IMessageSession session, CancellationToken cancellationToken = default)
+ {
+ testContext.MarkAsFailed(new InvalidOperationException("FeatureStartupTask should not be called"));
+ return Task.CompletedTask;
+ }
+
+ protected override Task OnStop(IMessageSession session, CancellationToken cancellationToken = default) => Task.CompletedTask;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt
index 861e06ce49..c0deb54667 100644
--- a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt
+++ b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt
@@ -1042,6 +1042,7 @@ namespace NServiceBus
public static class ServiceCollectionExtensions
{
public static void AddNServiceBusEndpoint(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, NServiceBus.EndpointConfiguration endpointConfiguration, object? endpointIdentifier = null) { }
+ public static void AddNServiceBusEndpointInstaller(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, NServiceBus.EndpointConfiguration endpointConfiguration, object? endpointIdentifier = null) { }
}
public static class SettingsExtensions
{
@@ -1573,9 +1574,12 @@ namespace NServiceBus.Installation
{
System.Threading.Tasks.Task Install(string identity, System.Threading.CancellationToken cancellationToken = default);
}
+ [System.Obsolete(@"Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint. Use 'IServiceCollection.AddNServiceBusEndpointInstaller' instead. Will be treated as an error from version 11.0.0. Will be removed in version 12.0.0.", false)]
public static class Installer
{
+ [System.Obsolete(@"Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint. Use 'IServiceCollection.AddNServiceBusEndpointInstaller' instead. Will be treated as an error from version 11.0.0. Will be removed in version 12.0.0.", false)]
public static NServiceBus.Installation.InstallerWithExternallyManagedContainer CreateInstallerWithExternallyManagedContainer(NServiceBus.EndpointConfiguration configuration, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection) { }
+ [System.Obsolete(@"Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint. Use 'IServiceCollection.AddNServiceBusEndpointInstaller' instead. Will be treated as an error from version 11.0.0. Will be removed in version 12.0.0.", false)]
public static System.Threading.Tasks.Task Setup(NServiceBus.EndpointConfiguration configuration, System.Threading.CancellationToken cancellationToken = default) { }
}
public class InstallerWithExternallyManagedContainer
diff --git a/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensionsTests.cs b/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensions_AddEndpoint_Tests.cs
similarity index 69%
rename from src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensionsTests.cs
rename to src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensions_AddEndpoint_Tests.cs
index 8aa0182d7f..c82ecc44ec 100644
--- a/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensionsTests.cs
+++ b/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensions_AddEndpoint_Tests.cs
@@ -8,7 +8,7 @@ namespace NServiceBus.Core.Tests.Host;
using NUnit.Framework;
[TestFixture]
-public class ServiceCollectionExtensionsTests
+public partial class ServiceCollectionExtensions_AddEndpoint_Tests
{
[Test]
public void Should_register_single_endpoint_without_identifier()
@@ -88,12 +88,36 @@ public void Should_throw_when_transport_not_specified()
Assert.That(ex!.Message, Does.Contain("A transport has not been configured. Use 'EndpointConfiguration.UseTransport()' to specify a transport"));
}
- static EndpointConfiguration CreateConfig(string endpointName)
+ [Test]
+ public void Should_throw_when_multiple_endpoints_assembly_scanning_enabled()
+ {
+ var services = new ServiceCollection();
+
+ services.AddNServiceBusEndpoint(CreateConfig("Sales"), "Sales");
+
+ var ex = Assert.Throws(() => services.AddNServiceBusEndpoint(CreateConfig("Billing", assemblyScanningEnabled: true), "Billing"));
+
+ Assert.That(ex!.Message, Does.Contain("When multiple endpoints are registered, each endpoint must disable assembly scanning (cfg.AssemblyScanner().Disable = true) and explicitly register its handlers and sagas using the corresponding registrations methods like AddHandler(), AddSaga() etc. The following endpoints have assembly scanning enabled: 'Billing'."));
+ }
+
+ [Test]
+ public void Should_throw_when_used_with_add_installer()
+ {
+ var services = new ServiceCollection();
+
+ services.AddNServiceBusEndpoint(CreateConfig("Sales"), "Sales");
+
+ var ex = Assert.Throws(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Billing"), "Billing"));
+
+ Assert.That(ex!.Message, Does.Contain("'AddNServiceBusEndpointInstaller' cannot be used together with 'AddNServiceBusEndpoint'."));
+ }
+
+ static EndpointConfiguration CreateConfig(string endpointName, bool assemblyScanningEnabled = false)
{
var config = new EndpointConfiguration(endpointName);
config.UseSerialization();
config.UseTransport(new LearningTransport());
- config.AssemblyScanner().Disable = true;
+ config.AssemblyScanner().Disable = !assemblyScanningEnabled;
return config;
}
}
\ No newline at end of file
diff --git a/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensions_AddInstaller_Tests.cs b/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensions_AddInstaller_Tests.cs
new file mode 100644
index 0000000000..b1c1b37e44
--- /dev/null
+++ b/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensions_AddInstaller_Tests.cs
@@ -0,0 +1,123 @@
+#nullable enable
+
+namespace NServiceBus.Core.Tests.Host;
+
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using NServiceBus;
+using NUnit.Framework;
+
+[TestFixture]
+public class ServiceCollectionExtensions_AddInstaller_Tests
+{
+ [Test]
+ public void Should_register_single_endpoint_without_identifier()
+ {
+ var services = new ServiceCollection();
+
+ Assert.DoesNotThrow(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Sales")));
+ }
+
+ [Test]
+ public void Should_register_single_endpoint_with_identifier()
+ {
+ var services = new ServiceCollection();
+
+ Assert.DoesNotThrow(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"), "sales-key"));
+ }
+
+ [Test]
+ public void Should_throw_when_first_endpoint_has_no_identifier_and_second_has_one()
+ {
+ var services = new ServiceCollection();
+
+ services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"));
+
+ var ex = Assert.Throws(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Billing"), "billing-key"));
+
+ Assert.That(ex!.Message, Does.Contain("each endpoint must provide an endpointIdentifier"));
+ }
+
+ [Test]
+ public void Should_throw_when_first_endpoint_has_identifier_and_second_has_none()
+ {
+ var services = new ServiceCollection();
+
+ services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"), "sales-key");
+
+ var ex = Assert.Throws(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Billing")));
+
+ Assert.That(ex!.Message, Does.Contain("each endpoint must provide an endpointIdentifier"));
+ }
+
+ [Test]
+ public void Should_register_multiple_endpoints_when_all_have_identifiers()
+ {
+ var services = new ServiceCollection();
+
+ services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"), "sales-key");
+
+ Assert.DoesNotThrow(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Billing"), "billing-key"));
+ }
+
+ [Test]
+ public void Should_throw_when_multiple_endpoints_have_duplicate_identifiers()
+ {
+ var services = new ServiceCollection();
+
+ services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"), "shared-key");
+
+ var ex = Assert.Throws(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Billing"), "shared-key"));
+
+ Assert.That(ex!.Message, Does.Contain("An endpoint with the identifier 'shared-key' has already been registered"));
+ }
+
+ [Test]
+ public void Should_throw_when_transport_not_specified()
+ {
+ var services = new ServiceCollection();
+
+ var ex = Assert.Throws(() =>
+ {
+ var endpointConfiguration = new EndpointConfiguration("Billing");
+ endpointConfiguration.UseSerialization();
+
+ services.AddNServiceBusEndpointInstaller(endpointConfiguration);
+ });
+
+ Assert.That(ex!.Message, Does.Contain("A transport has not been configured. Use 'EndpointConfiguration.UseTransport()' to specify a transport"));
+ }
+
+ [Test]
+ public void Should_throw_when_multiple_endpoints_assembly_scanning_enabled()
+ {
+ var services = new ServiceCollection();
+
+ services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"), "Sales");
+
+ var ex = Assert.Throws(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Billing", assemblyScanningEnabled: true), "Billing"));
+
+ Assert.That(ex!.Message, Does.Contain("When multiple endpoints are registered, each endpoint must disable assembly scanning (cfg.AssemblyScanner().Disable = true) and explicitly register its installers using the corresponding registrations methods like AddInstaller(). The following endpoints have assembly scanning enabled: 'Billing'."));
+ }
+
+ [Test]
+ public void Should_throw_when_used_with_add_installer()
+ {
+ var services = new ServiceCollection();
+
+ services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"), "Sales");
+
+ var ex = Assert.Throws(() => services.AddNServiceBusEndpoint(CreateConfig("Billing"), "Billing"));
+
+ Assert.That(ex!.Message, Does.Contain("'AddNServiceBusEndpoint' cannot be used together with 'AddNServiceBusEndpointInstaller'."));
+ }
+
+ static EndpointConfiguration CreateConfig(string endpointName, bool assemblyScanningEnabled = false)
+ {
+ var config = new EndpointConfiguration(endpointName);
+ config.UseSerialization();
+ config.UseTransport(new LearningTransport());
+ config.AssemblyScanner().Disable = !assemblyScanningEnabled;
+ return config;
+ }
+}
\ No newline at end of file
diff --git a/src/NServiceBus.Core/Hosting/EndpointHostedInstallerService.cs b/src/NServiceBus.Core/Hosting/EndpointHostedInstallerService.cs
new file mode 100644
index 0000000000..fdda25504b
--- /dev/null
+++ b/src/NServiceBus.Core/Hosting/EndpointHostedInstallerService.cs
@@ -0,0 +1,18 @@
+#nullable enable
+namespace NServiceBus;
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+
+class EndpointHostedInstallerService(IEndpointLifecycle endpointLifecycle) : IHostedService, IAsyncDisposable
+{
+ public async Task StartAsync(CancellationToken cancellationToken = default)
+ // we only ever create but not start
+ => await endpointLifecycle.Create(cancellationToken).ConfigureAwait(false);
+
+ public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+
+ public ValueTask DisposeAsync() => endpointLifecycle.DisposeAsync();
+}
\ No newline at end of file
diff --git a/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs b/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs
index cc8295ee73..b7f625a390 100644
--- a/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs
+++ b/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs
@@ -16,6 +16,94 @@ namespace NServiceBus;
///
public static class ServiceCollectionExtensions
{
+ ///
+ /// Registers an NServiceBus endpoint installer with the dependency injection container, enabling the endpoint installers
+ /// to participate in the hosted service lifecycle.
+ ///
+ /// Registering installers is mutually exclusive to registering endpoints. This is to force the installation API being used on a dedicated host that is only used to run installers
+ /// for example as part of a deployment.
+ /// The to add the endpoint to.
+ /// The defining how the endpoint should be configured.
+ ///
+ /// An optional identifier that uniquely identifies this endpoint within the dependency injection container.
+ /// When multiple endpoints are registered (by calling this method multiple times), this parameter is required
+ /// and must be a well-defined value that serves as a keyed service identifier.
+ ///
+ /// In most scenarios, using the endpoint name as the identifier is a good choice.
+ ///
+ ///
+ /// For more complex scenarios such as multi-tenant applications where endpoint infrastructure
+ /// per tenant is dynamically resolved, the identifier can be any object that implements
+ /// and in a way that conforms to Microsoft Dependency Injection keyed services assumptions.
+ /// The key is used with keyed service registration methods like AddKeyedSingleton and related methods,
+ /// and can be retrieved using keyed service resolution APIs like GetRequiredKeyedService or
+ /// the [FromKeyedServices] attribute on constructor parameters.
+ ///
+ ///
+ ///
+ /// When using a keyed endpoint, all services resolved within NServiceBus extension points
+ /// (message handlers, sagas, features, installers, etc.) are automatically resolved as keyed services
+ /// for that endpoint and do not require the [FromKeyedServices] attribute.
+ /// Conversely, the [FromKeyedServices] attribute is required when accessing endpoint-specific services
+ /// (such as ) outside of NServiceBus extension points, for example in controllers
+ /// or background jobs.
+ ///
+ ///
+ /// By default, only endpoint-specific registrations are resolved when resolving all services of a given type
+ /// within an endpoint. However, for advanced scenarios where global services registered on the shared
+ /// service collection need to be resolved along with endpoint-specific ones, use
+ /// with the [FromKeyedServices] attribute (for example: [FromKeyedServices(KeyedServiceKey.Any)] IEnumerable<IMyService>).
+ /// This bypasses the default safeguards that isolate endpoints, allowing resolution of all services including
+ /// globally shared ones.
+ ///
+ public static void AddNServiceBusEndpointInstaller(this IServiceCollection services, EndpointConfiguration endpointConfiguration, object? endpointIdentifier = null)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(endpointConfiguration);
+
+ var settings = endpointConfiguration.GetSettings();
+ // Unfortunately we have to also check this here due to the multiple hosting variants as long as
+ // the old hosting is still supported.
+ settings.AssertNotReused();
+
+ var endpointName = settings.EndpointName();
+ var transport = settings.Get().TransportDefinition;
+ var endpointRegistrations = GetExistingRegistrations(services);
+ var installerRegistrations = GetExistingRegistrations(services);
+
+ ValidateNotUsedWithAddNServiceBusEndpoints(endpointRegistrations, $"'{nameof(AddNServiceBusEndpointInstaller)}' cannot be used together with '{nameof(AddNServiceBusEndpoint)}'.");
+ ValidateEndpointIdentifier(endpointIdentifier, installerRegistrations);
+ ValidateAssemblyScanning(endpointConfiguration, endpointName, installerRegistrations, message: "its installers using the corresponding registrations methods like AddInstaller()");
+ ValidateTransportReuse(transport, installerRegistrations);
+
+ endpointConfiguration.EnableInstallers();
+
+ if (endpointIdentifier is null)
+ {
+ // Deliberately creating it here to make sure we are not accidentally doing it too late.
+ var externallyManagedContainerHost = EndpointExternallyManaged.Create(endpointConfiguration, services);
+
+ services.AddSingleton(externallyManagedContainerHost);
+ services.AddSingleton(sp => new BaseEndpointLifecycle(externallyManagedContainerHost, sp));
+ services.AddSingleton(sp => new EndpointHostedInstallerService(sp.GetRequiredService()));
+ }
+ else
+ {
+ // Backdoor for acceptance testing
+ var keyedServices = settings.GetOrDefault() ?? new KeyedServiceCollectionAdapter(services, endpointIdentifier);
+ var baseKey = keyedServices.ServiceKey.BaseKey;
+
+ // Deliberately creating it here to make sure we are not accidentally doing it too late.
+ var externallyManagedContainerHost = EndpointExternallyManaged.Create(endpointConfiguration, keyedServices);
+
+ services.AddKeyedSingleton(baseKey, externallyManagedContainerHost);
+ services.AddKeyedSingleton(baseKey, (sp, _) => new EndpointLifecycle(externallyManagedContainerHost, sp, keyedServices.ServiceKey, keyedServices));
+ services.AddSingleton(sp => new EndpointHostedInstallerService(sp.GetRequiredKeyedService(baseKey)));
+ }
+
+ services.AddSingleton(new EndpointInstallerRegistration(endpointName, endpointIdentifier, endpointConfiguration.AssemblyScanner().Disable, RuntimeHelpers.GetHashCode(transport)));
+ }
+
///
/// Registers an NServiceBus endpoint with the dependency injection container, enabling the endpoint
/// to resolve services from the application's service provider and participate in the hosted service lifecycle.
@@ -56,10 +144,7 @@ public static class ServiceCollectionExtensions
/// globally shared ones.
///
///
- public static void AddNServiceBusEndpoint(
- this IServiceCollection services,
- EndpointConfiguration endpointConfiguration,
- object? endpointIdentifier = null)
+ public static void AddNServiceBusEndpoint(this IServiceCollection services, EndpointConfiguration endpointConfiguration, object? endpointIdentifier = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(endpointConfiguration);
@@ -70,13 +155,15 @@ public static void AddNServiceBusEndpoint(
settings.AssertNotReused();
var endpointName = settings.EndpointName();
- var hostingSettings = settings.Get();
var transport = settings.Get().TransportDefinition;
- var registrations = GetExistingRegistrations(services);
+ var endpointRegistrations = GetExistingRegistrations(services);
+ var installerRegistrations = GetExistingRegistrations(services);
+ var hostingSettings = settings.Get();
- ValidateEndpointIdentifier(endpointIdentifier, registrations);
- ValidateAssemblyScanning(endpointConfiguration, endpointName, registrations);
- ValidateTransportReuse(transport, registrations);
+ ValidateNotUsedWithAddNServiceBusEndpoints(installerRegistrations, $"'{nameof(AddNServiceBusEndpoint)}' cannot be used together with '{nameof(AddNServiceBusEndpointInstaller)}'.");
+ ValidateEndpointIdentifier(endpointIdentifier, endpointRegistrations);
+ ValidateAssemblyScanning(endpointConfiguration, endpointName, endpointRegistrations);
+ ValidateTransportReuse(transport, endpointRegistrations);
hostingSettings.ConfigureHostLogging(endpointIdentifier);
@@ -108,7 +195,16 @@ public static void AddNServiceBusEndpoint(
internal static IServiceCollection Unwrap(this IServiceCollection services) => (services as KeyedServiceCollectionAdapter)?.Inner ?? services;
- static void ValidateEndpointIdentifier(object? endpointIdentifier, List registrations)
+ static void ValidateNotUsedWithAddNServiceBusEndpoints(List endpointRegistrations, string message)
+ {
+ if (endpointRegistrations.Count > 0)
+ {
+ throw new InvalidOperationException(message);
+ }
+ }
+
+ static void ValidateEndpointIdentifier(object? endpointIdentifier, List registrations)
+ where TRegistration : EndpointRegistration
{
if (registrations.Count == 0)
{
@@ -128,7 +224,8 @@ static void ValidateEndpointIdentifier(object? endpointIdentifier, List registrations)
+ static void ValidateAssemblyScanning(EndpointConfiguration endpointConfiguration, string endpointName, List registrations, string message = "its handlers and sagas using the corresponding registrations methods like AddHandler(), AddSaga() etc")
+ where TRegistration : EndpointRegistration
{
if (endpointConfiguration.GetSettings().HasSetting("NServiceBus.Hosting.DisableAssemblyScanningValidation"))
{
@@ -153,12 +250,13 @@ static void ValidateAssemblyScanning(EndpointConfiguration endpointConfiguration
{
throw new InvalidOperationException(
$"When multiple endpoints are registered, each endpoint must disable assembly scanning " +
- $"(cfg.AssemblyScanner().Disable = true) and explicitly register its handlers using AddHandler(). " +
+ $"(cfg.AssemblyScanner().Disable = true) and explicitly register {message}. " +
$"The following endpoints have assembly scanning enabled: {string.Join(", ", endpointsWithScanning.Select(n => $"'{n}'"))}.");
}
}
- static void ValidateTransportReuse(TransportDefinition transport, List registrations)
+ static void ValidateTransportReuse(TransportDefinition transport, List registrations)
+ where TRegistration : EndpointRegistration
{
var transportHash = RuntimeHelpers.GetHashCode(transport);
var existingRegistration = registrations.FirstOrDefault(r => r.TransportHashCode == transportHash);
@@ -169,10 +267,13 @@ static void ValidateTransportReuse(TransportDefinition transport, List GetExistingRegistrations(IServiceCollection services) =>
+ static List GetExistingRegistrations(IServiceCollection services)
+ where TRegistration : EndpointRegistration =>
[.. services
- .Where(d => d.ServiceType == typeof(EndpointRegistration) && d.ImplementationInstance is EndpointRegistration)
- .Select(d => (EndpointRegistration)d.ImplementationInstance!)];
+ .Where(d => d.ServiceType == typeof(TRegistration) && d.ImplementationInstance is TRegistration)
+ .Select(d => (TRegistration)d.ImplementationInstance!)];
+
+ record EndpointRegistration(string EndpointName, object? EndpointIdentifier, bool ScanningDisabled, int TransportHashCode);
- sealed record EndpointRegistration(string EndpointName, object? EndpointIdentifier, bool ScanningDisabled, int TransportHashCode);
+ record EndpointInstallerRegistration(string EndpointName, object? EndpointIdentifier, bool ScanningDisabled, int TransportHashCode) : EndpointRegistration(EndpointName, EndpointIdentifier, ScanningDisabled, TransportHashCode);
}
\ No newline at end of file
diff --git a/src/NServiceBus.Core/Installation/Installer.cs b/src/NServiceBus.Core/Installation/Installer.cs
index f98d032573..cad0fb2544 100644
--- a/src/NServiceBus.Core/Installation/Installer.cs
+++ b/src/NServiceBus.Core/Installation/Installer.cs
@@ -3,19 +3,32 @@
namespace NServiceBus.Installation;
using System;
-using Microsoft.Extensions.DependencyInjection;
-using System.Threading.Tasks;
using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Particular.Obsoletes;
///
/// Provides methods to setup an NServiceBus endpoint.
///
+[ObsoleteMetadata(
+ Message = "Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint",
+ TreatAsErrorFromVersion = "11",
+ RemoveInVersion = "12",
+ ReplacementTypeOrMember = "IServiceCollection.AddNServiceBusEndpointInstaller")]
+[Obsolete("Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint. Use 'IServiceCollection.AddNServiceBusEndpointInstaller' instead. Will be treated as an error from version 11.0.0. Will be removed in version 12.0.0.", false)]
public static class Installer
{
///
/// Executes all the installers and transport configuration without starting the endpoint.
/// always runs installers, even if has not been configured.
///
+ [ObsoleteMetadata(
+ Message = "Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint",
+ TreatAsErrorFromVersion = "11",
+ RemoveInVersion = "12",
+ ReplacementTypeOrMember = "IServiceCollection.AddNServiceBusEndpointInstaller")]
+ [Obsolete("Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint. Use 'IServiceCollection.AddNServiceBusEndpointInstaller' instead. Will be treated as an error from version 11.0.0. Will be removed in version 12.0.0.", false)]
public static async Task Setup(EndpointConfiguration configuration, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(configuration);
@@ -38,6 +51,12 @@ public static async Task Setup(EndpointConfiguration configuration, Cancellation
///
/// Creates an instance of that can be used to setup an NServiceBus when access to externally registered container dependencies are required.
///
+ [ObsoleteMetadata(
+ Message = "Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint",
+ TreatAsErrorFromVersion = "11",
+ RemoveInVersion = "12",
+ ReplacementTypeOrMember = "IServiceCollection.AddNServiceBusEndpointInstaller")]
+ [Obsolete("Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint. Use 'IServiceCollection.AddNServiceBusEndpointInstaller' instead. Will be treated as an error from version 11.0.0. Will be removed in version 12.0.0.", false)]
public static InstallerWithExternallyManagedContainer CreateInstallerWithExternallyManagedContainer(EndpointConfiguration configuration, IServiceCollection serviceCollection)
{
ArgumentNullException.ThrowIfNull(configuration);