Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>` in the spike project's own `.csproj` to opt out locally.

## Local Development Infrastructure

Expand Down
24 changes: 24 additions & 0 deletions .github/instructions/coreex-conventions.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ public async Task<Employee> 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<T> 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

Expand Down Expand Up @@ -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*/<version>/`) 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<TSource, TDestination, TSelf>` | 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<TSource, TDestination, TSelf>` (uni-directional) | One `OnMap(TSource)` | `{Name}Mapper.Map(source)` — static, directly on the mapper class, **not** `.Default.Map(...)` |
| `Validator<T, TSelf>` | Declarative rules / `OnValidateAsync` | `{Name}Validator.Default.ValidateAndThrowAsync(...)` / `.ValidateWithResultAsync(...)` — **never** bare `.ValidateAsync(...)` |
| `QueryArgsConfig<TSelf>` | 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.
Expand All @@ -218,7 +240,9 @@ private readonly ILogger<ProductService> _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 `<summary>`.
- Do not invert the doc convention — summaries go on **interfaces and contract properties**; the **implementing** class member gets `<inheritdoc/>` (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 `<summary>` (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.
2 changes: 1 addition & 1 deletion .github/instructions/coreex-host-setup.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProductLite>("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService<IProductReadService>().QueryAsync(qa, pa, ct).ConfigureAwait(false)).AddGet<Product>("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService<IProductReadService>().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<T>()` — 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<GraphQLLiteResponse>(...)`, 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_<name>` where `<name>` 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<ProductLite>("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService<IProductReadService>().QueryAsync(qa, pa, ct).ConfigureAwait(false)).AddGet<Product>("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService<IProductReadService>().GetAsync(args.GetIdentifier<string>(), ct)))` — `GetIdentifier<TId>` 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<T>()` — 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<GraphQLLiteResponse>(...)`, 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_<name>` where `<name>` 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).

---

Expand Down
7 changes: 6 additions & 1 deletion .github/instructions/coreex-repositories.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ The model accessors (`EfDbModel<T>` and `EfDbMappedModel<...>`) expose two varia

Querying: `Query(...)` returns a filtered `IQueryable<TModel>` (logical-delete and tenant filters already applied); `QueryTracked(...)` is the change-tracked variant. Materialize via the extensions `ToMappedItemsResultAsync<TSource, TItem>()` (→ `ItemsResult<TItem>` with paging/count), `ToMappedItemsAsync<...>()`, or `ToItemsResultAsync<TItem>()`.

> **`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<T>(...)`, `WithLogicalDeleteFilter()`, `WithTenantFilter()`, `WithFilter(...)`, `WithGetKey(...)`, `WithArgs(...)`, `WithOnBeforeCreateOrUpdate(...)`, `WithUpdateModelMapper(...)`.

## Dynamic Query Configuration
Expand Down Expand Up @@ -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<TRef>(field, ...)` | Any `IReferenceData` type (resolved by code via orchestrator) | `EqualityOperators` (`eq`/`ne`/`in`) | `.MustBeActive(...)` |

> **Reference data fields — don't investigate, apply the rule.** `AddReferenceDataField<TRef>(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<T>] 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 |
Expand Down Expand Up @@ -245,7 +249,7 @@ public async Task<ItemsResult<Contracts.ProductLite>> 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);
}
```
Expand Down Expand Up @@ -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<TFrom, TTo, TSelf>` 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.
Expand Down
1 change: 1 addition & 0 deletions .github/skills/coreex-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<IActionResult>` via the `WebApi` helper — never `ActionResult<T>` directly
Expand Down
3 changes: 3 additions & 0 deletions .github/skills/coreex-api/references/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
Loading
Loading