This query-object pattern is something I got from listening to a .NET Rocks episode #1494 Developer Tips and Design Patterns with Steve Smith. Steve Smith talks about the Specification Pattern at 49:50 in the podcast, and this is what inspired this.
Each query object owns a use-case-specific read operation, including its criteria
and projection. Unlike a classic specification, it describes the whole query
rather than just reusable selection criteria. Existing package and API names
such as AddQueryPattern remain unchanged.
What Steve describes is a problem we saw in our projects. Developers would keep adding more methods to the repository or keep adding JOINs to a result
because they need this one other column added to an already returning list. This will start to overly complicate the original list. For example, you
already have a screen that shows the list of users, and if they are active or not. Now, some other screen needs the list of users and wants the security
role or permissions that user has also displayed. What happens often is someone just goes and modifies that original user list and adds this other data.
So, where you needed a simple quick list of users is now doing way more than it needs and could potentially slow down the query.
This pattern also gives you the ability to easily test your queries. As Steve points out in the podcast what might work fine in compile time, or even within a unit test using mocked lists as your data, could give you a runtime error when running against EntityFramework.
The sample and tests use SQLite in-memory databases, which execute actual SQL. EF Core's InMemory provider does not validate SQL translation. SQLite coverage does not guarantee compatibility with another provider: also test application queries against the database engine used in production.
Contexts are injected and scoped, with one typed query-context/repository pair
per DbContext; multiple databases remain independent. List and scalar execution
support cancellation without removing the existing overloads. Automatic discovery
still scans all loaded assemblies by default, with optional explicit assembly
selection. See the usage documentation
for configuration, multiple-context examples, and cancellation compatibility.
dotnet add package myNOC.EntityFramework.QueryThe package, tests, and sample target .NET 8.0 and .NET 10.0 (LTS). The .NET 8 asset uses the latest EF Core 8 servicing release; the .NET 10 asset uses EF Core 10. EF Core 10 cannot run on .NET 8. Dependency reports may therefore show newer major versions for .NET 8; this is intentional. Test tooling uses the current stable MSTest, VSTest, Coverlet and NSubstitute.
Install the .NET SDK selected by global.json, plus the .NET 8 runtime to run the .NET 8 tests. No .NET 9 SDK/runtime is needed. Run these commands from the repository root:
dotnet tool restore
dotnet restore -warnaserror
dotnet build --configuration Release --no-restore -warnaserror
dotnet test --configuration Release --no-build --no-restore --logger trx --collect:"XPlat Code Coverage" --results-directory artifacts/test-results
dotnet pack src/myNOC.EntityFramework.Query --configuration Release --no-build --no-restore -warnaserror --output artifacts/packages
dotnet list package --outdated --include-transitive
dotnet list package --vulnerable --include-transitiveWarnings are errors in every project. Tests run for both target frameworks. PowerShell 7 is required for the versioning and package-validation scripts. To reproduce CI's version stamping locally:
./scripts/Test-Versioning.ps1
./scripts/Set-BuildVersion.ps1
$env:VersionPropsFile = (Resolve-Path artifacts/Version.props).Path
dotnet restore -warnaserror
dotnet build --configuration Release --no-restore -warnaserror
dotnet pack src/myNOC.EntityFramework.Query --configuration Release --no-build --no-restore -warnaserror --output artifacts/packages
$version = dotnet gitversion /output json /nofetch | ConvertFrom-Json
./scripts/Test-Packages.ps1 -Version $version.SemVer -Commit $version.Sha
Remove-Item Env:VersionPropsFileSource downloads require network access and a commit pushed to GitHub. Uncommitted source changes cannot match the published commit's checksums. Without the version props file, local builds retain the SDK's default version.
GitVersion.yml uses GitVersion 6, pinned in the local
tool manifest. CI fetches complete history and tags, then generates a single
props file used by restore, build and pack. It stamps Version, PackageVersion,
AssemblyVersion, FileVersion and InformationalVersion. The informational
version and NuGet repository metadata contain the exact build commit.
mainproduces only stablemajor.minor.patchpackages; two guards reject prereleases and publishing from any other branch.- A commit after
v1.2.3produces1.2.4. Publishing createsv1.2.4on that exact commit; the next release becomes1.2.5. - Rerunning a tagged commit retains its version rather than incrementing it.
- Add
+semver: minorto the commit or merge/squash message for1.3.0. Add+semver: majorfor2.0.0. Preserve these directives when squashing. - Work/feature branches produce
alpha.<branch>.<number>previews; PRs produce preview versions. CI validates but never publishes them. - Avoid
+semver: nonefor publishable changes: NuGet versions are immutable.
Test-Versioning.ps1 exercises patch releases, stable tags, tagged reruns, previews, explicit minor/major increments and merge-message increments in an isolated temporary Git repository.
Build and Test restores, builds and tests
the entire solution for both frameworks, collects coverage/TRX files, audits
dependencies, validates workflow syntax, and creates packages with warnings
treated as errors. Its packaged Source Link check downloads source and verifies
checksums for each PDB extracted from the .snupkg, not just build output.
Test results, coverage, dependency reports and validated packages are artifacts.
Release calls that same validation workflow,
then publishes only from main using the nuget GitHub environment.
Repository workflow token permissions default to read-only; the publishing job
alone receives contents: write and id-token: write.
Branch protection requires successful build-test and workflow-lint checks,
an up-to-date branch and resolved conversations. Merged branches are deleted
automatically; force-pushes and deletion of main are disallowed.
Trusted Publishing configuration for this repository:
| Setting | Value |
|---|---|
GitHub secret NUGET_USER |
erenken (NuGet username, not an API key) |
| GitHub environment | nuget, deployment branch restricted to main |
| NuGet policy name | queryPattern-release |
| Package owner | erenken |
| Provider | GitHub Actions |
| Repository owner / repository | erenken / queryPattern |
| Workflow filename | release.yml (not a full path) |
| Environment in policy | nuget (must match exactly) |
| Package scope | myNOC.EntityFramework.Query (no wildcard) |
| Permissions | Push only new package versions; no unlist/relist |
Manage the policy at NuGet Trusted Publishing.
NuGet/login exchanges a GitHub OIDC token for a short-lived publishing key.
There is no permanent API-key fallback. After this PR is merged, delete the
legacy NUGET_PUBLISH repository secret and revoke its old key on NuGet.
It is retained during the PR so the existing main workflow is not broken.
Releases are serialized. The workflow checks that an existing version tag
points to the exact build commit, explicitly publishes packages and symbols
with duplicate skipping, then creates a stable GitHub release and v<version>
tag. A previously published NuGet version must have matching repository commit
metadata; a collision from a different commit fails rather than silently
skipping and creating a misleading tag. After pushing, the workflow waits for
package availability and verifies that provenance again before creating the
release. Existing releases are reused and assets refreshed on reruns. A failed-job
rerun downloads the original successful validation job's package artifact.
NuGet or symbol indexing may finish after the push completes; unrelated
authentication/network errors fail the job rather than being ignored.
Every framework has a portable PDB in the separate .snupkg. The SDK's built-in
Source Link provider maps source to the exact GitHub commit; no extra Source
Link package is required. The primary .nupkg contains the framework DLLs.
In Visual Studio, add https://symbols.nuget.org/download/symbols under
Tools > Options > Debugging > Symbols, enable Source Link support,
and disable Just My Code when stepping into the library. Load the symbols
for myNOC.EntityFramework.Query.dll, then step into a method. Wait for NuGet's
symbol validation/indexing if symbols are not available immediately.
.tools/ (downloaded CLI/lint tools), .validation/ (local inspection output),
.pack/, artifacts/ (packages, version props, TRX/coverage/audit reports),
and project bin//obj/ directories are ignored and can be deleted after
validation. .nupkg and .snupkg files are ignored anywhere. Removing the
tools means downloading them again if needed. Keep the tracked tool manifest,
build props, GitVersion configuration and validation scripts.
See the library usage documentation for scoped context registration, multiple databases, automatic discovery, query objects, and cancellation-aware execution.
The sample application creates and seeds a temporary SQLite in-memory database, then runs list and scalar queries. No external database server is required. Run it from the repository root for either supported target:
dotnet run --project sample/QuerySample --framework net8.0
dotnet run --project sample/QuerySample --framework net10.0The tests cover SQL translation and
results, cancellation, assembly discovery, and isolation/disposal of multiple
database contexts. Run both targets with dotnet test QueryPattern.sln.