Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f783bb9
Add fluent WithStatusResult/WithErrorStatus to HttpRequestBuilder, MC…
chullybun Oct 13, 2026
d7f8001
Add QueryArgs free-text search support
chullybun Oct 13, 2026
aa0ba10
Document Products vector and hybrid search design
chullybun Oct 13, 2026
e3312bf
Add local Products embedding foundation and relevance diagnostics
chullybun Oct 13, 2026
88e7c57
Add product descriptions and price filtering
chullybun Oct 13, 2026
78a1826
Add exact pgvector product search and Aspire Ollama integration
chullybun Oct 14, 2026
036e198
Add eventual product search indexing and composable EF SQL bridge
chullybun Oct 14, 2026
66b711c
Update dependencies and streamline local builds and publishing
chullybun Oct 15, 2026
90a2f21
Add repeatable product search relevance evaluation
chullybun Oct 15, 2026
fe1830b
Add lexical product search and separate query repository
chullybun Oct 15, 2026
0b6fd9d
Add hybrid search and shared mountain bike demo catalog
chullybun Oct 15, 2026
756c749
Improve interactive search and catalog setup experience
chullybun Oct 16, 2026
9f0c46d
Add MCP product search filters
chullybun Oct 19, 2026
e048be4
Finalize v4.2.0 HTTP builders and release guidance
chullybun Oct 20, 2026
4b90d42
Fix MessagePack audit failure during CI restore
chullybun Oct 20, 2026
13cab23
Fix hybrid product search on .NET 8
chullybun Oct 20, 2026
e9e32f2
Preserve GraphQL search validation details
chullybun Oct 20, 2026
0d7aa5a
Document reference-data code conversion
chullybun Oct 21, 2026
b3a1293
Pin stable .NET SDK and fix package publishing
chullybun Oct 21, 2026
7a5eb50
Add predicate conditions to DependsOn validation
chullybun Oct 21, 2026
f38f89b
Stabilize outbox relay tests
chullybun Oct 21, 2026
9a598df
Improve validation metadata and preserve mutation outcomes
chullybun Oct 22, 2026
5149326
Preserve dictionary keys and correct JSON Merge Patch semantics
chullybun Oct 23, 2026
7db1073
Align sample and template health checks with preserved dictionary keys
chullybun Oct 23, 2026
249df99
Add Optional request values and refine validation plumbing
chullybun Oct 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .claude/commands/coreex-scaffold.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: "Guide a developer through CoreEx solution shaping after bootstrap, using a short plain-English interview that turns user answers into safe dotnet new template inputs. USE FOR: bootstrap-only repos, deciding API-only vs API plus relay vs API plus subscriber, choosing SQL Server vs Postgres vs Cosmos vs no database, choosing refdata/outbox/DDD/ROP options, installing CoreEx.Template, checking current solution shape, adding missing Api/Relay/Subscribe hosts to an existing repo, adding a .NET Aspire AppHost for local multi-host orchestration and dashboard visibility, and optionally preparing a first local runnable state with local dependency assets plus database/code-generation steps. DO NOT USE FOR: unrelated runtime debugging, bootstrap creation, or forcing root re-scaffolding over an existing solution. INVOKES: workspace inspection, ask-questions style interviews, dotnet new install/list, dry-run validation, solution wiring, optional local dependency asset creation, focused build/test validation, and either template generation or manual retrofit work depending on repo shape."
description: "Guide CoreEx solution scaffolding and retrofit decisions after bootstrap. USE FOR: choosing coreex templates and options (API, Relay, Subscribe, database, messaging, refdata, DDD, ROP), checking the current solution shape, adding missing hosts or an Aspire AppHost, or preparing a first local runnable state. DO NOT USE FOR: unrelated runtime debugging, creating the initial AI bootstrap (use coreex-bootstrap), or re-scaffolding an existing solution. INVOKES: workspace inspection, a one-question interview, pinned template installation, dry-run validation, template generation or retrofit, solution wiring, and focused build/test validation."
allowed-tools: [Read, Glob, Grep, Edit, Write, Bash]
---

