diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 450cf76d..228b3a6b 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -40,6 +40,7 @@ solution; the rule applies to consumer solutions where these assets are installe
- **Samples**: docker-compose infrastructure + dotnet run for Database projects + Aspire AppHost.
- **Linting**: No separate `dotnet format`. Build is the lint pass (nullable, LangVersion=preview, TreatWarningsAsErrors in `src\Directory.Build.props`).
- **Formatting**: 4 spaces for `*.cs`, 2 spaces for `*.json|*.xml|*.yaml|*.props|*.csproj|*.sln|*.sql` per `.editorconfig`.
+- **Ad-hoc spike/reflection projects**: this repo uses Central Package Management (root `Directory.Packages.props`). A throwaway `dotnet new console` project scaffolded *inside* the repo tree (even outside `src\`/`tests\`) will silently inherit it and can fail to restore (`NU1008`) if it references a package/version not centrally pinned. Scaffold spike projects outside the repo tree, or set `false` in the spike project's own `.csproj` to opt out locally.
## Local Development Infrastructure
diff --git a/.github/instructions/coreex-conventions.instructions.md b/.github/instructions/coreex-conventions.instructions.md
index 83b81a1b..4c20fcc4 100644
--- a/.github/instructions/coreex-conventions.instructions.md
+++ b/.github/instructions/coreex-conventions.instructions.md
@@ -157,6 +157,7 @@ public async Task CreateAsync(Employee value, CancellationToken cancel
- **Controllers / Minimal-API handlers** take a `CancellationToken` parameter (ASP.NET binds/injects it), pass it to the `WebApi` helper via `cancellationToken:`, and use the helper lambda's `ct` for the service call — see `coreex-api-controllers.instructions.md`.
- **Interfaces** declare the parameter too, so implementations and callers can honour it (`Task GetAsync(string id, CancellationToken cancellationToken = default);`).
- **Tests** are the exception — `Test.Scoped(...)` / the `WebApi` lambda supply the token; you don't manufacture one.
+- **When calling any method with more than one optional parameter after the token's usual slot, pass `cancellationToken` by name, not position.** Several CoreEx extensions (e.g. `ToMappedItemsResultAsync`/`ToItemsResultAsync` — `(mapper, paging = null, autoCount = true, cancellationToken = default)`) have a `bool` parameter sitting between the common arguments and `cancellationToken`. A bare positional token there binds to the `bool` instead and fails to compile — write `cancellationToken: cancellationToken` explicitly whenever a call has 3+ optional parameters, rather than assuming it's always last positionally.
## XML Documentation Comments
@@ -195,6 +196,27 @@ public class EmployeeService(IUnitOfWork unitOfWork, IEmployeeRepository reposit
}
```
+## When Unsure of a CoreEx API Member
+
+CoreEx's singleton/static call-site patterns (`Default`, `To`/`From`, static `Map`) look similar across types but differ in real, specific ways. **Never invent a plausible-sounding member name under uncertainty** — `MapToEntity`, `MapToDto`, `OnMapToPrimary`/`OnMapToSecondary`, and similar guesses do not exist on any CoreEx mapper type, and guessing wastes a compile-fail round-trip at best and silently ships broken code at worst.
+
+**Resolution order when unsure of an exact member/signature:**
+
+1. Check the relevant `.github/instructions/coreex-*.instructions.md` file first — the call pattern for every commonly-used type is already documented with a working example (see the quick-reference table below for the most frequently guessed ones).
+2. If still unsure, **just build** — a `CS1061`/`CS0117` ("no such member") is fast, authoritative, and reflects the *actually-restored* package version. Prefer this over speculative research.
+3. Check the docs-sync cache (`.github/docs/coreex/*.md`, after `/coreex-docs-sync`) if present.
+4. Only as a last resort, inspect CoreEx source directly — either the restored NuGet package (decompile, or `~/.nuget/packages/coreex*//`) or the GitHub repo. If using GitHub, **verify the tag/branch matches the installed package version** — `main` can be ahead of or behind what's actually referenced; do not assume it's current, and do not guess at file paths (e.g. the 3-generic-argument singleton mapper base lives in `BiDirectionMapperT3.cs`, not `BiDirectionMapper.cs`).
+5. If still unresolved, ask the developer rather than fabricate a member name.
+
+**Quick reference — exact call patterns (do not substitute a guessed name):**
+
+| Type | Override | Call site |
+|---|---|---|
+| `BiDirectionMapper` | Two `OnMap` overloads, same name, distinguished by source type — **not** `OnMapToPrimary`/`OnMapToSecondary` | `{Name}Mapper.To.Map(source)` (left→right), `{Name}Mapper.From.Map(source)` (right→left) — **not** `MapToEntity`/`MapToDto`/`.Default.Map(...)` |
+| `Mapper` (uni-directional) | One `OnMap(TSource)` | `{Name}Mapper.Map(source)` — static, directly on the mapper class, **not** `.Default.Map(...)` |
+| `Validator` | Declarative rules / `OnValidateAsync` | `{Name}Validator.Default.ValidateAndThrowAsync(...)` / `.ValidateWithResultAsync(...)` — **never** bare `.ValidateAsync(...)` |
+| `QueryArgsConfig` | Constructor-only `WithFilter`/`WithOrderBy` | `{Name}QueryArgsConfig.Default.Parse(query).ThrowOnError()` — never instantiate per-request |
+
## Private Field Naming
Private instance fields are always prefixed with `_`. No exceptions.
@@ -218,7 +240,9 @@ private readonly ILogger _logger;
- Do not use `DateTime.UtcNow` or `DateTimeOffset.UtcNow` — use `Runtime.UtcNow` (or `Runtime.UtcNow.UtcDateTime` for a `DateTime`).
- Do not use `Guid.NewGuid()` — use `Runtime.NewGuid()`.
- Do not omit or drop `CancellationToken` — every `async`/`Task`-returning method takes one (`CancellationToken cancellationToken = default`, last parameter) and passes it to every downstream awaitable call.
+- Do not pass `cancellationToken` positionally to a method with 3+ optional parameters (e.g. `ToMappedItemsResultAsync`/`ToItemsResultAsync`) — pass it as `cancellationToken:` by name; a bare positional token can silently bind to an unrelated `bool` parameter (e.g. `autoCount`) and fail to compile.
- Do not replace a private backing field with an auto-property simply because it could be one — backing fields are a valid developer choice.
- Do not leave interface members or contract properties undocumented — each gets a ``.
- Do not invert the doc convention — summaries go on **interfaces and contract properties**; the **implementing** class member gets `` (not a fresh summary). Summarising the concrete class while leaving the interface/contract undocumented is backwards.
- Do not leave a private method undocumented — it still needs a `` (params/returns optional), unless it's a test helper in a `*.Test*` project.
+- Do not invent a plausible-sounding CoreEx member name when unsure (`MapToEntity`, `OnMapToPrimary`/`OnMapToSecondary`, etc.) — check the relevant instructions file, then just build (a compile error is authoritative), before ever searching GitHub blind.
diff --git a/.github/instructions/coreex-host-setup.instructions.md b/.github/instructions/coreex-host-setup.instructions.md
index 2ebe23f0..67b28540 100644
--- a/.github/instructions/coreex-host-setup.instructions.md
+++ b/.github/instructions/coreex-host-setup.instructions.md
@@ -275,7 +275,7 @@ Key points:
- `UseIdempotencyKey()` must come **after** `UseExecutionContext()`.
- If the domain also publishes directly to Service Bus (e.g. for cross-domain adapters), add `AddAzureServiceBusPublisher(..., addAsDefaultIEventPublisher: false)` so the outbox publisher remains the default `IEventPublisher`.
- `MapHealthChecks()`'s **basic** `live`/`startup`/`ready` endpoints are intentionally left anonymous — they're conventionally probed by container orchestrators without credentials. Its **detailed** endpoints (`/health/*/detailed`) are different: they emit the full `HealthReport`, which can include component names and exception details, and should be secured — pass `detailedGroupConfigure: g => g.RequireAuthorization()`, **commented out by default** (as shown above) since it 500s until an authentication scheme and authorization services are registered. Uncomment alongside `UseAuthentication()` once a scheme is configured.
-- **`CoreEx.Data.GraphQL` is additive, not part of the base scaffold** — add it only when explicitly asked for a GraphQL query surface; use the `coreex-graphql` skill to wire it (`.github/skills/coreex-graphql/SKILL.md`) rather than improvising. Register roots with `builder.Services.AddCoreExGraphQLLite((o, sp) => o.AddQuery("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false)).AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.TryGetValue("id", out var id) && id is string { Length: > 0 } s ? s : throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args)), ct)))`, then `app.MapCoreExGraphQLLite("/query");` after `app.MapControllers();`. Resolve scoped dependencies (repositories, application services) per-invocation via `CoreEx.ExecutionContext.GetRequiredService()` — never capture an instance from the root `IServiceProvider` at registration time, since `IGraphQLEngine` is a singleton. It bridges GraphQL `where`/`orderBy` 1:1 onto the entity's existing `QueryArgsConfig` — no new resolver/filter logic. `MapCoreExGraphQLLite` executes through `WebApi.PostAsync(...)`, so standard exception-handling middleware applies as a safety net; add `.WithCoreExGraphQLTelemetry()` alongside the host's other OpenTelemetry tracing extensions to trace `ExecuteAsync` calls. To expose all reference data types as GraphQL roots in one call, use `o.AddReferenceDataQueries(sp, ReferenceDataQueryArgsConfig.Default)` — this bulk-registers every type known to the `ReferenceDataOrchestrator`, keyed as `ref_` where `` is the type's `AlternateNames` entry where registered, otherwise its own `Type.Name`; pass `excludeTypes` to opt specific types out. See [`CoreEx.Data.GraphQL` AGENTS.md](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Data.GraphQL/AGENTS.md).
+- **`CoreEx.Data.GraphQL` is additive, not part of the base scaffold** — add it only when explicitly asked for a GraphQL query surface; use the `coreex-graphql` skill to wire it (`.github/skills/coreex-graphql/SKILL.md`) rather than improvising. Register roots with `builder.Services.AddCoreExGraphQLLite((o, sp) => o.AddQuery("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false)).AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct)))` — `GetIdentifier` validates presence and type of the named argument (default `"id"`) — it casts to `TId`, it does not convert — and throws an `ArgumentException`, mapped by the engine to `ARGUMENT_ERROR`, if it's missing/empty/wrong-typed — then `app.MapCoreExGraphQLLite("/query");` after `app.MapControllers();`. Resolve scoped dependencies (repositories, application services) per-invocation via `CoreEx.ExecutionContext.GetRequiredService()` — never capture an instance from the root `IServiceProvider` at registration time, since `IGraphQLEngine` is a singleton. It bridges GraphQL `where`/`orderBy` 1:1 onto the entity's existing `QueryArgsConfig` — no new resolver/filter logic. `MapCoreExGraphQLLite` executes through `WebApi.PostAsync(...)`, so standard exception-handling middleware applies as a safety net; add `.WithCoreExGraphQLTelemetry()` alongside the host's other OpenTelemetry tracing extensions to trace `ExecuteAsync` calls. To expose all reference data types as GraphQL roots in one call, use `o.AddReferenceDataQueries(sp, ReferenceDataQueryArgsConfig.Default)` — this bulk-registers every type known to the `ReferenceDataOrchestrator`, keyed as `ref_` where `` is the type's `AlternateNames` entry where registered, otherwise its own `Type.Name`; pass `excludeTypes` to opt specific types out. See [`CoreEx.Data.GraphQL` AGENTS.md](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Data.GraphQL/AGENTS.md).
---
diff --git a/.github/instructions/coreex-repositories.instructions.md b/.github/instructions/coreex-repositories.instructions.md
index 29a05bab..084638c5 100644
--- a/.github/instructions/coreex-repositories.instructions.md
+++ b/.github/instructions/coreex-repositories.instructions.md
@@ -140,6 +140,8 @@ The model accessors (`EfDbModel` and `EfDbMappedModel<...>`) expose two varia
Querying: `Query(...)` returns a filtered `IQueryable` (logical-delete and tenant filters already applied); `QueryTracked(...)` is the change-tracked variant. Materialize via the extensions `ToMappedItemsResultAsync()` (→ `ItemsResult` with paging/count), `ToMappedItemsAsync<...>()`, or `ToItemsResultAsync()`.
+> **`cancellationToken` must be a named argument on `ToMappedItemsResultAsync`/`ToItemsResultAsync`.** Both signatures are `(mapper, paging = null, autoCount = true, cancellationToken = default)` — `autoCount` (`bool`) sits **before** `cancellationToken`. A bare positional `CancellationToken` in the third slot (e.g. `.ToMappedItemsResultAsync(mapper, paging, cancellationToken)`) binds to `autoCount` and fails to compile (`CS1503`). Always write `cancellationToken: cancellationToken` explicitly.
+
Per-model behaviour is configured on `EfDbOptions` / `EfDbModelOptions`: `WithModel(...)`, `WithLogicalDeleteFilter()`, `WithTenantFilter()`, `WithFilter(...)`, `WithGetKey(...)`, `WithArgs(...)`, `WithOnBeforeCreateOrUpdate(...)`, `WithUpdateModelMapper(...)`.
## Dynamic Query Configuration
@@ -192,6 +194,8 @@ Choose the `AddField` overload based on the contract property type:
| `AddNullField(field, ...)` | Null/not-null check only (no value comparison) | `Equal\|NotEqual` (null semantics) | `.WithModelPrefix(...)` |
| `AddReferenceDataField(field, ...)` | Any `IReferenceData` type (resolved by code via orchestrator) | `EqualityOperators` (`eq`/`ne`/`in`) | `.MustBeActive(...)` |
+> **Reference data fields — don't investigate, apply the rule.** `AddReferenceDataField(field, model, ...)`'s `field` argument is **always** the contract's generated navigation property name (`{Name}`, not `{Name}Code`) — a fixed Roslyn source-generator convention (`[ReferenceData] public partial string? {Name}Code { get; set; }` → generated `{Name}` nav property), documented in [`coreex-contracts.instructions.md#reference-data-properties`](/.github/instructions/coreex-contracts.instructions.md#reference-data-properties). This is never something to confirm by reading generated `.g.cs` files or exploring generator internals — it's deterministic. The `model` argument is the underlying persistence/EF column holding the code value; ask the developer only if the persistence model renames it from the `{Name}Code` default.
+
**Operator quick-reference** — use with `.WithOperators(...)` (combine flags with `|`):
| Flag / Composite | Filter string operators enabled | Typical use |
@@ -245,7 +249,7 @@ public async Task> QueryAsync(QueryArgs? quer
Sku = x.Product.Sku,
CategoryCode = x.CategoryCode,
QtyOnHand = x.QtyOnHand
- }, paging, cancellationToken)
+ }, paging, cancellationToken: cancellationToken) // named — `autoCount` (bool) sits before `cancellationToken` in the signature; a bare positional token there fails to compile (CS1503)
.ConfigureAwait(false);
}
```
@@ -404,6 +408,7 @@ Always call `.ConfigureAwait(false)` on every `await` inside repository and adap
- Do not reference the Infrastructure project from the Application layer — Infrastructure implements Application interfaces, not the other way around.
- Do not use AutoMapper or reflection-based mappers — use `BiDirectionMapper` with explicit `OnMap` overrides.
+- Do not call the mapper via an invented member name (`MapToEntity`, `MapToDto`, `.Default.Map(...)`) — the real call sites are `{Name}Mapper.To.Map(source)` (left→right) and `{Name}Mapper.From.Map(source)` (right→left); see [`coreex-conventions.instructions.md#when-unsure-of-a-coreex-api-member`](/.github/instructions/coreex-conventions.instructions.md#when-unsure-of-a-coreex-api-member) if unsure.
- Do not call `HttpClient` directly in adapter methods — use the typed HTTP client class in `Clients/`.
- Do not conflate Application-level mapping (aggregate ↔ contract) with Infrastructure-level mapping (contract ↔ persistence model).
- Do not write raw `DbContext` queries for standard CRUD — use the `EfDb` delegate methods.
diff --git a/.github/skills/coreex-api/SKILL.md b/.github/skills/coreex-api/SKILL.md
index 2b2a467b..84e963c1 100644
--- a/.github/skills/coreex-api/SKILL.md
+++ b/.github/skills/coreex-api/SKILL.md
@@ -45,6 +45,7 @@ Guides you through adding or modifying HTTP API endpoints in an `*.Api` host. Co
5. MVC controllers or Minimal API?
**Key rules at a glance:**
+- **If Q4 answer is "needs to be created":** stop before scaffolding the controller pair and invoke `coreex-app-service` (Path C — CQRS Read Service) to create `I{Name}ReadService`/`{Name}ReadService` first. Never shortcut by adding query/collection methods to the existing `I{Name}Service`/`{Name}Service`.
- Inherit from `ControllerBase` — **never** `Controller` (that adds View support)
- **CQRS split:** `{Name}Controller` (POST/PUT/PATCH/DELETE → `I{Name}Service`) + `{Name}ReadController` (GET/query → `I{Name}ReadService`). Both use the **same route** and **same `[OpenApiTag]`** so they appear as one OpenAPI group
- All action methods return `Task` via the `WebApi` helper — never `ActionResult` directly
diff --git a/.github/skills/coreex-api/references/workflow.md b/.github/skills/coreex-api/references/workflow.md
index 6e531c53..bbcc6e19 100644
--- a/.github/skills/coreex-api/references/workflow.md
+++ b/.github/skills/coreex-api/references/workflow.md
@@ -22,6 +22,8 @@ Full workflow for adding or modifying HTTP API endpoints in a CoreEx `*.Api` hos
| Read service (`I{Name}ReadService`) already exists? | Ask | Controllers cannot be written without a service to delegate to |
| MVC controllers or Minimal API? | MVC | MVC for most projects; Minimal API for lighter hosts — see Step 7 |
+**Gate — do not skip:** if the CQRS split calls for a `{Name}ReadController` (any GET/query/`$query` endpoint being added or moved) and `I{Name}ReadService`/`{Name}ReadService` does not already exist, **stop before Step 1** and invoke [`coreex-app-service`](../../coreex-app-service/SKILL.md) (Path C — CQRS Read Service) to create the read service pair first. Never add query/collection methods to the existing `I{Name}Service`/`{Name}Service` as a shortcut, and never write `{Name}ReadController` against a service that doesn't exist yet — only the by-id `GetAsync` may legitimately live on both. Resume the controller work once the read service exists.
+
---
## Step 1 — Scaffold the Controller Pair
@@ -339,6 +341,7 @@ All the same rules apply: `.Required()` on route params, no business logic in ha
1. `dotnet build` — no errors or warnings.
2. Confirm both `{Name}Controller` and `{Name}ReadController` share the same `[Route]` and `[OpenApiTag]`.
+2a. Confirm `{Name}ReadController` delegates to a real, separately-defined `I{Name}ReadService`/`{Name}ReadService` (created via `coreex-app-service` Path C) — not a query method bolted onto the existing `I{Name}Service`/`{Name}Service`.
3. Every route parameter uses `.Required()` — search for `.ThrowIfNull()` on route params and replace.
4. POST endpoints have `[IdempotencyKey]` (or user has explicitly declined it).
5. PUT + PATCH are both present for full-entity update (unless partial-update is the explicit ask).
diff --git a/.github/skills/coreex-app-service/SKILL.md b/.github/skills/coreex-app-service/SKILL.md
index 83fb66d2..dfc3d5d2 100644
--- a/.github/skills/coreex-app-service/SKILL.md
+++ b/.github/skills/coreex-app-service/SKILL.md
@@ -58,6 +58,7 @@ Guides you through creating or modifying a CoreEx Application-layer service in `
- `Validator` (with injection): instantiate at call site — `new {Name}Validator(_dep).ValidateAndThrowAsync(...)`
- All interface methods include `CancellationToken cancellationToken = default` as the last parameter; pass through to every async call and `TransactionAsync(async ct => ..., cancellationToken)`
- CQRS: mutations + `GetAsync` → `{Name}Service`; queries + `GetAsync` → `{Name}ReadService` (both have `GetAsync`)
+- **Before building Path C:** confirm `I{Name}Repository` already has `QueryAsync`/`QuerySchemaAsync` backed by a `{Name}QueryArgsConfig`. If not, stop and invoke `coreex-repository` first — never add filtering/ordering logic or a hand-rolled query in the service to work around a missing repository method
- Always `.ConfigureAwait(false)` on every `await`
- A Domain value object persisted via a JSON column (e.g. `Basket.ShippingAddress`) is mapped with a `BiDirectionMapper` in `Application/Mapping/` (not the uni-directional `Mapper` used for the root aggregate) — see [`coreex-application-services.instructions.md#json-backed-value-object-mapping`](/.github/instructions/coreex-application-services.instructions.md#json-backed-value-object-mapping)
diff --git a/.github/skills/coreex-app-service/references/workflow.md b/.github/skills/coreex-app-service/references/workflow.md
index 19dce01d..cab18f92 100644
--- a/.github/skills/coreex-app-service/references/workflow.md
+++ b/.github/skills/coreex-app-service/references/workflow.md
@@ -21,7 +21,7 @@ Full workflow for creating or modifying a CoreEx Application-layer service in `A
| Cross-domain or external-service calls? | No | Yes → adapter interface + Path D |
| Policy guard checks requiring I/O? | No | Yes → policy class + Path D |
| Domain layer present? | No | Yes → `Application/Mapping/` mapper; affects Path B |
-| Read queries or collection results needed? | No | Yes → Path C (CQRS read service) |
+| Read queries or collection results needed? | No | Yes → Path C (CQRS read service) — **first** confirm `I{Name}Repository.QueryAsync`/`QuerySchemaAsync` already exist; if not, stop and invoke `coreex-repository` to add the `{Name}QueryArgsConfig` before building the read service |
---
@@ -282,6 +282,8 @@ return await OrchestrateUpdateAsync(id, entity => entity.{Action}(pr.Value))
Split read operations from mutation operations. Both `{Name}Service` and `{Name}ReadService` expose `GetAsync` — this is **intentional**. Mutations + `GetAsync` belong to `{Name}Service`; queries and read-model shapes belong to `{Name}ReadService`.
+**Stop-and-check before C1:** `{Name}ReadService.QueryAsync`/`QuerySchemaAsync` are thin delegations straight to the repository (see C2) — they carry no filtering/ordering logic of their own. That logic lives entirely in the repository's `{Name}QueryArgsConfig`. Confirm `I{Name}Repository` already exposes `QueryAsync(QueryArgs?, PagingArgs?, ...)` and `QuerySchemaAsync(...)` backed by a `{Name}QueryArgsConfig`. If it doesn't yet, invoke `coreex-repository` first to add it — do not stub, hand-roll, or work around a missing repository query method here.
+
### C1 — Interface
Create `Application/Interfaces/I{Name}ReadService.cs`:
@@ -418,3 +420,4 @@ if (pr.IsFailure)
- **Do not reference Infrastructure from Application** — reach persistence and transport through Application interfaces only.
- **Do not add Query to `{Name}Service`** — query/collection shapes belong exclusively in `{Name}ReadService`.
- **Do not split the repository to mirror CQRS** — both services share one `I{Name}Repository` per data source.
+- **Do not build `{Name}ReadService.QueryAsync` against a repository that lacks it** — invoke `coreex-repository` to add the `{Name}QueryArgsConfig` first; the read service has no filtering/ordering logic of its own to fall back on.
diff --git a/.github/skills/coreex-docs-sync/README.md b/.github/skills/coreex-docs-sync/README.md
index 263e414e..c86ce890 100644
--- a/.github/skills/coreex-docs-sync/README.md
+++ b/.github/skills/coreex-docs-sync/README.md
@@ -29,6 +29,10 @@ since refreshing now means "re-install the matching release," not "re-fetch `mai
Do not run this inside the CoreEx repository itself — the docs are already present locally at
`samples/docs/` and `src/*/AGENTS.md`, and there is no NuGet-referenced version to pin to.
+**After a refresh that adds/removes/renames a skill, restart your AI client** (not just start a new
+session) — most clients load their skill catalog once at startup, so a newly added or renamed skill
+folder under `.github/skills/coreex-*/` may not show up until the app itself is restarted.
+
## How to invoke
**Claude Code:**
diff --git a/.github/skills/coreex-docs-sync/SKILL.md b/.github/skills/coreex-docs-sync/SKILL.md
index cc441ef8..bc8fb7cb 100644
--- a/.github/skills/coreex-docs-sync/SKILL.md
+++ b/.github/skills/coreex-docs-sync/SKILL.md
@@ -37,7 +37,7 @@ Keeps `.github/instructions/`, `.github/skills/`, `.github/prompts/`, `.github/a
4. **Dry-runs first.** Runs `dotnet new coreex-ai --dry-run` (and `--app-folder ` if this is a monorepo, per the recorded app folder) and shows the "Create"/"Overwrite" file list it reports — `--dry-run` never writes anything, so this is always safe to run first, review it before proceeding. Do **not** rely on omitting `--force` alone: when none of the target files exist yet (e.g. a repo missing most of the bundle), `dotnet new coreex-ai` without `--force` and without `--dry-run` still writes every file for real — it only blocks and lists conflicts when files it would touch already exist and differ.
5. **Confirms, then applies** — re-runs with `--force`. Note: `--force` overwrites every file the template currently emits; it does **not** delete files the template no longer emits (see [Guardrails](#guardrails)).
6. **Flags possible orphans** — compares the skill folder names under `.github/skills/coreex-*/` against the current known skill catalog — both L1 per-capability skills and L2 end-to-end workflow skills — listed in [`coreex-ai-workflows.md`](/.github/coreex-ai-workflows.md); any extra folder is very likely left over from a prior version and should be reviewed for manual removal, not assumed safe. Scoped to the `coreex-` prefix deliberately: `CoreEx.Template` only ever installs `coreex-`-prefixed skill folders, and a consumer repo may have other, unrelated skills installed (its own or from other tooling) that this refresh neither writes nor owns — comparing *all* of `.github/skills/*/` would falsely flag those as CoreEx orphans.
-7. **Reports** the refreshed `coreex-version`, the files changed (from the step-4 dry run), and any flagged orphans.
+7. **Reports** the refreshed `coreex-version`, the files changed (from the step-4 dry run), any flagged orphans, and — if any skill folder was added, removed, or renamed under `.github/skills/coreex-*/` — a reminder that the running AI client (Copilot CLI, Copilot Chat, etc.) caches its skill catalog at session/app startup and will not see the new set until the client is restarted (a new session/tab alone is not always enough — some clients cache the catalog at the app-process level, not per-session).
## Guardrails
@@ -46,6 +46,7 @@ Keeps `.github/instructions/`, `.github/skills/`, `.github/prompts/`, `.github/a
- **Always dry-run before `--force`.** Use `dotnet new coreex-ai --dry-run` — it is the only invocation guaranteed never to write anything, regardless of whether the target files already exist. Omitting `--force` (without `--dry-run`) is not equivalent: it blocks and lists conflicts only when existing files would change, but it still creates any missing files for real with no confirmation — so it is not safe to treat as a preview on its own.
- **Hand-edits to template-sourced files do not survive a refresh.** Every file under `.github/instructions/`, `.github/skills/`, `.github/prompts/`, `.github/agents/coreex-expert.agent.md`, and `.claude/commands/` is regenerated by this process — see the header on each file.
- **Never run this inside the CoreEx framework repository itself.**
+- **A skill set change requires an AI client restart to take effect.** Files on disk update immediately, but the client's skill catalog is typically loaded once at startup — a new chat/session in the same running app may still show the stale list. If the diff added, removed, or renamed a skill folder, tell the user to fully restart the AI client (not just open a new session) before relying on the updated catalog.
## Rationale — why version-pinned only, no live `main` fetch
diff --git a/.github/skills/coreex-graphql/SKILL.md b/.github/skills/coreex-graphql/SKILL.md
index 3b4bf682..42a85cbd 100644
--- a/.github/skills/coreex-graphql/SKILL.md
+++ b/.github/skills/coreex-graphql/SKILL.md
@@ -51,6 +51,9 @@ Guides you through adding `CoreEx.Data.GraphQL` (GraphQL-lite) to an `*.Api` hos
- Add `.WithCoreExGraphQLTelemetry()` alongside the host's other OpenTelemetry tracing extensions
- `EnableIntrospection` defaults to `false`; `MapCoreExGraphQLLite` is anonymous by default — pass `RequireAuthorization()` explicitly if the host's REST endpoints are secured
- Record enablement in the host's `AGENTS.md` "This Host's Feature Configuration" section as a plain `**GraphQL:** Enabled (roots: ...)` line — no `dotnet new` template symbol; this is a per-host hand-authored fact added as a side effect of this skill
+- Use `args.GetIdentifier(name = "id")` inside an `AddGet` resolver to validate the identifier argument's presence and type (it casts to `TId`, it does not convert) — it throws an `ArgumentException` (mapped to `ARGUMENT_ERROR`) if missing/empty/wrong-typed. Do not reach for `args.TryGetValue(...)` directly — `GraphQLLiteArgs` is not a dictionary; use `args.Arguments.TryGetValue(...)` only for non-identifier arguments.
+- The `TItem`/`TId` generics in `AddQuery`/`AddGet` are just whatever the entity's existing read-service method already returns (e.g. `ProductLite`, or the plain contract if there's no separate "Lite" projection) — there is no required naming convention; "Lite" in the samples is that domain's own projection name, not a CoreEx requirement.
+- `CoreEx.Data.GraphQL` is preview-versioned, but the code samples in this skill (`AddQuery`, `AddGet`, `AddReferenceDataQueries`, `GetIdentifier`, `MapCoreExGraphQLLite`, `AddCoreExGraphQLLite`, `WithCoreExGraphQLTelemetry`) are verified against the real source and kept current — copy them as-is and just build. Do **not** pre-emptively reflect on the installed `.dll` "to be safe" before even attempting a build; that's wasted work when the sample already matches. Only fall back to inspecting the installed package (per the resolution order in [`coreex-conventions.instructions.md#when-unsure-of-a-coreex-api-member`](/.github/instructions/coreex-conventions.instructions.md#when-unsure-of-a-coreex-api-member)) if an actual compile error appears after copying the sample.
For full workflow and code examples see [`references/workflow.md`](references/workflow.md).
diff --git a/.github/skills/coreex-graphql/references/workflow.md b/.github/skills/coreex-graphql/references/workflow.md
index 271e05e2..100e1f6a 100644
--- a/.github/skills/coreex-graphql/references/workflow.md
+++ b/.github/skills/coreex-graphql/references/workflow.md
@@ -35,13 +35,8 @@ builder.Services.AddCoreExGraphQLLite((o, sp) =>
{
o.AddQuery("products", ProductQueryArgsConfig.Default,
async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false))
- .AddGet("product", (args, ct) =>
- {
- if (!args.TryGetValue("id", out var id) || id is not string { Length: > 0 } idValue)
- throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args));
-
- return CoreEx.ExecutionContext.GetRequiredService().GetAsync(idValue, ct);
- });
+ // GetIdentifier validates the named argument (default "id") for presence and type (it casts to TId, it does not convert) and throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR, if missing/empty/wrong-typed.
+ .AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct));
});
// ... after app.MapControllers():
@@ -66,13 +61,8 @@ One `AddQuery`/`AddGet` pair per entity, bridging to its **existing** `Que
```csharp
o.AddQuery<{Entity}Lite>("{entities}", {Entity}QueryArgsConfig.Default,
async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false))
- .AddGet<{Entity}>("{entity}", (args, ct) =>
- {
- if (!args.TryGetValue("id", out var id) || id is not string { Length: > 0 } idValue)
- throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args));
-
- return CoreEx.ExecutionContext.GetRequiredService().GetAsync(idValue, ct);
- });
+ // GetIdentifier validates the named argument (default "id") for presence and type (it casts to TId, it does not convert) and throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR, if missing/empty/wrong-typed.
+ .AddGet<{Entity}>("{entity}", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct));
```
**When triggered from `coreex-api`:** after that skill adds a REST GET/Query endpoint pair, and the host already has GraphQL enabled, offer to add the matching root here in the same session rather than requiring a separate invocation.
diff --git a/.github/skills/coreex-repository/SKILL.md b/.github/skills/coreex-repository/SKILL.md
index 9578a01d..25078d34 100644
--- a/.github/skills/coreex-repository/SKILL.md
+++ b/.github/skills/coreex-repository/SKILL.md
@@ -50,6 +50,8 @@ Guides you through creating or modifying a CoreEx Infrastructure-layer repositor
- `*WithResultAsync` variants for `Result` ROP pipelines (per-project style choice)
- `BiDirectionMapper`: override **both** `OnMap` overloads; map `Id` explicitly; **never** map `ETag` or `ChangeLog` — base mapper owns them
- `QueryArgsConfig`: create a dedicated `{Name}QueryArgsConfig : QueryArgsConfig<{Name}QueryArgsConfig>` class per entity in `Infrastructure/Repositories/`; access via `.Default`; call `.Parse(query).ThrowOnError()` before use — never instantiate per-request
+- `AddReferenceDataField(field, model, ...)`: `field` is always the contract's generated nav property (`{Name}`, never `{Name}Code`) — a fixed, documented source-generator convention, **not** something to verify by exploring generated code
+- `ToMappedItemsResultAsync(mapper, paging, cancellationToken: cancellationToken)`: always pass `cancellationToken` **by name** — `autoCount` (`bool`, defaults `true`) sits before it in the signature, and a bare positional token there fails to compile
- Always `.ConfigureAwait(false)` on every `await`
For full workflow and code examples see [`references/workflow.md`](references/workflow.md).
diff --git a/.github/skills/coreex-repository/references/workflow.md b/.github/skills/coreex-repository/references/workflow.md
index 5ac79816..edae7aaf 100644
--- a/.github/skills/coreex-repository/references/workflow.md
+++ b/.github/skills/coreex-repository/references/workflow.md
@@ -185,6 +185,8 @@ Collect the following from the developer. **AI cannot infer field selection, ope
| Model/LINQ name if different from the contract name | Needed when the persistence column name differs (e.g., `Category` → `CategoryCode`) |
| Model prefix (e.g., `"Product"`) if using a join projection | Required when LINQ expressions must qualify via `Product.Sku`, `Product.Text`, etc. |
+> **Reference data fields — don't investigate, apply the rule.** For a reference-data-backed field, `AddReferenceDataField(field, model, ...)`'s `field` argument is **always** the contract's generated navigation property name (`{Name}`, not `{Name}Code`) — a fixed Roslyn source-generator convention (`[ReferenceData] public partial string? {Name}Code { get; set; }` → generated `{Name}` nav property), documented in [`coreex-contracts.instructions.md#reference-data-properties`](/.github/instructions/coreex-contracts.instructions.md#reference-data-properties). This is never something to confirm by reading generated `.g.cs` files or exploring generator internals — it's deterministic. The `model` argument is the underlying persistence/EF column holding the code value; ask the developer only if the persistence model renames it from the `{Name}Code` default.
+
**Step 3 — For each order-by field (only if ordering = yes):**
| Question | Why it matters |
@@ -279,7 +281,7 @@ public async Task> QueryAsync(QueryArgs? query
{
Id = x.{Name}.Id,
// ... project fields
- }, paging, cancellationToken)
+ }, paging, cancellationToken: cancellationToken) // named — ToMappedItemsResultAsync's `autoCount` (bool) sits before `cancellationToken`; a bare positional token lands on autoCount and fails to compile (CS1503)
.ConfigureAwait(false);
}
```
diff --git a/Version.props b/Version.props
index 6ab5dcbc..9efef45b 100644
--- a/Version.props
+++ b/Version.props
@@ -1,5 +1,5 @@
- 4.0.0-preview-2
+ 4.0.0-preview-3
diff --git a/docs/capabilities.md b/docs/capabilities.md
index 7f503852..4a9aab22 100644
--- a/docs/capabilities.md
+++ b/docs/capabilities.md
@@ -684,14 +684,8 @@ Supports:
builder.Services.AddCoreExGraphQLLite((o, sp) =>
{
o.AddQuery("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false))
- .AddGet("product", (args, ct) =>
- {
- // Validate explicitly rather than an indexer + null-forgiving lookup, so a missing/empty 'id' throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR.
- if (!args.TryGetValue("id", out var id) || id is not string { Length: > 0 } idValue)
- throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args));
-
- return CoreEx.ExecutionContext.GetRequiredService().GetAsync(idValue, ct);
- });
+ // GetIdentifier validates the named argument (default "id") for presence and type (it casts to TId, it does not convert) and throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR, if missing/empty/wrong-typed.
+ .AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct));
});
app.MapCoreExGraphQLLite("/query");
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 13f6be78..ff3ce662 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -10,6 +10,35 @@ By the end you'll have a running, fully tested CoreEx microservice as a foundati
---
+## Contents
+
+**Setup**
+- [Prerequisites](#prerequisites)
+- [Walk-through](#walk-through)
+
+**Core walkthrough**
+- [1. Create and enter your solution folder](#1-create-and-enter-your-solution-folder)
+- [2. Install the template pack](#2-install-the-template-pack)
+- [3. Install AI workflow assets](#3-install-ai-workflow-assets)
+- [4. Scaffold the solution](#4-scaffold-the-solution)
+- [5. Start the infrastructure](#5-start-the-infrastructure)
+- [6. Open your IDE and AI tooling](#6-open-your-ide-and-ai-tooling)
+- [7–9. Bring your domain to life](#79-bring-your-domain-to-life)
+- [7. Implement your first entity and API host](#7-implement-your-first-entity-and-api-host)
+- [8. Add an Outbox Relay host](#8-add-an-outbox-relay-host)
+- [9. Add a Subscribe host](#9-add-a-subscribe-host)
+- [10. Build and test](#10-build-and-test)
+
+**Going further (optional)**
+- [11. Add an Employee query endpoint](#11-add-an-employee-query-endpoint)
+- [12. Add GraphQL query support](#12-add-graphql-query-support)
+
+**Reference**
+- [What's next](#whats-next)
+- [Alternative: AI-guided scaffold](#alternative-ai-guided-scaffold)
+
+---
+
## Prerequisites
| Requirement | Notes |
@@ -259,6 +288,58 @@ dotnet test tests/Avanade.Hr.People.Test.Unit # fast, no infrastructure requir
dotnet test # all tests — requires containers + database
```
+At this point you have a complete, fully tested CoreEx microservice — CRUD API, Outbox Relay, and Subscribe host, all green. Everything from here is optional: two further extensions that build on the Employee entity you already have.
+
+> **Note:** The [walk-through video](#walk-through) above covers steps 1–10 only; it predates steps 11 and 12 below.
+
+---
+
+## Going further (optional)
+
+## 11. Add an Employee query endpoint
+
+### AI-assisted (recommended)
+
+> Paste the following into GitHub Copilot (Agent mode) or Claude Code. It invokes the `coreex-api` skill to add a paged, filterable, sortable query endpoint for the existing Employee entity.
+
+```
+/coreex-api
+
+Create a query endpoint for the Employee.
+Support paging.
+Support the following filter properties:
+- LastName - either EQ or StartsWith (case insensitive)
+- Gender - standard
+Support the following ordering properties (use all three as the default in sequence specified):
+- LastName
+- FirstName
+- Id
+```
+
+This invokes the [`coreex-api`](https://github.com/Avanade/CoreEx/blob/main/.github/skills/coreex-api/SKILL.md) skill, which scaffolds the `{Entity}ReadService`/`{Entity}ReadController` CQRS read pair and the underlying `QueryArgsConfig` filter/order configuration on the repository — following the same interview-first pattern used in step 7.
+
+### Manual alternative
+
+Follow the query pattern in the [Pattern Catalog](https://github.com/Avanade/CoreEx/blob/main/samples/docs/patterns.md) by hand: add an `EmployeeQueryArgsConfig` under `Infrastructure/Repositories/`, a `QueryAsync` method on the repository and `IEmployeeReadService`, and a matching `QueryAsync` action on `EmployeeReadController`. See `ProductQueryArgsConfig`/`ProductRepository` in the [Contoso samples](https://github.com/Avanade/CoreEx/tree/main/samples) for a worked example.
+
+---
+
+## 12. Add GraphQL query support
+
+### AI-assisted (recommended)
+
+> Paste the following into GitHub Copilot (Agent mode) or Claude Code. The `coreex-graphql` skill is self-contained — no additional prompt detail is required.
+
+```
+/coreex-graphql
+```
+
+This invokes the [`coreex-graphql`](https://github.com/Avanade/CoreEx/blob/main/.github/skills/coreex-graphql/SKILL.md) skill, which wires up `AddCoreExGraphQLLite`/`MapCoreExGraphQLLite` on the API host (first-time only) and registers a GraphQL root for the Employee query endpoint added in step 11 — bridging its existing `QueryArgsConfig`, with no new filter/sort logic written.
+
+### Manual alternative
+
+Follow [`CoreEx.Data.GraphQL`'s AGENTS.md](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Data.GraphQL/AGENTS.md) to wire `AddCoreExGraphQLLite`/`MapCoreExGraphQLLite` by hand and register an `AddQuery` (or `AddQuery` if there's no separate "Lite" projection) root.
+
---
## What's next
diff --git a/samples/docs/hosts-layer.md b/samples/docs/hosts-layer.md
index ed1d1795..925fb20c 100644
--- a/samples/docs/hosts-layer.md
+++ b/samples/docs/hosts-layer.md
@@ -73,14 +73,8 @@ paging, and field-selection behavior because they drive the exact same underlyin
builder.Services.AddCoreExGraphQLLite((o, sp) =>
{
o.AddQuery("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false))
- .AddGet("product", (args, ct) =>
- {
- // Validate explicitly rather than an indexer + null-forgiving lookup, so a missing/empty 'id' throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR.
- if (!args.TryGetValue("id", out var id) || id is not string { Length: > 0 } idValue)
- throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args));
-
- return CoreEx.ExecutionContext.GetRequiredService().GetAsync(idValue, ct);
- });
+ // GetIdentifier validates the named argument (default "id") for presence and type (it casts to TId, it does not convert) and throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR, if missing/empty/wrong-typed.
+ .AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct));
});
// ...
diff --git a/src/CoreEx.Data.GraphQL/AGENTS.md b/src/CoreEx.Data.GraphQL/AGENTS.md
index 8f4c42bf..1428c77f 100644
--- a/src/CoreEx.Data.GraphQL/AGENTS.md
+++ b/src/CoreEx.Data.GraphQL/AGENTS.md
@@ -11,14 +11,8 @@ Register roots explicitly — no attribute-based auto-discovery. Each `AddQuery`
builder.Services.AddCoreExGraphQLLite((o, sp) =>
{
o.AddQuery("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false))
- .AddGet("product", (args, ct) =>
- {
- // Validate explicitly rather than an indexer + null-forgiving lookup, so a missing/empty 'id' throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR.
- if (!args.TryGetValue("id", out var id) || id is not string { Length: > 0 } idValue)
- throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args));
-
- return CoreEx.ExecutionContext.GetRequiredService().GetAsync(idValue, ct);
- });
+ // GetIdentifier validates the named argument (default "id") for presence and type (it casts to TId, it does not convert) and throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR, if missing/empty/wrong-typed.
+ .AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct));
});
// ...
diff --git a/src/CoreEx.Data.GraphQL/README.md b/src/CoreEx.Data.GraphQL/README.md
index 5034f78f..9016cf19 100644
--- a/src/CoreEx.Data.GraphQL/README.md
+++ b/src/CoreEx.Data.GraphQL/README.md
@@ -87,15 +87,9 @@ The engine is deliberately **transport-agnostic**: it references only `CoreEx.Da
builder.Services.AddCoreExGraphQLLite((o, sp) =>
{
o.AddQuery("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false))
- .AddGet("product", (args, ct) =>
- {
- // Validate explicitly rather than an indexer + null-forgiving lookup, so a missing/empty 'id' throws an ArgumentException - which the engine maps to an
- // ARGUMENT_ERROR GraphQL error - instead of an unhandled KeyNotFoundException/NullReferenceException surfacing as an opaque EXECUTION_ERROR.
- if (!args.TryGetValue("id", out var id) || id is not string { Length: > 0 } idValue)
- throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args));
-
- return CoreEx.ExecutionContext.GetRequiredService().GetAsync(idValue, ct);
- });
+ // GetIdentifier validates the named argument (default "id") for presence and type (it casts to TId, it does not convert) and throws an ArgumentException - mapped by the engine to an ARGUMENT_ERROR GraphQL error - if
+ // it is missing, empty, or the wrong type, instead of an unhandled KeyNotFoundException/NullReferenceException surfacing as an opaque EXECUTION_ERROR.
+ .AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct));
});
// ...
diff --git a/src/CoreEx.Data/AGENTS.md b/src/CoreEx.Data/AGENTS.md
index e19058f8..84d47599 100644
--- a/src/CoreEx.Data/AGENTS.md
+++ b/src/CoreEx.Data/AGENTS.md
@@ -41,10 +41,12 @@ private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create()
.WithDefault($"{nameof(Order.CreatedOn)} desc"));
// In the repository
-public async Task> GetAllAsync(QueryArgs args, CancellationToken ct = default)
- => await _efDb.Model()
- .Query(new EfDbArgs(args, _queryConfig))
- .ToItemsResultAsync(MapToEntity, ct).ConfigureAwait(false);
+public async Task> GetAllAsync(QueryArgs args, PagingArgs? paging, CancellationToken cancellationToken = default)
+ => await _efDb.Model().Query()
+ .Where(_queryConfig, args)
+ .OrderBy(_queryConfig, args)
+ .ToItemsResultAsync(paging, cancellationToken: cancellationToken) // named — autoCount (bool) sits before cancellationToken
+ .ConfigureAwait(false);
```
## DataResult
diff --git a/src/CoreEx.EntityFrameworkCore/AGENTS.md b/src/CoreEx.EntityFrameworkCore/AGENTS.md
index b067c8ca..79665819 100644
--- a/src/CoreEx.EntityFrameworkCore/AGENTS.md
+++ b/src/CoreEx.EntityFrameworkCore/AGENTS.md
@@ -13,7 +13,7 @@ builder.Services
## EfDb — Entry Point
-Inject `EfDb` (or your `IEfDb`) into repositories. Access typed CRUD via `Model()`.
+Inject `EfDb` (or your `IEfDb`) into repositories. Access typed CRUD via `Model()` — this is for the case where the domain/contract type **is** the EF persistence model type (no separate mapper needed); `GetAsync`/`CreateAsync`/`UpdateAsync`/`DeleteAsync` take no mapper parameter:
```csharp
[ScopedService]
@@ -21,38 +21,53 @@ public class ProductRepository(EfDb efDb) : IProductRepository
{
private readonly EfDbModel _model = efDb.Model();
- public Task GetAsync(Guid id, CancellationToken ct = default) =>
- _model.GetAsync(new EfDbArgs(OperationType.Get), id, ProductMapper.Default.MapToEntity, ct);
+ public Task GetAsync(Guid id, CancellationToken ct = default) => _model.GetAsync(id, ct);
- public Task CreateAsync(Product product, CancellationToken ct = default) =>
- _model.CreateAsync(new EfDbArgs(OperationType.Create), product, ProductMapper.Default, ct);
+ public Task> CreateAsync(ProductModel product, CancellationToken ct = default) => _model.CreateAsync(product, ct);
}
```
## Mapped Model (Separate EF Model Type)
-Use `EfDbMappedModel` when the domain entity type differs from the EF persistence model type.
+Use `EfDbMappedModel` when the domain/contract type (`TValue`) differs from the EF persistence model type (`TModel`). Construct it once — typically as a property on your `EfDb` subclass — via `Model().ToMappedModel(mapper)`. `TMapper` must be an `IBiDirectionMapper` (see [`CoreEx.Mapping`](../CoreEx/Mapping/README.md)); once mapped, `GetAsync`/`CreateAsync`/`UpdateAsync`/`DeleteAsync` still take **no mapper parameter** — the mapper is already bound via the generic type:
```csharp
-private readonly EfDbMappedModel _model =
- efDb.Model();
+public sealed class MyEfDb(MyDbContext dbContext) : EfDb(dbContext, _options)
+{
+ private static readonly EfDbOptions _options = new EfDbOptions().WithModel(m => m.WithLogicalDeleteFilter());
+
+ public EfDbMappedModel Products => Model().ToMappedModel(ProductMapper.Default);
+}
+
+public class ProductRepository(MyEfDb ef) : IProductRepository
+{
+ public Task GetAsync(Guid id, CancellationToken ct = default) => ef.Products.GetAsync(id, ct);
+
+ public Task> CreateAsync(Product product, CancellationToken ct = default) => ef.Products.CreateAsync(product, ct);
+}
```
## Dynamic Query with Paging
-Use `Query(args)` for paged, filtered list endpoints. Combine with `EfDbExtensions.ToItemsResultAsync`.
+Get the underlying `IQueryable` via `Model.Query()`, apply the parsed `QueryArgsConfig` filter/order (see [`CoreEx.Data`](../CoreEx.Data/AGENTS.md)), then materialize with `ToMappedItemsResultAsync`.
+
+> **Always pass `cancellationToken` as a named argument.** `ToMappedItemsResultAsync`'s signature is `(mapper, paging = null, autoCount = true, cancellationToken = default)` — `autoCount` (`bool`) sits *before* `cancellationToken`. A bare positional `CancellationToken` argument in the third slot lands on `autoCount` and fails to compile (`CS1503`). Use `cancellationToken: cancellationToken` explicitly, every time.
```csharp
-private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create()
- .WithFilter(f => f.AddField(nameof(ProductModel.Status)))
- .WithOrderBy(o => o.AddField(nameof(ProductModel.Name)).WithDefault("Name"));
-
-public Task> GetAllAsync(QueryArgs? args, PagingArgs? paging,
- CancellationToken ct = default)
- => _model.Query(new EfDbArgs(args ?? new QueryArgs(), _queryConfig, paging))
- .ToMappedItemsResultAsync(ProductMapper.Default.MapToEntity, ct);
+public async Task> QueryAsync(QueryArgs? query, PagingArgs? paging, CancellationToken cancellationToken = default)
+{
+ var parsed = ProductQueryArgsConfig.Default.Parse(query).ThrowOnError();
+
+ return await ef.Products.Model.Query()
+ .Where(parsed)
+ .OrderBy(parsed)
+ .ToMappedItemsResultAsync(m => ProductMapper.From.Map(m), paging, cancellationToken: cancellationToken)
+ .ConfigureAwait(false);
+}
```
+> If the read side projects to a reduced "Lite" contract instead of the full entity, `ProductMapper.From.Map(m)` (an `IBiDirectionMapper`) still returns `Product`, not `ProductLite` — a mapper only knows its own two types. Project inline instead: `m => new ProductLite { Id = m.Id, Sku = m.Sku, ... }`.
+
## ValueConverter Bridge
Use `ValueConverterBridge` in `OnModelCreating` to reuse CoreEx `IConverter` instances as EF value converters.