From ddbee6a4100c73367af9c3ece5ca5de5a98aabbe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:18:37 +0000 Subject: [PATCH 1/6] Initial plan From 83a581c9e0d69f36159edb99567674a5f08b9f9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:21:05 +0000 Subject: [PATCH 2/6] feat: add named keyed Dataverse client registration --- .../Internal/ServiceClientFactoryTests.cs | 84 ++++++- .../ServiceCollectionExtensionsTests.cs | 234 +++++++++++++++++- .../Internal/ServiceClientFactory.cs | 28 +++ .../ServiceCollectionExtensions.cs | 42 ++++ 4 files changed, 386 insertions(+), 2 deletions(-) diff --git a/Dataverse.Extensions.DependencyInjection.Tests/Internal/ServiceClientFactoryTests.cs b/Dataverse.Extensions.DependencyInjection.Tests/Internal/ServiceClientFactoryTests.cs index 90fef8c..559def3 100644 --- a/Dataverse.Extensions.DependencyInjection.Tests/Internal/ServiceClientFactoryTests.cs +++ b/Dataverse.Extensions.DependencyInjection.Tests/Internal/ServiceClientFactoryTests.cs @@ -1,4 +1,5 @@ using Azure.Core; +using BauerApps.Dataverse.Extensions.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.PowerPlatform.Dataverse.Client; @@ -80,6 +81,88 @@ public async Task Create_DoesNotThrowWhenConnectionFailsAndDeferConnectionIsTrue await Assert.That(client).IsNotNull(); } + [Test] + public async Task CreateNamed_UsesNamedOptions() + { + // Arrange + const string name = "source"; + var services = new ServiceCollection(); + services.AddDataverseClient(name, options => + { + options.OrganizationUrl = new Uri("https://named-org.crm4.dynamics.com"); + options.DeferConnection = true; + }); + var provider = services.BuildServiceProvider(); + + // Act + var client = ServiceClientFactory.CreateNamed(provider, name); + + // Assert + await Assert.That(client).IsNotNull(); + } + + [Test] + public async Task CreateNamed_UsesDefaultAzureCredentialWhenTokenCredentialIsNull() + { + // Arrange + const string name = "source"; + var services = new ServiceCollection(); + services.AddDataverseClient(name, options => + { + options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + options.DeferConnection = true; + }); + var provider = services.BuildServiceProvider(); + + // Act + var client = ServiceClientFactory.CreateNamed(provider, name); + + // Assert — client is created without throwing (deferred connection) + await Assert.That(client).IsNotNull(); + } + + [Test] + public async Task CreateNamed_UsesCustomTokenCredential() + { + // Arrange + const string name = "source"; + var customCredential = new FakeTokenCredential(); + var services = new ServiceCollection(); + services.AddDataverseClient(name, options => + { + options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + options.TokenCredential = customCredential; + options.DeferConnection = true; + }); + var provider = services.BuildServiceProvider(); + + // Act + var client = ServiceClientFactory.CreateNamed(provider, name); + + // Assert + await Assert.That(client).IsNotNull(); + } + + [Test] + public async Task CreateNamed_DoesNotThrowWhenConnectionFailsAndDeferConnectionIsTrue() + { + // Arrange + const string name = "source"; + var services = new ServiceCollection(); + services.AddDataverseClient(name, options => + { + options.OrganizationUrl = new Uri("https://invalid-org.crm4.dynamics.com"); + options.DeferConnection = true; + }); + var provider = services.BuildServiceProvider(); + + // Act + var client = ServiceClientFactory.CreateNamed(provider, name); + + // Assert — deferred connection should not throw + await Assert.That(client).IsNotNull(); + } + /// /// Minimal fake TokenCredential for testing. /// @@ -93,4 +176,3 @@ public override ValueTask GetTokenAsync(TokenRequestContext request } } - diff --git a/Dataverse.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTests.cs b/Dataverse.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTests.cs index 05394aa..97db76b 100644 --- a/Dataverse.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTests.cs +++ b/Dataverse.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTests.cs @@ -119,6 +119,201 @@ await Assert.That(options.OrganizationUrl) await Assert.That(options.TokenCredential).IsNull(); } + [Test] + public async Task AddDataverseClient_Named_RegistersKeyedServiceClient() + { + // Arrange + const string name = "source"; + var services = new ServiceCollection(); + + // Act + services.AddDataverseClient(name, options => + { + options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + options.DeferConnection = true; + }); + + // Assert + await Assert.That(services) + .Contains(x => x.ServiceType == typeof(ServiceClient) + && x.ServiceKey as string == name + && x.Lifetime == ServiceLifetime.Singleton); + } + + [Test] + public async Task AddDataverseClient_Named_RegistersKeyedScopedIOrganizationServiceAsync2() + { + // Arrange + const string name = "source"; + var services = new ServiceCollection(); + + // Act + services.AddDataverseClient(name, options => + { + options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + options.DeferConnection = true; + }); + + // Assert + await Assert.That(services) + .Contains(x => x.ServiceType == typeof(IOrganizationServiceAsync2) + && x.ServiceKey as string == name + && x.Lifetime == ServiceLifetime.Scoped); + } + + [Test] + public async Task AddDataverseClient_Named_ConfiguresNamedOptions() + { + // Arrange + const string name = "source"; + var organizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + var services = new ServiceCollection(); + + // Act + services.AddDataverseClient(name, options => + { + options.OrganizationUrl = organizationUrl; + options.DeferConnection = true; + }); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Get(name); + + await Assert.That(options.OrganizationUrl).IsEqualTo(organizationUrl); + await Assert.That(options.DeferConnection).IsTrue(); + await Assert.That(options.TokenCredential).IsNull(); + } + + [Test] + public async Task AddDataverseClient_Named_ReturnsServiceCollectionForChaining() + { + // Arrange + const string name = "source"; + var services = new ServiceCollection(); + + // Act + var result = services.AddDataverseClient(name, options => + { + options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + options.DeferConnection = true; + }); + + // Assert + await Assert.That(result).IsSameReferenceAs(services); + } + + [Test] + public async Task AddDataverseClient_Named_FromConfiguration_BindsOptionsAndRegistersServices() + { + // Arrange + const string name = "source"; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["OrganizationUrl"] = "https://my-org.crm4.dynamics.com", + ["DeferConnection"] = "true" + }) + .Build(); + var services = new ServiceCollection(); + + // Act + services.AddDataverseClient(name, configuration); + + // Assert — services registered with the correct lifetimes + await Assert.That(services) + .Contains(x => x.ServiceType == typeof(ServiceClient) + && x.ServiceKey as string == name + && x.Lifetime == ServiceLifetime.Singleton); + await Assert.That(services) + .Contains(x => x.ServiceType == typeof(IOrganizationServiceAsync2) + && x.ServiceKey as string == name + && x.Lifetime == ServiceLifetime.Scoped); + + // Assert — non-secret values bound from configuration + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Get(name); + + await Assert.That(options.OrganizationUrl) + .IsEqualTo(new Uri("https://my-org.crm4.dynamics.com")); + await Assert.That(options.DeferConnection).IsTrue(); + await Assert.That(options.TokenCredential).IsNull(); + } + + [Test] + public async Task AddDataverseClient_Named_DoesNotAffectUnnamedRegistration() + { + // Arrange + const string name = "source"; + var unnamedOrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + var namedOrganizationUrl = new Uri("https://named-org.crm4.dynamics.com"); + + var provider = new ServiceCollection() + .AddDataverseClient(options => + { + options.OrganizationUrl = unnamedOrganizationUrl; + options.DeferConnection = true; + }) + .AddDataverseClient(name, options => + { + options.OrganizationUrl = namedOrganizationUrl; + options.DeferConnection = true; + }) + .BuildServiceProvider(); + + // Act + var unnamedOptions = provider.GetRequiredService>().Value; + var namedOptions = provider.GetRequiredService>().Get(name); + var unnamedClient = provider.GetRequiredService(); + var namedClient = provider.GetRequiredKeyedService(name); + + // Assert + await Assert.That(unnamedOptions.OrganizationUrl).IsEqualTo(unnamedOrganizationUrl); + await Assert.That(namedOptions.OrganizationUrl).IsEqualTo(namedOrganizationUrl); + await Assert.That(unnamedClient).IsNotNull(); + await Assert.That(namedClient).IsNotNull(); + await Assert.That(ReferenceEquals(namedClient, unnamedClient)).IsFalse(); + } + + [Test] + public async Task AddDataverseClient_Named_TwoNamedClientsCoexist() + { + // Arrange + const string source = "source"; + const string target = "target"; + var sourceOrganizationUrl = new Uri("https://source-org.crm4.dynamics.com"); + var targetOrganizationUrl = new Uri("https://target-org.crm4.dynamics.com"); + var services = new ServiceCollection(); + + // Act + services.AddDataverseClient(source, options => + { + options.OrganizationUrl = sourceOrganizationUrl; + options.DeferConnection = true; + }); + services.AddDataverseClient(target, options => + { + options.OrganizationUrl = targetOrganizationUrl; + options.DeferConnection = true; + }); + + // Assert — both keyed registrations exist + await Assert.That(services) + .Contains(x => x.ServiceType == typeof(ServiceClient) + && x.ServiceKey as string == source + && x.Lifetime == ServiceLifetime.Singleton); + await Assert.That(services) + .Contains(x => x.ServiceType == typeof(ServiceClient) + && x.ServiceKey as string == target + && x.Lifetime == ServiceLifetime.Singleton); + + var provider = services.BuildServiceProvider(); + var optionsMonitor = provider.GetRequiredService>(); + + await Assert.That(optionsMonitor.Get(source).OrganizationUrl).IsEqualTo(sourceOrganizationUrl); + await Assert.That(optionsMonitor.Get(target).OrganizationUrl).IsEqualTo(targetOrganizationUrl); + } + [Test] public async Task AddDataverseClient_FromConfiguration_ThrowsOnStartWhenOrganizationUrlMissing() { @@ -138,6 +333,26 @@ await Assert.That(() => provider.GetRequiredService(); } + [Test] + public async Task AddDataverseClient_Named_FromConfiguration_ThrowsOnStartWhenOrganizationUrlMissing() + { + // Arrange + const string name = "source"; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["DeferConnection"] = "true" + }) + .Build(); + var provider = new ServiceCollection() + .AddDataverseClient(name, configuration) + .BuildServiceProvider(); + + // Act & Assert + await Assert.That(() => provider.GetRequiredService>().Get(name)) + .Throws(); + } + [Test] public async Task AddDataverseClient_ThrowsOnStartWhenOrganizationUrlIsNotHttps() { @@ -154,5 +369,22 @@ public async Task AddDataverseClient_ThrowsOnStartWhenOrganizationUrlIsNotHttps( await Assert.That(() => provider.GetRequiredService>().Value) .Throws(); } -} + [Test] + public async Task AddDataverseClient_Named_ThrowsOnStartWhenOrganizationUrlIsNotHttps() + { + // Arrange + const string name = "source"; + var provider = new ServiceCollection() + .AddDataverseClient(name, options => + { + options.OrganizationUrl = new Uri("http://my-org.crm4.dynamics.com"); + options.DeferConnection = true; + }) + .BuildServiceProvider(); + + // Act & Assert + await Assert.That(() => provider.GetRequiredService>().Get(name)) + .Throws(); + } +} diff --git a/Dataverse.Extensions.DependencyInjection/Internal/ServiceClientFactory.cs b/Dataverse.Extensions.DependencyInjection/Internal/ServiceClientFactory.cs index 1d4d75d..1dcac8e 100644 --- a/Dataverse.Extensions.DependencyInjection/Internal/ServiceClientFactory.cs +++ b/Dataverse.Extensions.DependencyInjection/Internal/ServiceClientFactory.cs @@ -37,4 +37,32 @@ public static ServiceClient Create(IServiceProvider serviceProvider) return new ServiceClient(connectionOptions, deferConnection: options.DeferConnection); } + + public static ServiceClient CreateNamed(IServiceProvider serviceProvider, string name) + { + var options = serviceProvider + .GetRequiredService>() + .Get(name); + + var credential = options.TokenCredential ?? new DefaultAzureCredential(); + + var scope = $"{options.OrganizationUrl.GetLeftPart(UriPartial.Authority)}/.default"; + + var logger = serviceProvider.GetService() + ?.CreateLogger(); + + var connectionOptions = new ConnectionOptions + { + ServiceUri = options.OrganizationUrl, + Logger = logger, + AccessTokenProviderFunctionAsync = async _ => + { + var token = await credential.GetTokenAsync( + new TokenRequestContext([scope]), CancellationToken.None); + return token.Token; + } + }; + + return new ServiceClient(connectionOptions, deferConnection: options.DeferConnection); + } } diff --git a/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs index f12e53a..a65aa6e 100644 --- a/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs +++ b/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -49,6 +49,37 @@ public IServiceCollection AddDataverseClient(IConfiguration configuration) return services.AddDataverseClientCore(); } + /// + /// Registers keyed singleton and scoped Dataverse services for the given client name. + /// + /// The keyed service name. + /// Action to configure named . + /// The service collection for chaining. + public IServiceCollection AddDataverseClient(string name, Action configureOptions) + { + services.AddOptionsWithValidateOnStart(name) + .Configure(configureOptions) + .ValidateDataverseClientOptions(); + + return services.AddNamedDataverseClientCore(name); + } + + /// + /// Registers keyed singleton and scoped Dataverse services for the given client name, + /// binding named options from the supplied configuration section. + /// + /// The keyed service name. + /// Configuration section to bind named options from. + /// The service collection for chaining. + public IServiceCollection AddDataverseClient(string name, IConfiguration configuration) + { + services.AddOptionsWithValidateOnStart(name) + .Bind(configuration) + .ValidateDataverseClientOptions(); + + return services.AddNamedDataverseClientCore(name); + } + private IServiceCollection AddDataverseClientCore() { services.AddSingleton(ServiceClientFactory.Create); @@ -59,6 +90,17 @@ private IServiceCollection AddDataverseClientCore() return services; } + + private IServiceCollection AddNamedDataverseClientCore(string name) + { + services.AddKeyedSingleton(name, + (sp, key) => ServiceClientFactory.CreateNamed(sp, (string)key!)); + + services.AddKeyedScoped(name, + (sp, key) => sp.GetRequiredKeyedService(key).Clone()); + + return services; + } } private static OptionsBuilder ValidateDataverseClientOptions( From 26140a8924fbb5a95aa59000d254b7710ebab4db Mon Sep 17 00:00:00 2001 From: Lars Bauer Date: Sat, 20 Jun 2026 15:36:48 +0200 Subject: [PATCH 3/6] fix: correct named options binding and rename Named to Keyed --- .../ServiceCollectionExtensions.cs | 52 +++++++++++-------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs index a65aa6e..11aad89 100644 --- a/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs +++ b/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -50,34 +50,44 @@ public IServiceCollection AddDataverseClient(IConfiguration configuration) } /// - /// Registers keyed singleton and scoped Dataverse services for the given client name. + /// Registers a keyed singleton and a keyed scoped + /// (via ) + /// in the dependency injection container under the given . /// - /// The keyed service name. - /// Action to configure named . + /// The service key used to resolve this client via [FromKeyedServices]. + /// Action to configure for this key. /// The service collection for chaining. - public IServiceCollection AddDataverseClient(string name, Action configureOptions) + public IServiceCollection AddDataverseClient(string key, Action configureOptions) { - services.AddOptionsWithValidateOnStart(name) - .Configure(configureOptions) + services.AddOptionsWithValidateOnStart(key) + .Configure(key, configureOptions) .ValidateDataverseClientOptions(); - return services.AddNamedDataverseClientCore(name); + return services.AddKeyedDataverseClientCore(key); } /// - /// Registers keyed singleton and scoped Dataverse services for the given client name, - /// binding named options from the supplied configuration section. + /// Registers a keyed singleton and a keyed scoped + /// (via ) + /// in the dependency injection container under the given , + /// binding from the supplied configuration section. /// - /// The keyed service name. - /// Configuration section to bind named options from. + /// The service key used to resolve this client via [FromKeyedServices]. + /// Configuration section to bind from. /// The service collection for chaining. - public IServiceCollection AddDataverseClient(string name, IConfiguration configuration) + /// + /// Only non-secret values bind from configuration (e.g. OrganizationUrl, + /// DeferConnection). Authentication uses + /// by default; to use a custom credential, set + /// via . + /// + public IServiceCollection AddDataverseClient(string key, IConfiguration configuration) { - services.AddOptionsWithValidateOnStart(name) - .Bind(configuration) + services.AddOptionsWithValidateOnStart(key) + .Bind(key, configuration) .ValidateDataverseClientOptions(); - return services.AddNamedDataverseClientCore(name); + return services.AddKeyedDataverseClientCore(key); } private IServiceCollection AddDataverseClientCore() @@ -91,13 +101,13 @@ private IServiceCollection AddDataverseClientCore() return services; } - private IServiceCollection AddNamedDataverseClientCore(string name) + private IServiceCollection AddKeyedDataverseClientCore(string key) { - services.AddKeyedSingleton(name, - (sp, key) => ServiceClientFactory.CreateNamed(sp, (string)key!)); + services.AddKeyedSingleton(key, + (sp, k) => ServiceClientFactory.CreateKeyed(sp, (string)k!)); - services.AddKeyedScoped(name, - (sp, key) => sp.GetRequiredKeyedService(key).Clone()); + services.AddKeyedScoped(key, + (sp, k) => sp.GetRequiredKeyedService(k).Clone()); return services; } @@ -112,4 +122,4 @@ private static OptionsBuilder ValidateDataverseClientOpt "OrganizationUrl must be an absolute URI (e.g. https://my-org.crm4.dynamics.com).") .Validate(o => o.OrganizationUrl is null || o.OrganizationUrl.Scheme == Uri.UriSchemeHttps, "OrganizationUrl must use HTTPS."); -} \ No newline at end of file +} From f05da4b6453066cc7c4de2268eecec4d04ec9f96 Mon Sep 17 00:00:00 2001 From: Lars Bauer Date: Sat, 20 Jun 2026 15:38:07 +0200 Subject: [PATCH 4/6] refactor: rename CreateNamed to CreateKeyed in ServiceClientFactory --- .../Internal/ServiceClientFactory.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dataverse.Extensions.DependencyInjection/Internal/ServiceClientFactory.cs b/Dataverse.Extensions.DependencyInjection/Internal/ServiceClientFactory.cs index 1dcac8e..4fa8959 100644 --- a/Dataverse.Extensions.DependencyInjection/Internal/ServiceClientFactory.cs +++ b/Dataverse.Extensions.DependencyInjection/Internal/ServiceClientFactory.cs @@ -38,11 +38,11 @@ public static ServiceClient Create(IServiceProvider serviceProvider) return new ServiceClient(connectionOptions, deferConnection: options.DeferConnection); } - public static ServiceClient CreateNamed(IServiceProvider serviceProvider, string name) + public static ServiceClient CreateKeyed(IServiceProvider serviceProvider, string key) { var options = serviceProvider .GetRequiredService>() - .Get(name); + .Get(key); var credential = options.TokenCredential ?? new DefaultAzureCredential(); From 2eb10e85527a2eeb5681fe6066754567bbc77f7b Mon Sep 17 00:00:00 2001 From: Lars Bauer Date: Sat, 20 Jun 2026 15:45:24 +0200 Subject: [PATCH 5/6] refactor: rename Named to Keyed in tests and update README and AGENTS docs --- AGENTS.md | 42 +++++---- .../Internal/ServiceClientFactoryTests.cs | 35 ++++--- .../ServiceCollectionExtensionsTests.cs | 92 +++++++++---------- README.md | 37 +++++++- 4 files changed, 123 insertions(+), 83 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9835d8d..d7019c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,22 +2,22 @@ ## Project overview -A small .NET 10 NuGet library (`BauerApps.Dataverse.Extensions.DependencyInjection`) that provides one-line DI registration for Microsoft Dataverse `ServiceClient`. The key design decision: `ServiceClient` is registered as a **singleton** (shares connection pool & metadata cache), while `IOrganizationServiceAsync2` is registered as **scoped** via `Clone()` (thread-safe per-request usage). Do not change these lifetimes. +A small .NET 10 NuGet library (`BauerApps.Dataverse.Extensions.DependencyInjection`) that provides one-line DI registration for Microsoft Dataverse `ServiceClient`. The key design decision: `ServiceClient` is a singleton (shared connection + token cache) while `IOrganizationServiceAsync2` is scoped (per-request `Clone()`). ## Scope & non-goals -The library has a single responsibility: **wire up `ServiceClient` and register it for DI** with the correct lifetimes and authentication. Keep it a thin wiring layer built only on the public, supported `ServiceClient` surface. +The library has a single responsibility: **wire up `ServiceClient` and register it for DI** with the correct lifetimes and authentication. Keep it a thin wiring layer built only on the public, supported Dataverse SDK surface. -- **In scope**: DI registration (singleton client + scoped `Clone()`), authentication (Azure.Identity + options pattern), logger wiring, startup validation. -- **Non-goals**: behavioral wrappers/decorators over `IOrganizationService*` (request tagging/correlation, retries, caching, auditing, etc.), constructing credentials from primitive config fields (clientId/secret/etc. — accept a `TokenCredential` instead), and anything that depends on or reimplements `ServiceClient` internals or undocumented behavior. +- **In scope**: DI registration (singleton client + scoped `Clone()`), keyed (multi-environment) client registration, authentication (Azure.Identity + options pattern), logger wiring, startup validation. +- **Non-goals**: behavioral wrappers/decorators over `IOrganizationService*` (request tagging/correlation, retries, caching, auditing, etc.), constructing credentials from primitive config fields (tenantId/clientId/secret strings), FetchXml helpers, entity mapping. -Rationale: maintainability and resilience to changes in the floating `1.*` Dataverse client dependency. Cross-cutting request behavior is the consumer's responsibility. When in doubt, prefer *not* adding the feature. +Rationale: maintainability and resilience to changes in the floating `1.*` Dataverse client dependency. Cross-cutting request behavior is the consumer's responsibility. When in doubt, prefer *not* adding something. -## Architecture (4 source files) +## Architecture -- `ServiceCollectionExtensions.cs` — Public API surface. `AddDataverseClient()` overloads (one taking `Action`, one taking `IConfiguration` to bind from a config section) using C# 14 `extension` blocks. Shared wiring/validation live in private static helpers (`AddDataverseClientCore`, `ValidateDataverseClientOptions`). +- `ServiceCollectionExtensions.cs` — Public API surface. `AddDataverseClient()` overloads: unkeyed (one taking `Action`, one taking `IConfiguration`) and keyed (same two overloads with a leading `string key` parameter). Uses C# 14 `extension` block style. - `DataverseClientOptions.cs` — Options POCO with `OrganizationUrl` (required), `TokenCredential`, `DeferConnection`. -- `Internal/ServiceClientFactory.cs` — Singleton factory wiring `Azure.Identity` credentials into `ConnectionOptions.AccessTokenProviderFunctionAsync`. Marked `internal`, tested via `InternalsVisibleTo`. +- `Internal/ServiceClientFactory.cs` — Factory with two methods: `Create` (unkeyed, uses `IOptions`) and `CreateKeyed` (uses `IOptionsMonitor.Get(key)`). Marked `internal`, tested via `InternalsVisibleTo`. - Root namespace is `BauerApps.Dataverse.Extensions` (set via `` in csproj, differs from folder name). ## Build & test @@ -27,7 +27,7 @@ dotnet build Dataverse.Extensions.slnx dotnet test Dataverse.Extensions.slnx ``` -Solution uses `.slnx` format (XML-based), not `.sln`. Tests use **TUnit** (not xUnit/NUnit/MSTest) — uses `[Test]` attribute and `await Assert.That(...)` fluent async assertions. The test runner is configured in `global.json` (`"runner": "Microsoft.Testing.Platform"`). +Solution uses `.slnx` format (XML-based), not `.sln`. Tests use **TUnit** (not xUnit/NUnit/MSTest) — uses `[Test]` attribute and `await Assert.That(...)` fluent async assertions. The test runner is TUnit's own; `dotnet test` works via the adapter. ## Conventions @@ -35,23 +35,25 @@ Solution uses `.slnx` format (XML-based), not `.sln`. Tests use **TUnit** (not x - **Internal access**: `InternalsVisibleTo` is configured via `` in csproj, not `AssemblyInfo.cs`. - **Test structure**: Mirrors source layout. `ServiceCollectionExtensionsTests.cs` at root, `Internal/ServiceClientFactoryTests.cs` for internal classes. Tests use Arrange/Act/Assert with comments. - **Test pattern**: All tests set `DeferConnection = true` to avoid real Dataverse connections. Use `FakeTokenCredential` (private nested class) when testing custom credentials. +- **Test naming**: Unkeyed tests use the method name directly (e.g. `AddDataverseClient_RegistersServiceClient`). Keyed tests use `_Keyed_` segment (e.g. `AddDataverseClient_Keyed_RegistersKeyedServiceClient`). Factory tests follow `Create_` and `CreateKeyed_` prefixes. - **C# 14 features**: Uses `extension` blocks (not classic `static` extension methods). Keep this style for new extensions. -- **Dependencies**: `Azure.Identity`, `Microsoft.Extensions.Options`, `Microsoft.Extensions.Options.ConfigurationExtensions` (for the `IConfiguration` binding overload), `Microsoft.PowerPlatform.Dataverse.Client`. The Dataverse client uses floating version `1.*`. +- **Dependencies**: `Azure.Identity`, `Microsoft.Extensions.Options`, `Microsoft.Extensions.Options.ConfigurationExtensions` (for the `IConfiguration` binding overload), `Microsoft.PowerPlatform.Dataverse.Client`. +- **Documentation**: `README.md` (user-facing) and `AGENTS.md` (agent-facing) **must both be updated** whenever a new feature is added or existing API behaviour changes. README covers usage examples; AGENTS.md covers architecture, conventions, and patterns. ## CI/CD & versioning -- **Versioning**: Automated via [release-please](https://github.com/googleapis/release-please). Version is tracked in `.release-please-manifest.json` (keyed by package directory) and patched into csproj `` via the `` marker comment. +- **Versioning**: Automated via [release-please](https://github.com/googleapis/release-please). Version is tracked in `.release-please-manifest.json` (keyed by package directory) and patched into `` in the csproj by release-please — never edit these manually. - **Commit messages**: Use [Conventional Commits](https://www.conventionalcommits.org/) — `feat:` (minor bump), `fix:` (patch), `feat!:` or `BREAKING CHANGE` footer (major). - **CI** (`.github/workflows/ci.yml`): Builds and tests on every push/PR to `main`. -- **Release** (`.github/workflows/release.yml`): On push to `main`, release-please opens/updates a Release PR. Merging it creates a GitHub release + tag, then publishes to NuGet via [trusted publishing](https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing) (OIDC via `NuGet/login@v1`, no long-lived API key). +- **Release** (`.github/workflows/release.yml`): On push to `main`, release-please opens/updates a Release PR. Merging it creates a GitHub release + tag, then publishes to NuGet via trusted publishing. ## Release process -Releases are fully automated by **release-please**; never bump the version, edit `CHANGELOG.md`, or tag manually. The version flow is driven entirely by commit messages, so writing correct [Conventional Commits](https://www.conventionalcommits.org/) is what makes the process work. +Releases are fully automated by **release-please**; never bump the version, edit `CHANGELOG.md`, or tag manually. The version flow is driven entirely by commit messages, so writing correct [Conventional Commits](https://www.conventionalcommits.org/) is critical. **Configuration**: -- `release-please-config.json` — `release-type: simple` for the single package `BauerApps.Dataverse.Extensions.DependencyInjection`. `include-component-in-tag: false` (tags are plain `vX.Y.Z`). The csproj is listed as a `generic` extra-file so its `` (marked with ``) is updated on release. Changelog is written to `Dataverse.Extensions.DependencyInjection/CHANGELOG.md`. +- `release-please-config.json` — `release-type: simple` for the single package `BauerApps.Dataverse.Extensions.DependencyInjection`. `include-component-in-tag: false` (tags are plain `vX.Y.Z`). - `.release-please-manifest.json` — the current released version, keyed by package directory. Do not edit by hand; release-please maintains it. **Commit message rules** (these determine the next version bump): @@ -73,13 +75,19 @@ Notes: 1. Open a PR with Conventional Commit message(s) and merge to `main`. 2. release-please opens (or updates) a **Release PR** that bumps the version, updates `CHANGELOG.md`, and updates `.release-please-manifest.json`. 3. Review and merge the Release PR. This creates the GitHub release + `vX.Y.Z` tag. -4. The `publish` job (gated on `release_created`) then builds, tests, packs, and publishes the package to NuGet via trusted publishing (OIDC via `NuGet/login@v1`, `--skip-duplicate`) — no manual `dotnet nuget push` and no stored API key. It also attaches the `.nupkg` to the GitHub release and flips the Release PR label from `autorelease: tagged` to `autorelease: published`. +4. The `publish` job (gated on `release_created`) then builds, tests, packs, and publishes the package to NuGet via trusted publishing (OIDC via `NuGet/login@v1`, `--skip-duplicate`) — no manual steps needed. ## Key patterns When adding new configuration options: 1. Add property to `DataverseClientOptions` -2. Add validation in `ServiceCollectionExtensions.AddDataverseClient()` via `.Validate()` if required -3. Wire it in `ServiceClientFactory.Create()` using the options pattern (`IOptions`) +2. Add validation in `ServiceCollectionExtensions` via `.Validate()` if required +3. Wire it in `ServiceClientFactory` using the appropriate options pattern (`IOptions` for unkeyed, `IOptionsMonitor` for keyed) 4. Test both the registration (service descriptor assertions) and the factory behavior +5. Update `README.md` with usage examples and `AGENTS.md` with any architecture or convention changes +When adding a new keyed registration: +- Use `AddKeyedSingleton` / `AddKeyedScoped` — do NOT reuse the unkeyed core path +- Thread the key through via the keyed service factory delegate `(sp, key) => ...` +- Use `IOptionsMonitor.Get(key)` — never `IOptions` for keyed registrations +- Keep unkeyed and keyed paths fully independent to avoid `Options.DefaultName` collisions diff --git a/Dataverse.Extensions.DependencyInjection.Tests/Internal/ServiceClientFactoryTests.cs b/Dataverse.Extensions.DependencyInjection.Tests/Internal/ServiceClientFactoryTests.cs index 559def3..bcdade5 100644 --- a/Dataverse.Extensions.DependencyInjection.Tests/Internal/ServiceClientFactoryTests.cs +++ b/Dataverse.Extensions.DependencyInjection.Tests/Internal/ServiceClientFactoryTests.cs @@ -82,32 +82,32 @@ public async Task Create_DoesNotThrowWhenConnectionFailsAndDeferConnectionIsTrue } [Test] - public async Task CreateNamed_UsesNamedOptions() + public async Task CreateKeyed_UsesKeyedOptions() { // Arrange - const string name = "source"; + const string key = "source"; var services = new ServiceCollection(); - services.AddDataverseClient(name, options => + services.AddDataverseClient(key, options => { - options.OrganizationUrl = new Uri("https://named-org.crm4.dynamics.com"); + options.OrganizationUrl = new Uri("https://keyed-org.crm4.dynamics.com"); options.DeferConnection = true; }); var provider = services.BuildServiceProvider(); // Act - var client = ServiceClientFactory.CreateNamed(provider, name); + var client = ServiceClientFactory.CreateKeyed(provider, key); // Assert await Assert.That(client).IsNotNull(); } [Test] - public async Task CreateNamed_UsesDefaultAzureCredentialWhenTokenCredentialIsNull() + public async Task CreateKeyed_UsesDefaultAzureCredentialWhenTokenCredentialIsNull() { // Arrange - const string name = "source"; + const string key = "source"; var services = new ServiceCollection(); - services.AddDataverseClient(name, options => + services.AddDataverseClient(key, options => { options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); options.DeferConnection = true; @@ -115,20 +115,20 @@ public async Task CreateNamed_UsesDefaultAzureCredentialWhenTokenCredentialIsNul var provider = services.BuildServiceProvider(); // Act - var client = ServiceClientFactory.CreateNamed(provider, name); + var client = ServiceClientFactory.CreateKeyed(provider, key); // Assert — client is created without throwing (deferred connection) await Assert.That(client).IsNotNull(); } [Test] - public async Task CreateNamed_UsesCustomTokenCredential() + public async Task CreateKeyed_UsesCustomTokenCredential() { // Arrange - const string name = "source"; + const string key = "source"; var customCredential = new FakeTokenCredential(); var services = new ServiceCollection(); - services.AddDataverseClient(name, options => + services.AddDataverseClient(key, options => { options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); options.TokenCredential = customCredential; @@ -137,19 +137,19 @@ public async Task CreateNamed_UsesCustomTokenCredential() var provider = services.BuildServiceProvider(); // Act - var client = ServiceClientFactory.CreateNamed(provider, name); + var client = ServiceClientFactory.CreateKeyed(provider, key); // Assert await Assert.That(client).IsNotNull(); } [Test] - public async Task CreateNamed_DoesNotThrowWhenConnectionFailsAndDeferConnectionIsTrue() + public async Task CreateKeyed_DoesNotThrowWhenConnectionFailsAndDeferConnectionIsTrue() { // Arrange - const string name = "source"; + const string key = "source"; var services = new ServiceCollection(); - services.AddDataverseClient(name, options => + services.AddDataverseClient(key, options => { options.OrganizationUrl = new Uri("https://invalid-org.crm4.dynamics.com"); options.DeferConnection = true; @@ -157,7 +157,7 @@ public async Task CreateNamed_DoesNotThrowWhenConnectionFailsAndDeferConnectionI var provider = services.BuildServiceProvider(); // Act - var client = ServiceClientFactory.CreateNamed(provider, name); + var client = ServiceClientFactory.CreateKeyed(provider, key); // Assert — deferred connection should not throw await Assert.That(client).IsNotNull(); @@ -175,4 +175,3 @@ public override ValueTask GetTokenAsync(TokenRequestContext request => new(new AccessToken("fake-token", DateTimeOffset.UtcNow.AddHours(1))); } } - diff --git a/Dataverse.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTests.cs b/Dataverse.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTests.cs index 97db76b..67262c5 100644 --- a/Dataverse.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTests.cs +++ b/Dataverse.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTests.cs @@ -120,14 +120,14 @@ await Assert.That(options.OrganizationUrl) } [Test] - public async Task AddDataverseClient_Named_RegistersKeyedServiceClient() + public async Task AddDataverseClient_Keyed_RegistersKeyedServiceClient() { // Arrange - const string name = "source"; + const string key = "source"; var services = new ServiceCollection(); // Act - services.AddDataverseClient(name, options => + services.AddDataverseClient(key, options => { options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); options.DeferConnection = true; @@ -136,19 +136,19 @@ public async Task AddDataverseClient_Named_RegistersKeyedServiceClient() // Assert await Assert.That(services) .Contains(x => x.ServiceType == typeof(ServiceClient) - && x.ServiceKey as string == name + && x.ServiceKey as string == key && x.Lifetime == ServiceLifetime.Singleton); } [Test] - public async Task AddDataverseClient_Named_RegistersKeyedScopedIOrganizationServiceAsync2() + public async Task AddDataverseClient_Keyed_RegistersKeyedScopedIOrganizationServiceAsync2() { // Arrange - const string name = "source"; + const string key = "source"; var services = new ServiceCollection(); // Act - services.AddDataverseClient(name, options => + services.AddDataverseClient(key, options => { options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); options.DeferConnection = true; @@ -157,20 +157,20 @@ public async Task AddDataverseClient_Named_RegistersKeyedScopedIOrganizationServ // Assert await Assert.That(services) .Contains(x => x.ServiceType == typeof(IOrganizationServiceAsync2) - && x.ServiceKey as string == name + && x.ServiceKey as string == key && x.Lifetime == ServiceLifetime.Scoped); } [Test] - public async Task AddDataverseClient_Named_ConfiguresNamedOptions() + public async Task AddDataverseClient_Keyed_ConfiguresKeyedOptions() { // Arrange - const string name = "source"; + const string key = "source"; var organizationUrl = new Uri("https://my-org.crm4.dynamics.com"); var services = new ServiceCollection(); // Act - services.AddDataverseClient(name, options => + services.AddDataverseClient(key, options => { options.OrganizationUrl = organizationUrl; options.DeferConnection = true; @@ -178,7 +178,7 @@ public async Task AddDataverseClient_Named_ConfiguresNamedOptions() // Assert var provider = services.BuildServiceProvider(); - var options = provider.GetRequiredService>().Get(name); + var options = provider.GetRequiredService>().Get(key); await Assert.That(options.OrganizationUrl).IsEqualTo(organizationUrl); await Assert.That(options.DeferConnection).IsTrue(); @@ -186,14 +186,14 @@ public async Task AddDataverseClient_Named_ConfiguresNamedOptions() } [Test] - public async Task AddDataverseClient_Named_ReturnsServiceCollectionForChaining() + public async Task AddDataverseClient_Keyed_ReturnsServiceCollectionForChaining() { // Arrange - const string name = "source"; + const string key = "source"; var services = new ServiceCollection(); // Act - var result = services.AddDataverseClient(name, options => + var result = services.AddDataverseClient(key, options => { options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); options.DeferConnection = true; @@ -204,10 +204,10 @@ public async Task AddDataverseClient_Named_ReturnsServiceCollectionForChaining() } [Test] - public async Task AddDataverseClient_Named_FromConfiguration_BindsOptionsAndRegistersServices() + public async Task AddDataverseClient_Keyed_FromConfiguration_BindsOptionsAndRegistersServices() { // Arrange - const string name = "source"; + const string key = "source"; var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { @@ -218,21 +218,21 @@ public async Task AddDataverseClient_Named_FromConfiguration_BindsOptionsAndRegi var services = new ServiceCollection(); // Act - services.AddDataverseClient(name, configuration); + services.AddDataverseClient(key, configuration); // Assert — services registered with the correct lifetimes await Assert.That(services) .Contains(x => x.ServiceType == typeof(ServiceClient) - && x.ServiceKey as string == name + && x.ServiceKey as string == key && x.Lifetime == ServiceLifetime.Singleton); await Assert.That(services) .Contains(x => x.ServiceType == typeof(IOrganizationServiceAsync2) - && x.ServiceKey as string == name + && x.ServiceKey as string == key && x.Lifetime == ServiceLifetime.Scoped); // Assert — non-secret values bound from configuration var provider = services.BuildServiceProvider(); - var options = provider.GetRequiredService>().Get(name); + var options = provider.GetRequiredService>().Get(key); await Assert.That(options.OrganizationUrl) .IsEqualTo(new Uri("https://my-org.crm4.dynamics.com")); @@ -241,42 +241,42 @@ await Assert.That(options.OrganizationUrl) } [Test] - public async Task AddDataverseClient_Named_DoesNotAffectUnnamedRegistration() + public async Task AddDataverseClient_Keyed_DoesNotAffectUnkeyedRegistration() { // Arrange - const string name = "source"; - var unnamedOrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); - var namedOrganizationUrl = new Uri("https://named-org.crm4.dynamics.com"); + const string key = "source"; + var unkeyedOrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + var keyedOrganizationUrl = new Uri("https://keyed-org.crm4.dynamics.com"); var provider = new ServiceCollection() .AddDataverseClient(options => { - options.OrganizationUrl = unnamedOrganizationUrl; + options.OrganizationUrl = unkeyedOrganizationUrl; options.DeferConnection = true; }) - .AddDataverseClient(name, options => + .AddDataverseClient(key, options => { - options.OrganizationUrl = namedOrganizationUrl; + options.OrganizationUrl = keyedOrganizationUrl; options.DeferConnection = true; }) .BuildServiceProvider(); // Act - var unnamedOptions = provider.GetRequiredService>().Value; - var namedOptions = provider.GetRequiredService>().Get(name); - var unnamedClient = provider.GetRequiredService(); - var namedClient = provider.GetRequiredKeyedService(name); + var unkeyedOptions = provider.GetRequiredService>().Value; + var keyedOptions = provider.GetRequiredService>().Get(key); + var unkeyedClient = provider.GetRequiredService(); + var keyedClient = provider.GetRequiredKeyedService(key); // Assert - await Assert.That(unnamedOptions.OrganizationUrl).IsEqualTo(unnamedOrganizationUrl); - await Assert.That(namedOptions.OrganizationUrl).IsEqualTo(namedOrganizationUrl); - await Assert.That(unnamedClient).IsNotNull(); - await Assert.That(namedClient).IsNotNull(); - await Assert.That(ReferenceEquals(namedClient, unnamedClient)).IsFalse(); + await Assert.That(unkeyedOptions.OrganizationUrl).IsEqualTo(unkeyedOrganizationUrl); + await Assert.That(keyedOptions.OrganizationUrl).IsEqualTo(keyedOrganizationUrl); + await Assert.That(unkeyedClient).IsNotNull(); + await Assert.That(keyedClient).IsNotNull(); + await Assert.That(ReferenceEquals(keyedClient, unkeyedClient)).IsFalse(); } [Test] - public async Task AddDataverseClient_Named_TwoNamedClientsCoexist() + public async Task AddDataverseClient_Keyed_TwoKeyedClientsCoexist() { // Arrange const string source = "source"; @@ -334,10 +334,10 @@ await Assert.That(() => provider.GetRequiredService { @@ -345,11 +345,11 @@ public async Task AddDataverseClient_Named_FromConfiguration_ThrowsOnStartWhenOr }) .Build(); var provider = new ServiceCollection() - .AddDataverseClient(name, configuration) + .AddDataverseClient(key, configuration) .BuildServiceProvider(); // Act & Assert - await Assert.That(() => provider.GetRequiredService>().Get(name)) + await Assert.That(() => provider.GetRequiredService>().Get(key)) .Throws(); } @@ -371,12 +371,12 @@ await Assert.That(() => provider.GetRequiredService + .AddDataverseClient(key, options => { options.OrganizationUrl = new Uri("http://my-org.crm4.dynamics.com"); options.DeferConnection = true; @@ -384,7 +384,7 @@ public async Task AddDataverseClient_Named_ThrowsOnStartWhenOrganizationUrlIsNot .BuildServiceProvider(); // Act & Assert - await Assert.That(() => provider.GetRequiredService>().Get(name)) + await Assert.That(() => provider.GetRequiredService>().Get(key)) .Throws(); } } diff --git a/README.md b/README.md index 3904ea9..a8ee4a8 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,12 @@ [![NuGet Downloads](https://img.shields.io/nuget/dt/BauerApps.Dataverse.Extensions.DependencyInjection)](https://www.nuget.org/packages/BauerApps.Dataverse.Extensions.DependencyInjection) [![License](https://img.shields.io/github/license/LarsBauer/dataverse-serviceclient-extensions)](LICENSE) -Dependency injection extensions for [Microsoft.PowerPlatform.Dataverse.Client](https://www.nuget.org/packages/Microsoft.PowerPlatform.Dataverse.Client). Registers a singleton `ServiceClient` and a scoped `IOrganizationServiceAsync2` (via `Clone()`) — the correct pattern most people get wrong. +Dependency injection extensions for [Microsoft.PowerPlatform.Dataverse.Client](https://www.nuget.org/packages/Microsoft.PowerPlatform.Dataverse.Client). Registers a singleton `ServiceClient` and a scoped `IOrganizationServiceAsync2` (via `Clone()`) with a single method call. ## Features - One-line DI registration for `ServiceClient` with proper singleton + scoped `Clone()` lifecycle +- Keyed (multi-environment) client registration via native .NET keyed DI - Authentication via [Azure.Identity](https://www.nuget.org/packages/Azure.Identity) (`DefaultAzureCredential` by default, any `TokenCredential` supported) - Automatic logger wiring from the DI container - Options validation at startup — fail fast on misconfiguration @@ -118,9 +119,41 @@ builder.Services.PostConfigure(options => options.TokenCredential = new ClientSecretCredential(tenantId, clientId, clientSecret)); ``` +## Keyed clients (multiple environments) + +Use keyed registration when your application needs to connect to more than one Dataverse environment — for example a data migration that reads from a source org and writes to a target org. + +Register each client with a string key: + +```csharp +builder.Services.AddDataverseClient("source", options => +{ + options.OrganizationUrl = new Uri("https://source.crm4.dynamics.com"); +}); + +builder.Services.AddDataverseClient("target", + builder.Configuration.GetSection("Dataverse:Target")); +``` + +Resolve via `[FromKeyedServices]`: + +```csharp +public sealed class MigrationService( + [FromKeyedServices("source")] IOrganizationServiceAsync2 source, + [FromKeyedServices("target")] IOrganizationServiceAsync2 target) +{ + public async Task MigrateAsync() + { + // read from source, write to target + } +} +``` + +Each keyed registration is fully independent — its own singleton `ServiceClient` and its own scoped `IOrganizationServiceAsync2`. Keyed and unkeyed registrations coexist without conflict. + ## Why scoped `IOrganizationServiceAsync2`? -`ServiceClient` is registered as a **singleton** to share the underlying connection, metadata cache, and authentication token. However, using a single instance across concurrent requests can cause issues (e.g., `CallerId` leaking between requests). +`ServiceClient` is registered as a **singleton** to share the underlying connection, metadata cache, and authentication token. However, using a single instance across concurrent requests can cause subtle threading issues. `Clone()` creates a lightweight copy that shares the parent's connection pool but is safe for per-request use. This library registers `IOrganizationServiceAsync2` as **scoped**, so each request gets its own clone automatically. From d551600b6d56166b4abff03ba28e1bfb0a1d04d1 Mon Sep 17 00:00:00 2001 From: Lars Bauer Date: Sat, 20 Jun 2026 15:57:11 +0200 Subject: [PATCH 6/6] fix: remove invalid name argument from Configure and Bind on OptionsBuilder --- .../ServiceCollectionExtensions.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs index 11aad89..279f51b 100644 --- a/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs +++ b/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -59,8 +59,10 @@ public IServiceCollection AddDataverseClient(IConfiguration configuration) /// The service collection for chaining. public IServiceCollection AddDataverseClient(string key, Action configureOptions) { + // AddOptionsWithValidateOnStart(key) scopes the builder to the named instance; + // Configure(action) without a name argument binds to that same named instance. services.AddOptionsWithValidateOnStart(key) - .Configure(key, configureOptions) + .Configure(configureOptions) .ValidateDataverseClientOptions(); return services.AddKeyedDataverseClientCore(key); @@ -83,8 +85,10 @@ public IServiceCollection AddDataverseClient(string key, Action public IServiceCollection AddDataverseClient(string key, IConfiguration configuration) { + // AddOptionsWithValidateOnStart(key) scopes the builder to the named instance; + // Bind(configuration) without a name argument binds to that same named instance. services.AddOptionsWithValidateOnStart(key) - .Bind(key, configuration) + .Bind(configuration) .ValidateDataverseClientOptions(); return services.AddKeyedDataverseClientCore(key);