Expand Down
2 changes: 1 addition & 1 deletion .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-reportgenerator-globaltool": {
"version": "5.5.10",
"version": "5.5.11",
"commands": [
"reportgenerator"
]
Expand Down
7 changes: 7 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ solution; the rule applies to consumer solutions where these assets are installe
- **Build**: `dotnet build CoreEx.sln`
- **Test**: `dotnet test CoreEx.sln` or target specific projects.
- **Single test**: `dotnet test <proj> --filter "FullyQualifiedName~<name>"`
- **Local target framework (inner loop)**: projects multi-target `net8.0;net9.0;net10.0`. Locally, develop, build, and test on `net10.0` Debug only — set the user environment variable `CoreExLocalTargetFramework=net10.0` (honoured by the root `Directory.Build.props` policy imported by `src`, `samples`, and `tests`; ignored when `CI=true` and by `dotnet pack`). The policy does not force Debug or change single-target projects. Keep framework defaults in props, not late targets or duplicate project declarations, so SDK initialization and Visual Studio framework discovery agree. Restart Visual Studio after changing the environment variable. Do **not** use `dotnet build -f net10.0` on the solution: it fails with `NETSDK1005` on non-net10 projects such as `CoreEx.Generator` (netstandard2.0). Avoid local Release builds unless specifically required. CI builds and tests all target frameworks in Release; run a full multi-target build locally (`-p:CoreExLocalTargetFramework=`) only when changing `#if NET…` code, framework-conditional package versions in `Directory.Packages.props`, or framework-sensitive APIs. For direct `msbuild -t:Pack` or Visual Studio Pack, explicitly clear `CoreExLocalTargetFramework`; unlike `dotnet pack`, those entry points do not supply the CLI's `_IsPacking` global property. `nuget-publish.ps1` already clears the switch.
- **Reclaiming disk**: run [`tools/clean-build-outputs.ps1`](../tools/clean-build-outputs.ps1) (`-WhatIf` to preview) to remove all `bin`/`obj` `Debug`/`Release` outputs.
- **Samples**: docker-compose infrastructure + dotnet run for Database projects + Aspire AppHost.
- **`CoreEx.Template` changes**: `dotnet build`/`dotnet test` do **not** exercise `src/CoreEx.Template/content/**` — that's raw `dotnet new` template content, not compiled C#. Any change under `src/CoreEx.Template/content/` (a host's `Program.cs`/`GlobalUsing.cs`/`.csproj`, a `template.json` symbol, etc.) must be validated by actually scaffolding it: run [`tools/validate-template-pack.ps1`](../tools/validate-template-pack.ps1), which packs the template, installs it, scaffolds every parameter combination it knows about into temp directories, and `dotnet build`s the ones flagged `Build = $true`. It also runs in CI (`.github/workflows/CI.yml`). If you add a new template, host, or parameter combination, add a matching scenario to the script's `$testScenarios` array — parameter-conditional bugs (an unconditional `global using`/`ProjectReference` that should have been gated behind a symbol like `has-data-provider` or `implement-servicebus`) only surface when the generated code is actually compiled, which most existing host-template scenarios don't yet do since they scaffold in isolation without their `coreex` solution siblings.
- **Linting**: No separate `dotnet format`. Build is the lint pass (nullable, LangVersion=preview, TreatWarningsAsErrors in `src\Directory.Build.props`).
Expand Down Expand Up @@ -86,6 +88,7 @@ Connection strings for each service in development are in each host's `appsettin
- Mutable contracts often implement `IIdentifier<T>`, `IETag`, and `IChangeLog`.
- Use `[NonNullable]` for properties that are mandatory/required but declared nullable (`string?`, `int?`) for DTO flexibility, so OpenAPI reports them as non-nullable.
- Use `[ReadOnly(true)]` for server-managed fields and `[ReferenceData<T>]` for reference-data-backed code properties.
- Use `Optional<T>` only on explicit patch/input DTOs when omission must differ from supplied `null` or default values; validate with `.Unwrap()` and map only specified values into ordinary application/domain objects. Do not leak wrappers into response/event or persistence models.
- Canonical casing transformations belong in property setters when already established by the model (for example `Sku` uppercasing in `ProductBase`).
- Favor the existing source-generation approach; do not hand-write members that are meant to be generated.

