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 @@
[](https://www.nuget.org/packages/BauerApps.Dataverse.Extensions.DependencyInjection)
[](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);