You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
* Initial plan
* feat: add named keyed Dataverse client registration
* fix: correct named options binding and rename Named to Keyed
* refactor: rename CreateNamed to CreateKeyed in ServiceClientFactory
* refactor: rename Named to Keyed in tests and update README and AGENTS docs
* fix: remove invalid name argument from Configure and Bind on OptionsBuilder
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Lars Bauer <larsbauer91@gmail.com>
Copy file name to clipboardExpand all lines: AGENTS.md
+25-17Lines changed: 25 additions & 17 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,22 +2,22 @@
2
2
3
3
## Project overview
4
4
5
-
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.
5
+
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()`).
6
6
7
7
## Scope & non-goals
8
8
9
-
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.
9
+
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.
-**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.
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.
14
+
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.
15
15
16
-
## Architecture (4 source files)
16
+
## Architecture
17
17
18
-
-`ServiceCollectionExtensions.cs` — Public API surface. `AddDataverseClient()` overloads(one taking `Action<DataverseClientOptions>`, one taking `IConfiguration` to bind from a config section) using C# 14 `extension` blocks. Shared wiring/validation live in private static helpers (`AddDataverseClientCore`, `ValidateDataverseClientOptions`).
18
+
-`ServiceCollectionExtensions.cs` — Public API surface. `AddDataverseClient()` overloads: unkeyed (one taking `Action<DataverseClientOptions>`, one taking `IConfiguration`) and keyed (same two overloads with a leading `string key` parameter). Uses C# 14 `extension` block style.
19
19
-`DataverseClientOptions.cs` — Options POCO with `OrganizationUrl` (required), `TokenCredential`, `DeferConnection`.
20
-
-`Internal/ServiceClientFactory.cs` — Singleton factory wiring `Azure.Identity` credentials into `ConnectionOptions.AccessTokenProviderFunctionAsync`. Marked `internal`, tested via `InternalsVisibleTo`.
20
+
-`Internal/ServiceClientFactory.cs` — Factory with two methods: `Create` (unkeyed, uses `IOptions<T>`) and `CreateKeyed` (uses `IOptionsMonitor<T>.Get(key)`). Marked `internal`, tested via `InternalsVisibleTo`.
21
21
- Root namespace is `BauerApps.Dataverse.Extensions` (set via `<RootNamespace>` in csproj, differs from folder name).
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"`).
30
+
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.
31
31
32
32
## Conventions
33
33
34
34
-**Namespace**: `BauerApps.Dataverse.Extensions` (library), `BauerApps.Dataverse.Extensions.Tests` (tests). Mirror `Internal/` subfolder in both.
35
35
-**Internal access**: `InternalsVisibleTo` is configured via `<AssemblyAttribute>` in csproj, not `AssemblyInfo.cs`.
36
36
-**Test structure**: Mirrors source layout. `ServiceCollectionExtensionsTests.cs` at root, `Internal/ServiceClientFactoryTests.cs` for internal classes. Tests use Arrange/Act/Assert with comments.
37
37
-**Test pattern**: All tests set `DeferConnection = true` to avoid real Dataverse connections. Use `FakeTokenCredential` (private nested class) when testing custom credentials.
38
+
-**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.
38
39
-**C# 14 features**: Uses `extension` blocks (not classic `static` extension methods). Keep this style for new extensions.
39
-
-**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.*`.
40
+
-**Dependencies**: `Azure.Identity`, `Microsoft.Extensions.Options`, `Microsoft.Extensions.Options.ConfigurationExtensions` (for the `IConfiguration` binding overload), `Microsoft.PowerPlatform.Dataverse.Client`.
41
+
-**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.
40
42
41
43
## CI/CD & versioning
42
44
43
-
-**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 `<Version>`via the `<!-- x-release-please-version -->` marker comment.
45
+
-**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 `<Version>`in the csproj by release-please — never edit these manually.
44
46
-**Commit messages**: Use [Conventional Commits](https://www.conventionalcommits.org/) — `feat:` (minor bump), `fix:` (patch), `feat!:` or `BREAKING CHANGE` footer (major).
45
47
-**CI** (`.github/workflows/ci.yml`): Builds and tests on every push/PR to `main`.
46
-
-**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).
48
+
-**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.
47
49
48
50
## Release process
49
51
50
-
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.
52
+
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.
51
53
52
54
**Configuration**:
53
55
54
-
-`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 `<Version>` (marked with `<!-- x-release-please-version -->`) is updated on release. Changelog is written to `Dataverse.Extensions.DependencyInjection/CHANGELOG.md`.
56
+
-`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`).
55
57
-`.release-please-manifest.json` — the current released version, keyed by package directory. Do not edit by hand; release-please maintains it.
56
58
57
59
**Commit message rules** (these determine the next version bump):
@@ -73,13 +75,19 @@ Notes:
73
75
1. Open a PR with Conventional Commit message(s) and merge to `main`.
74
76
2. release-please opens (or updates) a **Release PR** that bumps the version, updates `CHANGELOG.md`, and updates `.release-please-manifest.json`.
75
77
3. Review and merge the Release PR. This creates the GitHub release + `vX.Y.Z` tag.
76
-
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`.
78
+
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.
77
79
78
80
## Key patterns
79
81
80
82
When adding new configuration options:
81
83
1. Add property to `DataverseClientOptions`
82
-
2. Add validation in `ServiceCollectionExtensions.AddDataverseClient()` via `.Validate()` if required
83
-
3. Wire it in `ServiceClientFactory.Create()` using the options pattern (`IOptions<T>`)
84
+
2. Add validation in `ServiceCollectionExtensions` via `.Validate()` if required
85
+
3. Wire it in `ServiceClientFactory` using the appropriate options pattern (`IOptions<T>` for unkeyed, `IOptionsMonitor<T>` for keyed)
84
86
4. Test both the registration (service descriptor assertions) and the factory behavior
87
+
5. Update `README.md` with usage examples and `AGENTS.md` with any architecture or convention changes
85
88
89
+
When adding a new keyed registration:
90
+
- Use `AddKeyedSingleton` / `AddKeyedScoped` — do NOT reuse the unkeyed core path
91
+
- Thread the key through via the keyed service factory delegate `(sp, key) => ...`
92
+
- Use `IOptionsMonitor<T>.Get(key)` — never `IOptions<T>` for keyed registrations
93
+
- Keep unkeyed and keyed paths fully independent to avoid `Options.DefaultName` collisions
0 commit comments