Expand All @@ -112,6 +115,8 @@ Connection strings for each service in development are in each host's `appsettin
### Adapters (Anti-Corruption Layer)
- When a domain needs to interact with another domain or external service, define an **adapter interface** in `Application\Adapters\`. The Application layer depends on this domain-idiomatic abstraction — never on the remote API's schema or transport directly.
- Infrastructure implements the adapter using a **typed HTTP client** (`Infrastructure\Clients\`) for the transport concern, keeping client and orchestration in separate focused classes.
- Register typed HTTP clients in `Program.cs` via `builder.AddTypedHttpClient<TClient>("ServiceName")` (or `builder.AddNamedHttpClient("ServiceName")`), which automatically wires base address configuration, `IdempotencyKeyHandler`, service discovery, and `Microsoft.Extensions.Http.Resilience` standard resilience handlers.
- Inside typed clients, use `client.Send(method, uri)` or `Get`/`Post`/`Put`/`Patch`/`Delete`, then `WithJson(value)` for JSON or `WithContent(content)` for explicit content. `HttpRequestBuilder` is single-use; exactly one body assignment is allowed. Fluent configuration (`WithQuery`, `WithPaging`, headers, `WithStatusResult`, `WithValidator`) precedes terminal execution with automatic response disposal (except raw-response terminals).
- Two adapter roles appear in Shopping:
- **Synchronous adapter** (`IProductAdapter`) — real-time calls (e.g. inventory reservation at checkout); the HTTP client is called live inside the unit of work.
- **Sync/replication adapter** (`IProductSyncAdapter`) — event-driven data replication; receives published domain events and maintains a local eventually-consistent copy in the domain's own store.
Expand Down Expand Up @@ -157,6 +162,8 @@ Connection strings for each service in development are in each host's `appsettin

### House Rules
- Code comments end with a period/full stop.
- Use `///` XML documentation for types and members, including private nested types, methods, and properties; use `<inheritdoc/>` for implementations and overrides. Private fields and test fixtures/helpers retain their existing exemptions.
- Do not hard-wrap prose in READMEs, AGENTS files, skills, instructions, or other Markdown documents for visual formatting. Keep each paragraph on one physical line; use editor soft wrapping, LF line endings, and line breaks only for Markdown structure or code blocks.
- Always use `.ConfigureAwait(false)` in service/repository code.
- `<Nullable>enable</Nullable>` and `<ImplicitUsings>enable</ImplicitUsings>` are set in `Directory.Build.props` — treat nullable warnings as errors, never suppress them with `!` without justification.
- Every project has a single `GlobalUsing.cs` at the project root. All `using` statements go there — never in individual source files. The code generator (`*.CodeGen`) emits no `using` statements and depends on this.
Expand Down
2 changes: 2 additions & 0 deletions .github/instructions/coreex-api-controllers.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ public Task<IActionResult> GetAsync(string id, CancellationToken cancellationTok

Do not use `.ThrowIfNull()` / `.ThrowIfNullOrEmpty()` on route parameters — those throw `ArgumentNullException`, which results in a 500 rather than a 400.

Classify lookup failures by the identifier's role, not URI presence: a missing target resource or explicitly addressed child is 404; a missing request-body reference is a field-level 400 validation error. Unmatched collection filters normally return empty results, not 404. Application services/policies interpret expected lookup outcomes; controllers preserve that classification through `WebApi`. Retain deliberate idempotent-delete and security-concealment semantics. See [Not Found Handling](/.github/instructions/coreex-application-services.instructions.md#not-found-handling).

### POST — Create with Location Header

Use `ro.WithLocationUri(...)` to set the `Location` response header:
Expand Down
57 changes: 52 additions & 5 deletions .github/instructions/coreex-application-services.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,37 @@ if (result.IsFailure) return result.AsResult();

## Not Found Handling

After loading an entity, throw immediately if it does not exist:
Choose the error according to the identifier's role in the operation, not merely because a repository or downstream API returned "not found":

- **Missing target resource:** use `NotFoundException` / `Result.NotFoundError` (HTTP 404) for the primary resource or an explicitly addressed child, such as `/bookings/{id}` or `/bookings/{id}/travellers/{travellerId}`.
- **Invalid reference:** use a field-level `ValidationException` / `Result.ValidationError` (HTTP 400 under CoreEx conventions) when a request-body property refers to a nonexistent entity. An unknown `travellerId` in `POST /bookings/{id}/travellers` is an invalid reference; a missing target booking is a 404.
- **Query inputs:** URI presence alone does not imply a target resource. An unmatched collection filter normally returns an empty result; invalid query inputs follow the endpoint's validation contract.
- **Downstream failures:** translate only an expected entity-not-found outcome for an invalid reference; preserve other failures unchanged. A downstream HTTP 404 can also indicate an incorrect route or deployment/configuration problem. Do not automatically turn every downstream 404 into caller validation.
- **State/process restrictions:** use business errors where appropriate, with specific conflict/concurrency or authorization semantics taking precedence. Do not use business errors or choose HTTP 404 merely to retain a machine-readable error code.
- **Endpoint semantics:** preserve deliberate behavior, including idempotent deletion and security-driven concealment of resource existence.

For a missing target resource:

```csharp
var current = await _repository.GetAsync(id, cancellationToken).ConfigureAwait(false);
NotFoundException.ThrowIfDefault(current);
```

For an expected missing reference returned as `Result<Traveller>`, translate only that failure:

```csharp
var traveller = await _travellerAdapter.GetAsync(request.TravellerId, cancellationToken).ConfigureAwait(false);
return traveller.IsNotFoundError
? Result<Traveller>.ValidationError<BookingTravellerAddRequest>(x => x.TravellerId, "Traveller was not found.")
: traveller;
```

The expression type is the request contract containing the invalid reference; the result value type is the entity returned on success. For a nullable lookup, check null and use the same field-error factory instead. Use `ValidationException.Create<Request>(...)` in exception-style services, `MessageItem.CreateErrorMessage<Request>(...)` for aggregation, and `context.AddError(...)` inside validators to preserve contextual paths. Reusable policies without a known request contract may accept an already-resolved JSON path; do not introduce a DTO dependency solely for an expression. `nameof(param)` alone does not resolve JSON names.

## Business Rule Exceptions

Request/input validity (including invalid references) uses validation errors with request-field context where known. Aggregate/entity/value-object business invariants use business errors with domain messages/codes, not request paths; Domain must not reference request DTOs or infer their JSON paths. Both normally map to 400, but are distinct contracts. Keep domain enforcement after request validation. Application may selectively translate a known domain error to a field error only when the mapping is unambiguous; never blanket-convert business failures. See [domain invariant conventions](/.github/instructions/coreex-domain.instructions.md#invariant-failures-versus-request-validation).

Use `BusinessException` for domain rule violations that are the caller's fault but are not validation errors:

```csharp
Expand Down Expand Up @@ -275,6 +297,7 @@ return await _unitOfWork.TransactionAsync(async ct =>
```

- `WhereMutated(action)` — executes `action` only when the data result records a mutation; add the event inside this callback. **Mind the overload:** `DataResult<T>` (from `Create`/`Update`) carries the value, so use `WhereMutated(v => ...)`; `DataResult` (from `Delete`) has **no value**, so use the parameterless `WhereMutated(() => ...)` — not `WhereMutated(_ => ...)`.
- **Success is not evidence of mutation.** Preserve `DataResult` through repository/ROP/domain mapping until mutation-dependent decisions are complete. A successful `WasMutated == false` returns success without a mutation-announcement event; use `WhereMutated` inside the same transaction as the write, then unwrap for the public response. An earlier successful read or aggregate `HasChanges`/`PersistenceState` check does not replace the persistence outcome. The flag is the provider's mutation signal, potentially before deferred writes/commit; it is not an exactly-once or committed-row guarantee. Commands and deliberate attempt notifications have separate semantics, so do not mechanically gate them as mutation events.
- `EventData.CreateEventWith(value, action)` — a typed event **carrying the entity value** (Create/Update only — pass the **real** mutated value `v`). For a **no-value** event (Delete), use `EventData.CreateEvent<T>(action).WithKey(id)` — the type + action + key, **no value**. **Never fabricate a value to feed `CreateEventWith` on a delete** (e.g. `CreateEventWith(new Employee { Id = id }, …)` or `CreateEventWith<T>(default, …)`): a delete event must have **no body** — a synthetic entity wrongly serialises a near-empty value, adds a version suffix that delete events must not have, and buries the id in the body instead of the metadata key. The id belongs in `.WithKey(id)`.
- `EventAction.Created`, `EventAction.Updated`, `EventAction.Deleted` — use the standard constants.

Expand Down Expand Up @@ -312,16 +335,36 @@ public Task<Result<Basket>> CreateAsync(string customerId, CancellationToken can
return _unitOfWork.TransactionAsync(async ct =>
{
var br = await _repository.CreateAsync(aggregate, ct).ConfigureAwait(false);
return br.ThenAs(b =>
return br.ThenAs(dr =>
{
var contract = BasketMapper.Map(b);
_unitOfWork.Events.Add(EventData.CreateEventWith(contract, EventAction.Created));
var contract = BasketMapper.Map(dr.Value);
dr.WhereMutated(_ => _unitOfWork.Events.Add(EventData.CreateEventWith(contract, EventAction.Created)));
return contract;
});
}, cancellationToken);
}
```

The repository above returns `Result<DataResult<Domain.Basket>>` from Create/Update, preserving its provider's flag when mapping persistence models: `dr.Map(value => BasketMapper.Map(value))`. `Map` transforms even a no-op's value without changing `WasMutated`; the lambda retains the CoreEx mapper's conditional non-null guarantee. Reads remain `Result<Domain.Basket>`; delete returns `Result<DataResult>`. For a repository with an idempotent delete:

```csharp
public Task<Result> DeleteAsync(string id, CancellationToken cancellationToken = default)
{
id.ThrowIfNullOrEmpty();
return _unitOfWork.TransactionAsync(async ct =>
{
var result = await _repository.DeleteAsync(id, ct).ConfigureAwait(false);
return result.ThenAs(dr =>
{
dr.WhereMutated(() => _unitOfWork.Events.Add(EventData.CreateEvent<Basket>(EventAction.Deleted).WithKey(id)));
return Result.Success;
});
}, cancellationToken);
}
```

If deletion needs a prior aggregate read to enforce business rules, keep it, but still gate the event on the successful delete result. Test the successful no-op outcome even after that read found the target. A sequential repeat-delete test does not cover the read/delete gap; depending on tracking/provider behavior, a concurrent deletion can instead produce a concurrency failure, which must propagate unchanged.

For multi-step orchestration with early exit on the first failure:

```csharp
Expand Down Expand Up @@ -453,16 +496,20 @@ public class ProductPolicy(IProductAdapter productAdapter)
public Task<Result<Product>> EnsureExistsAsync(string productId, CancellationToken cancellationToken = default) => Result
.GoAsync(() => _productAdapter.GetAsync(productId, cancellationToken))
.OnFailure(r => r.IsNotFoundError
? Result.ValidationError(MessageItem.CreateErrorMessage(nameof(productId), "Product was not found."))
? Result.ValidationError(MessageItem.CreateErrorMessage("productId", "Product was not found."))
: r);
}

// In the calling service — _productAdapter is already injected into the service:
var result = await new ProductPolicy(_productAdapter).EnsureExistsAsync(productId, cancellationToken);
```

This policy checks a request-body reference whose agreed JSON path is `"productId"`; it does not define missing-target behavior. For a policy reused across fields, accept a resolved path from the caller. Apply [Not Found Handling](#not-found-handling) before choosing the failure type.

## Application-Level Mapping

Pass code strings directly to concrete typed reference-data parameters/properties; map DTO destinations through their annotated `XxxCode` properties. Do not add conversion-only `RefData<T>` helpers or redundant null checks. See [Reference-Data Code Conversion](/.github/instructions/coreex-contracts.instructions.md#reference-data-code-conversion); conversion is not validation.

For collection projections, follow [Mapper Projection Nullability](/.github/instructions/coreex-conventions.instructions.md#mapper-projection-nullability); preserve the lambda selectors in the examples below.

When a domain has a Domain layer, an `Application/Mapping/` sub-folder holds mappers that translate between the **Domain aggregate** and the **Contract**. This mapping is an Application-layer concern because it sits at the public surface boundary — it is not tied to any persistence technology.
Expand Down
Loading
Loading