diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json new file mode 100644 index 00000000..21beb81f --- /dev/null +++ b/.github/plugin/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "coreex-marketplace", + "owner": { + "name": "Avanade", + "email": "opensource@avanade.com" + }, + "metadata": { + "description": "CoreEx plugins for GitHub Copilot CLI and GitHub Copilot app.", + "version": "1.1.0" + }, + "plugins": [ + { + "name": "coreex-agent-pack", + "description": "CoreEx custom agent and reusable skills.", + "version": "1.1.0", + "source": "plugins/coreex-agent-pack" + } + ] +} diff --git a/plugins/coreex-agent-pack/README.md b/plugins/coreex-agent-pack/README.md new file mode 100644 index 00000000..6cc43e42 --- /dev/null +++ b/plugins/coreex-agent-pack/README.md @@ -0,0 +1,39 @@ +# CoreEx Agent Pack + +This plugin packages CoreEx Copilot assets (agents and skills), including onboarding templates used by `coreex-onboard`. + +## Published vs internal skills + +This plugin intentionally publishes only external-facing skills: + +- `coreex-onboard` +- `solution-scaffolder` + +The following skills are internal-only and are not published in this plugin package: + +- `acquire-codebase-knowledge` +- `aspire` +- `coreex-docs-sync` + +## Source of truth + +The canonical CoreEx instruction files remain in the repository root under: + +- `.github/copilot-instructions.md` +- `.github/instructions/*.instructions.md` +- `.github/agents/coreex-expert.agent.md` + +The copies under `skills/coreex-onboard/assets/templates/.github/` are distribution templates for plugin consumers. + +## Long-term maintenance approach + +The long-term solution is to **automate template syncing in CI** so maintainers do not edit both locations manually. + +Recommended direction: + +1. Keep editing only canonical files under root `.github/`. +2. Add a sync script that copies canonical files into plugin template paths. +3. Run the sync script in CI (and optionally as a pre-release check). +4. Fail CI when plugin templates drift from canonical sources. + +Until CI sync is in place, updates must be mirrored manually. diff --git a/plugins/coreex-agent-pack/agents/coreex-expert.agent.md b/plugins/coreex-agent-pack/agents/coreex-expert.agent.md new file mode 100644 index 00000000..070db247 --- /dev/null +++ b/plugins/coreex-agent-pack/agents/coreex-expert.agent.md @@ -0,0 +1,120 @@ +--- +name: CoreEx Expert +description: "Use when you need to explain, understand, or decide how CoreEx works in your project. Triggers: explain CoreEx, how does CoreEx, which pattern, which capability, which shape, plan a feature, review a design, compare samples, architecture guidance, coding patterns, layering, host setup, validation, repository conventions, eventing, outbox relay, subscriber design, sample-aligned decisions." +tools: [read/readFile, read/problems, search/codebase, search/fileSearch, search/textSearch, search/listDirectory, search/usages, search/changes, web/fetch, web/githubRepo, web/githubTextSearch, edit/editFiles, edit/createFile] +user-invocable: true +argument-hint: Ask for CoreEx pattern guidance, architecture decisions, or sample-aligned implementation advice. +--- +You are the CoreEx Expert. + +Your mission: +- Provide authoritative guidance on CoreEx architecture, patterns, and practices. +- Prefer CoreEx-native primitives and conventions over generic .NET advice. +- Keep recommendations aligned with the established layering, sample implementations, and consumer-facing AI guides. +- Apply equally whether working in the CoreEx repository itself or a consuming project. + +## Primary sources of truth + +### Locally present + +These files are present when the CoreEx AI workflow set has been copied into the project: + +- `.github/copilot-instructions.md` — project-wide guidelines, repository shape, key conventions, and house rules. +- `.github/instructions/coreex-contracts.instructions.md` — entity contracts, `[Contract]`, `[ReferenceData]`, source generation. +- `.github/instructions/coreex-domain.instructions.md` — DDD aggregates, `Entity`, mutation guards, `Result` pipelines. +- `.github/instructions/coreex-application-services.instructions.md` — service shape, `TransactionAsync`, validation-before-transaction, event enqueuing. +- `.github/instructions/coreex-validators.instructions.md` — `Validator`, rule chains, `CommonValidator`, `ValidateAndThrowAsync`. +- `.github/instructions/coreex-repositories.instructions.md` — `EfDbModel`, `IBiDirectionMapper`, `QueryArgsConfig`, paging. +- `.github/instructions/coreex-api-controllers.instructions.md` — controller shape, `WebApi` helpers, `[IdempotencyKey]`, PATCH. +- `.github/instructions/coreex-event-subscribers.instructions.md` — subscriber classes, `[Subscribe]`, `SubscribedManager`, error handling. +- `.github/instructions/coreex-host-setup.instructions.md` — `Program.cs` shape, middleware order, service registration, outbox relay hosts. +- `.github/instructions/coreex-tooling.instructions.md` — `*.CodeGen` and `*.Database` projects, `ref-data.yaml`, DbEx, generated-file ownership. +- `.github/instructions/coreex-tests.instructions.md` — `UnitTestEx`, `NUnit`, `AwesomeAssertions`, outbox/event expectations, seed data. + +### Per-package AI usage guides + +Check `.github/docs/coreex/agents/` for locally cached guides first (see [Local doc cache](#local-doc-cache)). `/coreex-docs-sync` caches guides for **all** CoreEx packages — check the manifest's `referenced-packages` field to distinguish packages already in the project from ones the project would need to add. + +If a guide is not cached locally, fetch from GitHub: + +- [CoreEx](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx/AGENTS.md) — exceptions, `ExecutionContext`, `Result`, entity contracts, `Runtime.UtcNow`, DI attributes. +- [CoreEx.AspNetCore](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.AspNetCore/AGENTS.md) — `WebApi`, middleware, health checks, idempotency. +- [CoreEx.AspNetCore.NSwag](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.AspNetCore.NSwag/AGENTS.md) — NSwag/OpenAPI integration. +- [CoreEx.Azure.Messaging.ServiceBus](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Azure.Messaging.ServiceBus/AGENTS.md) — Service Bus publisher, subscribers, error handling. +- [CoreEx.Caching.FusionCache](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Caching.FusionCache/AGENTS.md) — `IHybridCache`, Redis backplane, idempotency provider. +- [CoreEx.CodeGen](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.CodeGen/AGENTS.md) — `CodeGenConsole`, `ref-data.yaml`, generated-file ownership. +- [CoreEx.Data](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Data/AGENTS.md) — `IUnitOfWork`, `TransactionAsync`, `QueryArgsConfig`, `DataResult`. +- [CoreEx.Database](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Database/AGENTS.md) — `IDatabase`, `DatabaseCommand`, outbox relay base types. +- [CoreEx.Database.Postgres](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Database.Postgres/AGENTS.md) — PostgreSQL `IDatabase`, outbox, error-code conventions. +- [CoreEx.Database.SqlServer](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Database.SqlServer/AGENTS.md) — SQL Server `IDatabase`, session context, outbox, error-code conventions. +- [CoreEx.DomainDriven](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.DomainDriven/AGENTS.md) — `Entity`, `Aggregate`, `PersistenceState`. +- [CoreEx.EntityFrameworkCore](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.EntityFrameworkCore/AGENTS.md) — `EfDb`, `EfDbModel`, dynamic query, `ValueConverterBridge`. +- [CoreEx.Events](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Events/AGENTS.md) — `EventData`, `IEventFormatter`, `IEventPublisher`, `SubscribedManager`. +- [CoreEx.RefData](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.RefData/AGENTS.md) — `ReferenceData`, `ReferenceDataHybridCache`, `ReferenceDataOrchestrator`. +- [CoreEx.UnitTesting](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.UnitTesting/AGENTS.md) — outbox/event expectations, `JsonDataReader`, `AwesomeAssertions`. +- [CoreEx.Validation](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Validation/AGENTS.md) — `Validator`, rule catalogue, `ValidateAndThrowAsync`. + +### Sample architecture docs + +Check `.github/docs/coreex/` for a local cache first (see [Local doc cache](#local-doc-cache)). If local copies are present, prefer them. Otherwise fetch from GitHub: + +- [Local Development Setup](https://github.com/Avanade/CoreEx/blob/main/samples/docs/local-dev.md) — infrastructure services (Docker/Podman), connection strings, Service Bus emulator config, startup sequences, and Aspire E2E guide. +- [Layer Dependencies](https://github.com/Avanade/CoreEx/blob/main/samples/docs/layers.md) — full layer dependency diagram, design-time tooling overview, dependency rules. +- [Pattern Catalog](https://github.com/Avanade/CoreEx/blob/main/samples/docs/patterns.md) — error handling, railway-oriented flows, outbox, adapters, policies, testing. +- [Contracts Layer](https://github.com/Avanade/CoreEx/blob/main/samples/docs/contracts-layer.md) — generated contracts, interfaces, reference data code properties. +- [Domain Layer](https://github.com/Avanade/CoreEx/blob/main/samples/docs/domain-layer.md) — aggregates, mutation guards, integration-event accumulation, `Result` pipelines. +- [Application Layer](https://github.com/Avanade/CoreEx/blob/main/samples/docs/application-layer.md) — service orchestration, `TransactionAsync`, `IUnitOfWork.Events`, validators, policies, adapters. +- [Infrastructure Layer](https://github.com/Avanade/CoreEx/blob/main/samples/docs/infrastructure-layer.md) — EF Core repositories, `IBiDirectionMapper`, outbox table wiring, relay publisher. +- [Hosts Layer](https://github.com/Avanade/CoreEx/blob/main/samples/docs/hosts-layer.md) — API, Subscribe, and Relay `Program.cs` shapes, middleware ordering, Service Bus wiring. +- [Testing](https://github.com/Avanade/CoreEx/blob/main/samples/docs/testing.md) — unit, integration, API, Subscribe, and Relay test patterns with concrete examples. +- [Tooling](https://github.com/Avanade/CoreEx/blob/main/samples/docs/tooling.md) — `*.CodeGen` and `*.Database` project run order, generated-file ownership, schema generation. +- [Aspire](https://github.com/Avanade/CoreEx/blob/main/samples/docs/aspire.md) — Aspire orchestration for local distributed development and E2E testing. + +## Local doc cache + +`/coreex-docs-sync` populates two local folders. Prefer local copies over GitHub URLs or fetches whenever they are present. + +| Folder | Contents | +|---|---| +| `.github/docs/coreex/` | 10 sample architecture docs (layers, patterns, each layer walkthrough, testing, tooling, Aspire) | +| `.github/docs/coreex/agents/` | AI usage guides for **all** CoreEx packages — available for guidance even on packages not yet adopted by this project | + +A manifest at `.github/docs/coreex/.manifest` records the sync date, CoreEx version, and which packages are currently referenced in the project. + +**When you are about to consult a sample architecture doc or a per-package guide:** + +1. Check for the file under `.github/docs/coreex/` or `.github/docs/coreex/agents/` respectively. +2. If found, use the local copy. Then read `.github/docs/coreex/.manifest` and check: + - `synced` date: if older than 30 days, recommend running `/coreex-docs-sync`. + - `4.0.0-preview-1`: scan `*.csproj`, `Directory.Packages.props`, and `Directory.Build.props` for the `CoreEx` package version; if it differs from the manifest, recommend running `/coreex-docs-sync`. +3. If no local cache exists and you are about to fetch a GitHub URL, offer first: *"I can run `/coreex-docs-sync` to cache the CoreEx docs and all package guides locally — this avoids repeated GitHub fetches. Want me to do that first?"* + +**At the start of a session involving CoreEx guidance**, read `.github/docs/coreex/.manifest` if it exists. The `referenced-packages` field lists which CoreEx packages this project currently uses — distinguish between guiding on an **already-referenced** package and recommending a **new** one the project would need to add. + +Do not set up the local cache silently — always offer and wait for confirmation. + +## Operating rules + +- Always inspect current code before recommending changes. +- Give sample-backed guidance where possible; cite the specific doc or file that supports the recommendation. +- Favor smallest safe change and preserve existing structure. +- Separate explanation, plan, and implementation guidance clearly. +- For mutable entities, call out ETag, changelog, validation, and idempotency implications where relevant. +- For messaging, explicitly distinguish API-only, API plus outbox relay, API plus subscriber, and full orchestration shapes. +- Never recommend editing `*.g.cs`, `*.g.sql`, or `*.g.pgsql` files — direct the user to the owning generator instead (Roslyn source generator for `*.g.cs`; `*.Database` project for `*.g.sql`/`*.g.pgsql`). + +## Decision routing + +These skills are part of the CoreEx AI workflow set and live in `.github/skills/`. They can be copied from the [CoreEx repository](https://github.com/Avanade/CoreEx/tree/main/.github/skills) into a consuming project: + +- Greenfield domain or host scaffolding → advise using the [CoreEx.Template](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Template/README.md) `dotnet new` templates. +- Retrofit capability on an existing domain → inspect the current code and recommend the smallest manual changes aligned to the samples and instructions. +- Repo mapping or onboarding documentation → advise using `/acquire-codebase-knowledge`. + +## Response format + +1. **Recommendation** — the CoreEx-idiomatic answer. +2. **Why this fits CoreEx** — pattern or design principle it follows. +3. **Evidence** — specific file/doc/sample that backs it up. +4. **Risks and tradeoffs** — anything the user should weigh. +5. **Minimal next steps** — actionable and ordered. diff --git a/plugins/coreex-agent-pack/plugin.json b/plugins/coreex-agent-pack/plugin.json new file mode 100644 index 00000000..9745b729 --- /dev/null +++ b/plugins/coreex-agent-pack/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "coreex-agent-pack", + "description": "CoreEx custom agent and skills for architecture guidance and scaffolding.", + "version": "1.1.0", + "author": { + "name": "Avanade" + }, + "license": "MIT", + "agents": "agents/", + "skills": "skills/" +} diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/SKILL.md b/plugins/coreex-agent-pack/skills/coreex-onboard/SKILL.md new file mode 100644 index 00000000..f9923763 --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/SKILL.md @@ -0,0 +1,61 @@ +--- +name: coreex-onboard +description: "Scaffold CoreEx Copilot instruction and agent markdown files into a repository from bundled plugin templates." +argument-hint: "Optional: mode (safe or force) and whether to overwrite existing files." +tags: ["coreex", "onboarding", "bootstrap", "instructions", "scaffolding"] +--- + +# CoreEx Onboard + +Scaffolds CoreEx AI guidance files into a repository so Copilot behavior aligns to CoreEx standards from day one. + +## When to Use + +- A repository is empty or missing CoreEx Copilot guidance files. +- You installed `coreex-agent-pack` and want `.github` guidance files materialized in-repo. +- You want a repeatable way to bootstrap CoreEx instructions across multiple repositories. + +## When Not to Use + +- You only need one file tweaked manually; edit that file directly instead. +- You are upgrading repository code, not onboarding Copilot guidance. +- You need to scaffold solution/domain hosts; use `solution-scaffolder` after onboarding. + +## Workflow Overview + +1. Detect the repository root and verify write access to `.github/`. +2. Compare bundled templates with existing target files. +3. Apply in **safe mode** by default: create missing files only; do not overwrite. +4. If the user requests overwrite, apply **force mode** for selected files. +5. Write/update `.github/coreex-bootstrap.json` with source plugin and timestamp. +6. Summarize created, skipped, and overwritten files. + +For step-by-step guidance, see [the workflow guide](references/workflow.md). +For completion gates, see [the checklist guide](references/checklists.md). + +## Quick Reference + +| Source template path | Target path | +|---|---| +| `assets/templates/.github/copilot-instructions.md` | `.github/copilot-instructions.md` | +| `assets/templates/.github/instructions/*.instructions.md` | `.github/instructions/*.instructions.md` | +| `assets/templates/.github/agents/coreex-expert.agent.md` | `.github/agents/coreex-expert.agent.md` | + +Bootstrap marker format: + +```json +{ + "bootstrapVersion": "1.1.0", + "plugin": "coreex-agent-pack", + "skill": "coreex-onboard", + "mode": "safe", + "updatedAtUtc": "2026-01-01T00:00:00Z" +} +``` + +## Key References + +- [CoreEx Copilot Instructions](assets/templates/.github/copilot-instructions.md) +- [CoreEx Instruction Templates](assets/templates/.github/instructions/) +- [CoreEx Expert Agent Template](assets/templates/.github/agents/coreex-expert.agent.md) +- [Skill workflow](references/workflow.md) diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/agents/coreex-expert.agent.md b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/agents/coreex-expert.agent.md new file mode 100644 index 00000000..73d9f6cc --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/agents/coreex-expert.agent.md @@ -0,0 +1,120 @@ +--- +name: CoreEx Expert +description: "Use when you need to explain, understand, or decide how CoreEx works in your project. Triggers: explain CoreEx, how does CoreEx, which pattern, which capability, which shape, plan a feature, review a design, compare samples, architecture guidance, coding patterns, layering, host setup, validation, repository conventions, eventing, outbox relay, subscriber design, sample-aligned decisions." +tools: [read/readFile, read/problems, search/codebase, search/fileSearch, search/textSearch, search/listDirectory, search/usages, search/changes, web/fetch, web/githubRepo, web/githubTextSearch, edit/editFiles, edit/createFile] +user-invocable: true +argument-hint: Ask for CoreEx pattern guidance, architecture decisions, or sample-aligned implementation advice. +--- +You are the CoreEx Expert. + +Your mission: +- Provide authoritative guidance on CoreEx architecture, patterns, and practices. +- Prefer CoreEx-native primitives and conventions over generic .NET advice. +- Keep recommendations aligned with the established layering, sample implementations, and consumer-facing AI guides. +- Apply equally whether working in the CoreEx repository itself or a consuming project. + +## Primary sources of truth + +### Locally present + +These files are present when the CoreEx AI workflow set has been copied into the project: + +- `.github/copilot-instructions.md` — project-wide guidelines, repository shape, key conventions, and house rules. +- `.github/instructions/coreex-contracts.instructions.md` — entity contracts, `[Contract]`, `[ReferenceData]`, source generation. +- `.github/instructions/coreex-domain.instructions.md` — DDD aggregates, `Entity`, mutation guards, `Result` pipelines. +- `.github/instructions/coreex-application-services.instructions.md` — service shape, `TransactionAsync`, validation-before-transaction, event enqueuing. +- `.github/instructions/coreex-validators.instructions.md` — `Validator`, rule chains, `CommonValidator`, `ValidateAndThrowAsync`. +- `.github/instructions/coreex-repositories.instructions.md` — `EfDbModel`, `IBiDirectionMapper`, `QueryArgsConfig`, paging. +- `.github/instructions/coreex-api-controllers.instructions.md` — controller shape, `WebApi` helpers, `[IdempotencyKey]`, PATCH. +- `.github/instructions/coreex-event-subscribers.instructions.md` — subscriber classes, `[Subscribe]`, `SubscribedManager`, error handling. +- `.github/instructions/coreex-host-setup.instructions.md` — `Program.cs` shape, middleware order, service registration, outbox relay hosts. +- `.github/instructions/coreex-tooling.instructions.md` — `*.CodeGen` and `*.Database` projects, `ref-data.yaml`, DbEx, generated-file ownership. +- `.github/instructions/coreex-tests.instructions.md` — `UnitTestEx`, `NUnit`, `AwesomeAssertions`, outbox/event expectations, seed data. + +### Per-package AI usage guides + +Check `.github/docs/coreex/agents/` for locally cached guides first (see [Local doc cache](#local-doc-cache)). `/coreex-docs-sync` caches guides for **all** CoreEx packages — check the manifest's `referenced-packages` field to distinguish packages already in the project from ones the project would need to add. + +If a guide is not cached locally, fetch from GitHub: + +- [CoreEx](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx/AGENTS.md) — exceptions, `ExecutionContext`, `Result`, entity contracts, `Runtime.UtcNow`, DI attributes. +- [CoreEx.AspNetCore](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.AspNetCore/AGENTS.md) — `WebApi`, middleware, health checks, idempotency. +- [CoreEx.AspNetCore.NSwag](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.AspNetCore.NSwag/AGENTS.md) — NSwag/OpenAPI integration. +- [CoreEx.Azure.Messaging.ServiceBus](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Azure.Messaging.ServiceBus/AGENTS.md) — Service Bus publisher, subscribers, error handling. +- [CoreEx.Caching.FusionCache](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Caching.FusionCache/AGENTS.md) — `IHybridCache`, Redis backplane, idempotency provider. +- [CoreEx.CodeGen](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.CodeGen/AGENTS.md) — `CodeGenConsole`, `ref-data.yaml`, generated-file ownership. +- [CoreEx.Data](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Data/AGENTS.md) — `IUnitOfWork`, `TransactionAsync`, `QueryArgsConfig`, `DataResult`. +- [CoreEx.Database](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Database/AGENTS.md) — `IDatabase`, `DatabaseCommand`, outbox relay base types. +- [CoreEx.Database.Postgres](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Database.Postgres/AGENTS.md) — PostgreSQL `IDatabase`, outbox, error-code conventions. +- [CoreEx.Database.SqlServer](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Database.SqlServer/AGENTS.md) — SQL Server `IDatabase`, session context, outbox, error-code conventions. +- [CoreEx.DomainDriven](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.DomainDriven/AGENTS.md) — `Entity`, `Aggregate`, `PersistenceState`. +- [CoreEx.EntityFrameworkCore](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.EntityFrameworkCore/AGENTS.md) — `EfDb`, `EfDbModel`, dynamic query, `ValueConverterBridge`. +- [CoreEx.Events](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Events/AGENTS.md) — `EventData`, `IEventFormatter`, `IEventPublisher`, `SubscribedManager`. +- [CoreEx.RefData](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.RefData/AGENTS.md) — `ReferenceData`, `ReferenceDataHybridCache`, `ReferenceDataOrchestrator`. +- [CoreEx.UnitTesting](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.UnitTesting/AGENTS.md) — outbox/event expectations, `JsonDataReader`, `AwesomeAssertions`. +- [CoreEx.Validation](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Validation/AGENTS.md) — `Validator`, rule catalogue, `ValidateAndThrowAsync`. + +### Sample architecture docs + +Check `.github/docs/coreex/` for a local cache first (see [Local doc cache](#local-doc-cache)). If local copies are present, prefer them. Otherwise fetch from GitHub: + +- [Local Development Setup](https://github.com/Avanade/CoreEx/blob/main/samples/docs/local-dev.md) — infrastructure services (Docker/Podman), connection strings, Service Bus emulator config, startup sequences, and Aspire E2E guide. +- [Layer Dependencies](https://github.com/Avanade/CoreEx/blob/main/samples/docs/layers.md) — full layer dependency diagram, design-time tooling overview, dependency rules. +- [Pattern Catalog](https://github.com/Avanade/CoreEx/blob/main/samples/docs/patterns.md) — error handling, railway-oriented flows, outbox, adapters, policies, testing. +- [Contracts Layer](https://github.com/Avanade/CoreEx/blob/main/samples/docs/contracts-layer.md) — generated contracts, interfaces, reference data code properties. +- [Domain Layer](https://github.com/Avanade/CoreEx/blob/main/samples/docs/domain-layer.md) — aggregates, mutation guards, integration-event accumulation, `Result` pipelines. +- [Application Layer](https://github.com/Avanade/CoreEx/blob/main/samples/docs/application-layer.md) — service orchestration, `TransactionAsync`, `IUnitOfWork.Events`, validators, policies, adapters. +- [Infrastructure Layer](https://github.com/Avanade/CoreEx/blob/main/samples/docs/infrastructure-layer.md) — EF Core repositories, `IBiDirectionMapper`, outbox table wiring, relay publisher. +- [Hosts Layer](https://github.com/Avanade/CoreEx/blob/main/samples/docs/hosts-layer.md) — API, Subscribe, and Relay `Program.cs` shapes, middleware ordering, Service Bus wiring. +- [Testing](https://github.com/Avanade/CoreEx/blob/main/samples/docs/testing.md) — unit, integration, API, Subscribe, and Relay test patterns with concrete examples. +- [Tooling](https://github.com/Avanade/CoreEx/blob/main/samples/docs/tooling.md) — `*.CodeGen` and `*.Database` project run order, generated-file ownership, schema generation. +- [Aspire](https://github.com/Avanade/CoreEx/blob/main/samples/docs/aspire.md) — Aspire orchestration for local distributed development and E2E testing. + +## Local doc cache + +`/coreex-docs-sync` populates two local folders. Prefer local copies over GitHub URLs or fetches whenever they are present. + +| Folder | Contents | +|---|---| +| `.github/docs/coreex/` | 11 sample architecture docs (layers, patterns, each layer walkthrough, testing, tooling, Aspire) | +| `.github/docs/coreex/agents/` | AI usage guides for **all** CoreEx packages — available for guidance even on packages not yet adopted by this project | + +A manifest at `.github/docs/coreex/.manifest` records the sync date, CoreEx version, and which packages are currently referenced in the project. + +**When you are about to consult a sample architecture doc or a per-package guide:** + +1. Check for the file under `.github/docs/coreex/` or `.github/docs/coreex/agents/` respectively. +2. If found, use the local copy. Then read `.github/docs/coreex/.manifest` and check: + - `synced` date: if older than 30 days, recommend running `/coreex-docs-sync`. + - `4.0.0-preview-1`: scan `*.csproj`, `Directory.Packages.props`, and `Directory.Build.props` for the `CoreEx` package version; if it differs from the manifest, recommend running `/coreex-docs-sync`. +3. If no local cache exists and you are about to fetch a GitHub URL, offer first: *"I can run `/coreex-docs-sync` to cache the CoreEx docs and all package guides locally — this avoids repeated GitHub fetches. Want me to do that first?"* + +**At the start of a session involving CoreEx guidance**, read `.github/docs/coreex/.manifest` if it exists. The `referenced-packages` field lists which CoreEx packages this project currently uses — distinguish between guiding on an **already-referenced** package and recommending a **new** one the project would need to add. + +Do not set up the local cache silently — always offer and wait for confirmation. + +## Operating rules + +- Always inspect current code before recommending changes. +- Give sample-backed guidance where possible; cite the specific doc or file that supports the recommendation. +- Favor smallest safe change and preserve existing structure. +- Separate explanation, plan, and implementation guidance clearly. +- For mutable entities, call out ETag, changelog, validation, and idempotency implications where relevant. +- For messaging, explicitly distinguish API-only, API plus outbox relay, API plus subscriber, and full orchestration shapes. +- Never recommend editing `*.g.cs`, `*.g.sql`, or `*.g.pgsql` files — direct the user to the owning generator instead (Roslyn source generator for `*.g.cs`; `*.Database` project for `*.g.sql`/`*.g.pgsql`). + +## Decision routing + +These skills are part of the CoreEx AI workflow set and live in `.github/skills/`. They can be copied from the [CoreEx repository](https://github.com/Avanade/CoreEx/tree/main/.github/skills) into a consuming project: + +- Greenfield domain or host scaffolding → advise using the [CoreEx.Template](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Template/README.md) `dotnet new` templates. +- Retrofit capability on an existing domain → inspect the current code and recommend the smallest manual changes aligned to the samples and instructions. +- Repo mapping or onboarding documentation → advise using `/acquire-codebase-knowledge`. + +## Response format + +1. **Recommendation** — the CoreEx-idiomatic answer. +2. **Why this fits CoreEx** — pattern or design principle it follows. +3. **Evidence** — specific file/doc/sample that backs it up. +4. **Risks and tradeoffs** — anything the user should weigh. +5. **Minimal next steps** — actionable and ordered. diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/copilot-instructions.md b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/copilot-instructions.md new file mode 100644 index 00000000..c783f78e --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/copilot-instructions.md @@ -0,0 +1,185 @@ +--- +# applyTo is intentionally omitted — this file is applied globally by VS Copilot convention for copilot-instructions.md. +description: "Project-wide guidelines and conventions for CoreEx development" +tags: ["guidelines", "conventions", "comments"] +--- + +# Copilot Instructions + +## Purpose +CoreEx is a modular .NET framework for enterprise APIs and distributed services. Favor CoreEx-native primitives, patterns, and extensions over ad-hoc implementations. + +## Repository Shape +- `CoreEx.sln`: main solution for framework + samples. +- `src\`: reusable CoreEx libraries (AspNetCore, Database, EntityFrameworkCore, Events, Validation, DomainDriven, RefData, Caching, etc.). +- `gen\CoreEx.Generator\`: Roslyn source generator for contracts. +- `tests\`: framework-level tests. +- `samples\src\Contoso.*\`: sample domains split by layer/host. +- `samples\aspire\AppHost.cs`: orchestration entrypoint. +- `coreex-starter\`: separate starter template repo — ignore unless user wants starter changes. + +## Build, Test, and Run +- **Build**: `dotnet build CoreEx.sln` +- **Test**: `dotnet test CoreEx.sln` or target specific projects. +- **Single test**: `dotnet test --filter "FullyQualifiedName~"` +- **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`. + +## Local Development Infrastructure + +All sample hosts depend on containerised infrastructure. Start it before running any host or integration test: + +```bash +podman compose -f docker-compose.yml up -d # Podman preferred; `docker compose` also works +``` + +| Service | Port(s) | Purpose | +|---|---|---| +| `db-sql-server` | 1433 | Shopping domain database; Service Bus emulator backing store | +| `db-postgres` | 5432 | Products domain database | +| `redis-cache` | 6379 | FusionCache Redis backplane (all domains) | +| `servicebus-emulator` | 5672 AMQP, 5300 mgmt | Azure Service Bus emulator; namespace `sbemulatorns`; topic `contoso` with subscriptions `products` and `shopping` (both session-enabled); config at `servicebus/Config.json` | +| `dts-emulator` | 8080, 8082 | Azure Durable Task Scheduler emulator; task hubs `default` and `order` | +| `aspire-dashboard` | 18888 UI, 4317 OTLP | Standalone OpenTelemetry dashboard; usable without running the full Aspire AppHost | + +Connection strings for each service in development are in each host's `appsettings.Development.json` under the `Aspire:` configuration key hierarchy. See [the local development guide](https://github.com/Avanade/CoreEx/blob/main/samples/docs/local-dev.md) for full detail, connection string patterns, and startup sequences. + +## Architecture +- **Two roles**: framework packages (`src\`) + sample reference implementations (`samples\`). +- **Business layers** (strict inward dependency — inner layers have no knowledge of outer): `*.Contracts` → `*.Application` → `*.Domain` (optional) → `*.Infrastructure`. +- **Host layers** (composition roots, no business logic): `*.Api`, `*.Relay`, `*.Subscribe`. +- **Design-time tooling** (no runtime presence): `*.CodeGen` (generates reference-data layer from `ref-data.yaml`) and `*.Database` (schema, seeding, outbox infrastructure via DbEx). +- **Sample flow**: Controllers → `WebApi` helpers → Application services (validate + `IUnitOfWork`) → Infrastructure repositories (EF + explicit mappers) → transactional outbox → relay publishes to Service Bus → subscribers consume. +- **Polyglot data**: Products uses PostgreSQL (`CoreEx.Database.Postgres` + `CoreEx.EntityFrameworkCore`); Shopping uses SQL Server (`CoreEx.Database.SqlServer` + `CoreEx.EntityFrameworkCore`). Layers above Infrastructure are database-agnostic. +- **Primary domains**: Products and Shopping complete; Orders WIP. See `samples\README.md` for topology. +- **Aspire**: orchestrates all sample hosts in `samples\aspire\Contoso.Aspire\AppHost.cs` for local distributed development and E2E testing. + +## Key Conventions That Matter in This Repo + +### CoreEx-First Patterns +- Prefer CoreEx primitives before introducing external libraries that overlap with framework capabilities. +- Prefer CoreEx exception types (`NotFoundException`, `ValidationException`, `BusinessException`, `ConcurrencyException`, etc.) and CoreEx `Result`/`Result` flows over custom error wrappers. +- Do not introduce AutoMapper in any repository unless the repository maintainer explicitly requests it. Repositories and services use explicit mapping helpers/classes. + +### Contracts and Source Generation +- Contracts are commonly declared as `[Contract] public partial class ...`. +- Mutable contracts often implement `IIdentifier`, `IETag`, and `IChangeLog`. +- Use `[ReadOnly(true)]` for server-managed fields and `[ReferenceData]` for reference-data-backed code properties. +- 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. + +### Dependency Injection and Layering +- Services and repositories commonly self-register with `[ScopedService<...>]`. +- Hosts use `AddDynamicServicesUsing()` to discover and register services instead of manually wiring every type. +- Keep interface/implementation layering intact: + - application interfaces live in `Application\Interfaces\`, `Application\Repositories\`, `Application\Adapters\`, or `Application\Policies\`; + - infrastructure implementations live in `Infrastructure\`. +- There are two distinct mapping layers — do not conflate them: + - **Application-level** (`Application\Mapping\`): Domain aggregate → Contract, using `Mapper`; present only in domains with a Domain layer (e.g. Shopping). + - **Infrastructure-level** (`Infrastructure\Mapping\`): Contract ↔ Persistence model, using `BiDirectionMapper`; present in all domains. + +### Application-Service Shape +- Application services follow a repeated pattern: + 1. guard/normalize inputs; + 2. validate with CoreEx validators; + 3. load current state where needed; + 4. wrap mutations **and** event publication together inside `_unitOfWork.TransactionAsync(...)` — both the database write and the outbox event are committed atomically or not at all; + 5. add `EventData` to `_unitOfWork.Events` inside that same transactional scope. +- Use exception-based flows for straightforward CRUD-style services. +- Use `Result` pipelines for aggregate-oriented flows and multi-step orchestration, especially in Shopping. + +### 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. +- 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. +- Do not call `HttpClient` directly from services — always go through the adapter interface. + +### Policies +- Policies (`Application\Policies\`) encapsulate **domain-level guard logic** that requires I/O — adapter or repository calls. A policy provides a named, independently testable home for rules that depend on external state and cannot be expressed in a validator alone (synchronous) or in the domain model (no async I/O). Policies return `Result` or `Result` and can be called from any point in service orchestration where the condition needs to be verified. +- Use a policy when an invariant cannot be expressed in a validator alone (e.g. confirming a referenced entity exists before allowing a mutation). +- Policies return `Result` or `Result` and compose naturally into `Result` service pipelines via `.GoAsync()` / `.ThenAsAsync()`. + +### Host Composition +- `Program.cs` files follow a predictable CoreEx host shape: + - `builder.AddHostSettings();` + - `AddExecutionContext()` + - `AddMvcWebApi()` and `AddHttpWebApi()` + - host-specific SQL Server / Redis / Service Bus / outbox registrations + - `PostConfigureAllHealthChecks()` + - NSwag/OpenAPI registration + - OpenTelemetry wiring + - middleware order with `UseCoreExExceptionHandler()`, `UseExecutionContext()`, and host-specific additions such as `UseIdempotencyKey()` or `MapHostedServices()`. +- API hosts, subscriber hosts, and outbox relay hosts intentionally have different startup shapes. Do not collapse them into one generic startup unless the user explicitly asks for that refactor. + +### Controllers and HTTP +- Use CoreEx `WebApi` helpers (`PostAsync`, `PutAsync`, `PatchAsync`, `DeleteAsync`). +- PATCH: `application/merge-patch+json`. +- POST: use `[IdempotencyKey]`. +- OpenAPI/health endpoints standard in hosts. + +### Data and Messaging +- Transactional outbox + Azure Service Bus are first-class messaging patterns across all domains. +- **Products** uses PostgreSQL; **Shopping** uses SQL Server. Do not assume SQL Server when working on Products. +- Shopping: synchronous HTTP inventory reservation + transactional outbox + async event publishing. Preserve this split. +- Both domains use `CoreEx.Caching.FusionCache` (hybrid in-process + Redis backplane cache) for reference data and idempotency. Register via `AddFusionCache()` / `AddFusionHybridCache()` in `Program.cs`; clear via `Test.ClearFusionCacheAsync()` in test `[OneTimeSetUp]`. + +### Testing +- Framework: UnitTestEx + NUnit + AwesomeAssertions (the `AwesomeAssertions` NuGet package — not FluentAssertions). +- Sample: `WithGenericTester` (unit) or `WithApiTester` (API/Subscribe/Relay). +- Integration tests: per-class named seed files `Data\read-data.seed.yaml` / `Data\mutate-data.seed.yaml` (Products), or a single `Data\data.yaml` (Orders/Shopping), in Test.Common + `Resources\` JSON expectations. +- **Intra-domain dependencies are real; inter-domain dependencies are always mocked.** Own database, cache, and outbox are started and seeded in `[OneTimeSetUp]`. Cross-domain HTTP calls and direct broker publishes are replaced with `MockHttpClientFactory` / `UseExpectedAzureServiceBusPublisher()`. +- Outbox assertion helpers are database-specific: `UseExpectedPostgresOutboxPublisher()` for Products; `UseExpectedSqlServerOutboxPublisher()` for Shopping. Do not use the SQL Server helper in Products tests. +- Mock downstream HTTP calls; do not assume live APIs. + +### House Rules +- Code comments end with a period/full stop. +- Always use `.ConfigureAwait(false)` in service/repository code. +- `enable` and `enable` 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. +- File-scoped namespace declarations only: `namespace Foo.Bar;` — never block-scoped. +- Single-line `if` bodies do not need braces: `if (x) return;` +- Use expression-bodied syntax (`=>`) when the entire method or property body is a single expression. +- Private instance fields are always prefixed with `_`. + +### Generated Code +Never create or edit `*.g.cs`, `*.g.sql`, or `*.g.pgsql` files directly. Each generator owns its outputs: + +| File pattern | Generator | Change instead | +|---|---|---| +| `*.g.cs` (contracts, ref-data) | Roslyn source generator (`CoreEx.Generator`) | The `[Contract]`- or `[ReferenceData]`-decorated partial class | +| `*.g.cs` (ref-data layer — controller, service, repository, mapper) | `*.CodeGen` project (CoreEx.CodeGen + `ref-data.yaml`) | `ref-data.yaml` config or the Handlebars templates in `CoreEx.CodeGen/RefData/Templates/` | +| `*.g.sql`, `*.g.pgsql`, `*DbContext.g.cs`, `Persistence/*.g.cs` | `*.Database` project (DbEx) | DbEx YAML config or SQL migration scripts | + +See [the instruction authoring guide](https://github.com/Avanade/CoreEx/blob/main/.github/INSTRUCTION_AUTHORING.md#generated-code) for full generator ownership detail. + +## Key Docs to Read Before Large Changes +- `README.md` — repo-level positioning and top-level commands. +- `samples\README.md` — runnable Contoso architecture and local setup. +- `docs\capabilities.md` — deeper CoreEx capability and pattern explanations. +- `samples\docs\layers.md` — full layer diagram, dependency rules, and design-time tooling overview. +- `samples\docs\patterns.md` — pattern catalog with links to layer-specific detail for every architectural, application, messaging, and testing pattern used in the samples. +- `samples\docs\.md` — detailed walkthrough for each layer: `contracts-layer.md`, `application-layer.md`, `domain-layer.md`, `infrastructure-layer.md`, `hosts-layer.md`, `testing.md`, `tooling.md`. +- `.github\instructions\*.instructions.md` — area-specific rules auto-injected when editing matching files (`Program.cs`, contracts, application services, repositories, validators, subscribers, tests). + +## Agent Customizations (Prompts, Skills, and Templates) + +The following prompts, skills, and templates are available in this repository. Type `/` in chat to invoke prompts and skills. Use `dotnet new` in a terminal for templates. + +| Command | Type | When to use | +|---------|------|-------------| +| `CoreEx.Template` | Template pack | Deterministic `dotnet new` scaffolding for solution, API, relay, and subscriber shapes. Use `dotnet new install CoreEx.Template` and then run `dotnet new coreex`, `coreex-api`, `coreex-relay`, or `coreex-subscribe` as needed. | +| `CoreEx Expert` | Agent | Architecture guidance, pattern recommendations, and design review aligned to the samples and repo instructions. | +| `/init` | Prompt | Initialize a new CoreEx solution or workspace. | +| `/setup` | Prompt | Configure an existing CoreEx solution with standard tooling and settings. | + +## Guidance for Authoring Instructions and Skills + +When creating or maintaining Copilot instruction files and skills: + +- **Instruction files** (`.instructions.md`) — see [the instruction authoring guide](https://github.com/Avanade/CoreEx/blob/main/.github/INSTRUCTION_AUTHORING.md) for standards on YAML frontmatter, section order, and content rules. +- **Skill files** (`SKILL.md`) — see [the skill authoring guide](https://github.com/Avanade/CoreEx/blob/main/.github/SKILL_AUTHORING.md) for the directory structure pattern (`references/`, `assets/`), lean main file rules (<300 lines), and cross-referencing guidelines. + +Both documents define durable patterns for creating guidance that is discoverable, maintainable, and context-efficient. \ No newline at end of file diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-api-controllers.instructions.md b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-api-controllers.instructions.md new file mode 100644 index 00000000..e8248f6b --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-api-controllers.instructions.md @@ -0,0 +1,330 @@ +--- +applyTo: "**/Controllers/**/*.cs" +description: "API conventions for CoreEx: MVC ControllerBase and Minimal API approaches, WebApi integration, routing, CQRS separation" +tags: ["controllers", "api", "routing", "cqrs", "dependency-injection", "minimal-api"] +--- + +# API Conventions + +> **Precondition — the Api host must exist.** Controllers live in the `*.Api` host, which is **not** part of the base `coreex` solution. Before authoring a controller, confirm an Api host is present (`**/*.Api/*.Api.csproj`); if it is absent, run the scaffolding workflow first (see `coreex-host-setup.instructions.md` → "Scaffolding an API host") — which confirms creation, generates it via the `coreex-api` template using the recorded solution options, and adds the new projects to the solution (`dotnet sln add`) as the final in-session step. Also ensure the entity's application service exists (create per *Service Operations — Confirm Scope* in `coreex-application-services.instructions.md` if not). + +> **Maintain the API tests alongside the controller.** When you create or change a controller's operations, **offer to create or update the matching `XxxReadTests` / `XxxMutateTests`** (per-entity, one partial file per operation) in the `*.Test.Api` project — see `coreex-tests.instructions.md` → "API Tests — Structure & Generation". If accepted, co-design the seed data, tests, and `.res.json`/`.req.json` resources together; if declined, proceed but note the coverage gap. + +CoreEx.AspNetCore supports two approaches for exposing HTTP endpoints. Choose one per host — they can coexist in the same application when needed. + +| Approach | Registration | Returns | Best for | +|---|---|---|---| +| **MVC Controllers** | `AddMvcWebApi()` | `IActionResult` | Familiar controller model; NSwag/OpenAPI attributes | +| **Minimal APIs** | `AddHttpWebApi()` | `IResult` | Lightweight; less ceremony; endpoint groups in `Program.cs` | + +Both use the same `WebApi` helper — method names, `WithResult` variants, `ro.WithLocationUri`, `.Required()`, and `.Adjust(...)` are identical in both approaches. + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx.AspNetCore` | `WebApi`, `[IdempotencyKey]`, `[Accepts]`, `[ProducesNotFoundProblem]`, `[Query]`, `[Paging]`, `HttpNames`; Minimal API: `.WithQuery()`, `.WithPaging()`, `.Accepts()`, `.ProducesNotFoundProblem()`, `.ProducesNoContent()`, `.ProducesCreated()`, `.WithIdempotencyKey()` | +| `CoreEx.AspNetCore.NSwag` | `[OpenApiTag]` | +| `CoreEx` | `.Required()`, `.Adjust(...)` | + +--- + +## MVC Controllers + +### Structure + +- Inherit from `ControllerBase`. Never inherit from `Controller` (that brings View support). +- Decorate with `[ApiController]` and `[Route("...")]` on the class. +- Inject `WebApi` and the relevant service interface via primary constructor. Guard with `.ThrowIfNull()`. +- **Mirror the service CQRS split:** a **`XxxController`** exposes the **mutating** endpoints (POST/PUT/PATCH/DELETE) and injects `IXxxService`; a **`XxxReadController`** exposes the **read** endpoints (GET/query) and injects `IXxxReadService`. +- **Unify them in OpenAPI with a shared `[OpenApiTag("Xxx")]`.** Put the **same** tag on both controllers so Swagger/OpenAPI presents one logical "Xxx" group — CQRS is an **internal** structuring concern, not something the external API surface should expose. (A tag may also be placed on an individual action to cross-tag it.) + +```csharp +// Mutating endpoints +[ApiController, Route("/api/products"), OpenApiTag("Products")] +public class ProductController(WebApi webApi, IProductService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly IProductService _service = service.ThrowIfNull(); +} + +// Read endpoints — same route base and same OpenApiTag so they appear as one "Products" group +[ApiController, Route("/api/products"), OpenApiTag("Products")] +public class ProductReadController(WebApi webApi, IProductReadService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly IProductReadService _service = service.ThrowIfNull(); +} +``` + +### Method Signatures + +All action methods return `Task` using the `WebApi` helper. Do not return typed `ActionResult` directly. + +**Every action method takes a trailing `CancellationToken cancellationToken = default` and flows it through.** MVC binds it to `HttpContext.RequestAborted` automatically. Pass it to the `WebApi` helper via the named `cancellationToken:` argument, and use the **lambda's** `ct` parameter (`(ro, ct) => …`) when calling the service — never discard it with `(ro, _)`: + +```csharp +public Task PostAsync(CancellationToken cancellationToken = default) => _webApi.PostAsync(Request, (ro, ct) => +{ + ro.WithLocationUri(e => new Uri($"/api/employees/{e.Id}", UriKind.Relative)); + return _service.CreateAsync(ro.Value, ct); +}, cancellationToken: cancellationToken); +``` + +This is an instance of the universal rule — **every `async`/`Task`-returning method takes a `CancellationToken` and passes it on** (see `coreex-conventions.instructions.md`). The examples below all follow it. + +#### Standard (exception-based services) + +| HTTP Verb | WebApi helper | Notes | +|---|---|---| +| `GET` / `HEAD` | `_webApi.GetAsync(...)` | Use both attributes together | +| `POST` | `_webApi.PostAsync(...)` | Add `[IdempotencyKey]` for safe POST | +| `PUT` | `_webApi.PutAsync(...)` | Include ETag via `IF-MATCH` header | +| `PATCH` | `_webApi.PatchAsync(...)` | Requires `get:` and `put:` lambdas | +| `DELETE` | `_webApi.DeleteAsync(...)` | Returns 204 No Content | + +#### Result-based (`Result` pipeline services) + +When the service returns `Result`, use the `WithResult` variants. The controller code is equally thin. + +| HTTP Verb | WebApi helper | Notes | +|---|---|---| +| `GET` | `_webApi.GetWithResultAsync(...)` | | +| `POST` (single out) | `_webApi.PostWithResultAsync(...)` | | +| `POST` (in + out) | `_webApi.PostWithResultAsync(...)` | Use when body maps to a different output type | +| `PUT` (single out) | `_webApi.PutWithResultAsync(...)` | | +| `PUT` (in + out) | `_webApi.PutWithResultAsync(...)` | | +| `DELETE` (typed) | `_webApi.DeleteWithResultAsync(...)` | Use when delete returns the deleted resource | + +### Route Parameters + +Use `.Required()` to validate route parameters at the point of first use. It **returns the value** when non-default, or throws a `ValidationException` when the value is null/default — which the `WebApi` error handler translates to a **400 validation response** (not a 500). This is the correct treatment: a missing or empty route parameter is a caller error, not a programming error. + +```csharp +[HttpGet("{id}"), HttpHead("{id}")] +public Task GetAsync(string id, CancellationToken cancellationToken = default) => + _webApi.GetAsync(Request, (_, ct) => _service.GetAsync(id.Required(), ct), cancellationToken: cancellationToken); +``` + +Do not use `.ThrowIfNull()` / `.ThrowIfNullOrEmpty()` on route parameters — those throw `ArgumentNullException`, which results in a 500 rather than a 400. + +### POST — Create with Location Header + +Use `ro.WithLocationUri(...)` to set the `Location` response header: + +```csharp +[HttpPost] +[Accepts] +[ProducesResponseType(201)] +[IdempotencyKey] +public Task PostAsync(CancellationToken cancellationToken = default) => _webApi.PostAsync(Request, (ro, ct) => +{ + ro.WithLocationUri(p => new Uri($"/api/products/{p.Id}", UriKind.Relative)); + return _service.CreateAsync(ro.Value, ct); +}, cancellationToken: cancellationToken); +``` + +> **Agent instruction — confirm idempotency for every POST.** When adding a `POST`, ask whether the operation should be **idempotent** (safe to retry without creating a duplicate). A general **create-style** POST is a strong candidate — default to offering it. If confirmed, decorate the action with **`[IdempotencyKey]`** (MVC) or `.WithIdempotencyKey()` (Minimal API): a retried request carrying the same key then returns the original result instead of creating a second resource. Omit it only when the user confirms the POST is **not** idempotent (e.g. a deliberately non-repeatable command). This applies to `POST` specifically; `PUT`/`PATCH`/`DELETE` are inherently idempotent and do not take the attribute. + +For a **full-entity update**, expose **both** endpoints by default — they share the same write `UpdateAsync`: +- **`PUT`** — full replace. +- **`PATCH`** — merge-patch (RFC 7396) over the current entity. + +Only implement **specialized/partial** update or patch endpoints when the user **explicitly** asks; the default is the PUT + PATCH pair. + +```csharp +[HttpPut("{id}")] +[Accepts] +public Task UpdateAsync(string id, CancellationToken cancellationToken = default) => _webApi.PutAsync(Request, + (ro, ct) => _service.UpdateAsync(ro.Value.Adjust(p => p.Id = id), ct), cancellationToken: cancellationToken); +``` + +`PATCH` always supplies both `get:` and `put:` delegates: it **fetches** the current entity, merges the patch document over it, then calls `put`. The fetch uses the write service's own primary `GetAsync` (`XxxService` exposes a by-id `GetAsync` for exactly this), so the mutating controller depends on a **single** service — no need to also inject `IXxxReadService`: + +```csharp +[HttpPatch("{id}")] +[Accepts(HttpNames.MergePatchJsonMediaTypeName)] +public Task PatchAsync(string id, CancellationToken cancellationToken = default) => _webApi.PatchAsync(Request, + get: (ro, ct) => _service.GetAsync(id.Required(), ct), + put: (ro, ct) => _service.UpdateAsync(ro.Value.Adjust(p => p.Id = id), ct), + cancellationToken: cancellationToken); +``` + +Because the **write** service backs the `get:` fetch, **`IXxxService` must declare a by-id `GetAsync`** (alongside the mutators) — and `XxxService` must implement it. This `GetAsync` lives on the **write** interface even though it reads; it is the controller's single dependency for PATCH (and for a write-side `GET` by id). Do **not** route the PATCH fetch through `IXxxReadService`: + +```csharp +public interface IProductService +{ + Task GetAsync(string id, CancellationToken ct = default); // ← required by PATCH's get: and the write GET + Task CreateAsync(Product value, CancellationToken ct = default); + Task UpdateAsync(Product value, CancellationToken ct = default); + Task DeleteAsync(string id, CancellationToken ct = default); +} +``` + +### Query Endpoints + +Expose `QueryArgs` and `PagingArgs` via `[Query]` and `[Paging]` action attributes. Access them via the request options object (`ro`): + +```csharp +[HttpGet] +[Query(supportsOrderBy: true), Paging(supportsCount: true)] +public Task QueryAsync(CancellationToken cancellationToken = default) => + _webApi.GetAsync(Request, (ro, ct) => _service.QueryAsync(ro.QueryArgs, ro.PagingArgs, ct), cancellationToken: cancellationToken); +``` + +### Reference Data Endpoints + +Delegate to `ReferenceDataOrchestrator.Current.GetWithFilterAsync()`. Support `codes`, `text`, and `isIncludeInactive` filter parameters: + +```csharp +[HttpGet("categories")] +public Task GetCategoriesAsync([FromQuery] IEnumerable? codes = default, string? text = default, CancellationToken cancellationToken = default) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetWithFilterAsync(codes, text, ro.IsIncludeInactive, ct), cancellationToken: cancellationToken); +``` + +### Response Metadata Attributes + +Decorate actions with standard response metadata attributes: + +- `[ProducesResponseType(statusCode)]` — preferred generic form for new code. +- `[ProducesResponseType(typeof(T), statusCode)]` — equivalent non-generic form; either is acceptable. +- `[ProducesNotFoundProblem()]` — shorthand for `[ProducesResponseType(typeof(ProblemDetails), 404)]`; use on GET/PUT/PATCH/DELETE where not-found is expected. +- `[Accepts]` — documents the consumed media type. + +### Query Schema Endpoint + +Read controllers that expose a `QueryAsync` should also expose a `$query` schema endpoint. This returns the JSON schema for the supported query/filter parameters: + +```csharp +[HttpGet("$query")] +[ProducesResponseType(typeof(JsonElement), 200)] +public Task QuerySchemaAsync(CancellationToken cancellationToken = default) => + _webApi.GetAsync(Request, (ro, ct) => _service.QuerySchemaAsync(ct), cancellationToken: cancellationToken); +``` + +### Result-Based Services + +When the service returns `Result`, use the `WithResult` variants: + +```csharp +[HttpPost("{basketId}/checkout")] +[ProducesResponseType(typeof(Basket), StatusCodes.Status200OK)] +[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] +public Task CheckoutAsync(string basketId, CancellationToken cancellationToken = default) => + _webApi.PostWithResultAsync(Request, (_, ct) => + _service.CheckoutAsync(basketId.Required(), ct), HttpStatusCode.OK, cancellationToken: cancellationToken); + +[HttpPost("{basketId}/items")] +[IdempotencyKey] +[Accepts] +[ProducesResponseType(typeof(Basket), StatusCodes.Status200OK)] +public Task ItemAddAsync(string basketId, CancellationToken cancellationToken = default) => + _webApi.PostWithResultAsync(Request, (ro, ct) => + _service.ItemAddAsync(basketId.Required(), ro.Value, ct), HttpStatusCode.OK, cancellationToken: cancellationToken); +``` + +--- + +## Minimal APIs + +Register the HTTP variant in `Program.cs` and map endpoints directly — no controller class required. `WebApi` is injected into the handler lambda alongside the service: + +```csharp +// Program.cs +builder.Services.AddHttpWebApi(); // or alongside AddMvcWebApi() if both are needed +``` + +### Attribute → RouteHandlerBuilder Equivalents + +MVC action attributes have direct `RouteHandlerBuilder` extension equivalents — chain them after `app.MapGet/Post/etc.`: + +| MVC attribute | Minimal API equivalent | +|---|---| +| `[Query(supportsOrderBy: true)]` | `.WithQuery(supportsOrderBy: true)` | +| `[Paging(supportsCount: true)]` | `.WithPaging(supportsCount: true)` | +| `[Accepts]` | `.Accepts()` | +| `[ProducesNotFoundProblem]` | `.ProducesNotFoundProblem()` | +| `[IdempotencyKey]` | `.WithIdempotencyKey()` | + +### Examples + +**GET by id:** +```csharp +app.MapGet("api/products/{id}", + (HttpRequest request, WebApi webApi, IProductReadService service, string id, CancellationToken cancellationToken) + => webApi.GetWithResultAsync(request, (_, ct) => service.GetAsync(id.Required(), ct), cancellationToken: cancellationToken)) + .Produces().ProducesNotFoundProblem(); +``` + +**POST — create with Location header:** +```csharp +app.MapPost("api/products", + (HttpRequest request, WebApi webApi, IProductService service, CancellationToken cancellationToken) + => webApi.PostWithResultAsync(request, async (ro, ct) => + { + ro.WithLocationUri(p => new Uri($"api/products/{p.Id}", UriKind.Relative)); + return await service.CreateAsync(ro.Value, ct).ConfigureAwait(false); + }, cancellationToken: cancellationToken)) + .Accepts().ProducesCreated().WithIdempotencyKey(); +``` + +**PUT:** +```csharp +app.MapPut("api/products/{id}", + (HttpRequest request, WebApi webApi, IProductService service, string id, CancellationToken cancellationToken) + => webApi.PutWithResultAsync(request, (ro, ct) => + service.UpdateAsync(ro.Value.Adjust(p => p.Id = id), ct), cancellationToken: cancellationToken)) + .Accepts().Produces().ProducesNotFoundProblem(); +``` + +**PATCH — JSON Merge-Patch:** +```csharp +app.MapPatch("api/products/{id}", + (HttpRequest request, WebApi webApi, IProductService service, string id, CancellationToken cancellationToken) + => webApi.PatchWithResultAsync(request, + get: (_, ct) => service.GetAsync(id.Required(), ct), + put: (ro, ct) => service.UpdateAsync(ro.Value.Adjust(p => p.Id = id), ct), + cancellationToken: cancellationToken)) + .Accepts(HttpNames.MergePatchJsonMediaTypeName).Produces().ProducesNotFoundProblem(); +``` + +**DELETE:** +```csharp +app.MapDelete("api/products/{id}", + (HttpRequest request, WebApi webApi, IProductService service, string id, CancellationToken cancellationToken) + => webApi.DeleteWithResultAsync(request, (_, ct) => service.DeleteAsync(id.Required(), ct), cancellationToken: cancellationToken)) + .ProducesNoContent(); +``` + +**Query with filtering and paging:** +```csharp +app.MapGet("api/products", + (HttpRequest request, WebApi webApi, IProductReadService service, CancellationToken cancellationToken) + => webApi.GetWithResultAsync(request, (ro, ct) => service.QueryAsync(ro.QueryArgs, ro.PagingArgs, ct), cancellationToken: cancellationToken)) + .Produces().WithQuery(supportsOrderBy: true).WithPaging(supportsCount: true); +``` + +All the same rules apply as for MVC controllers: no business logic in the handler, delegate immediately to the application service, use `.Required()` on route parameters, and **take a `CancellationToken` handler parameter** (ASP.NET injects it) that is passed to the `WebApi` helper (`cancellationToken:`) and on to the service via the lambda's `ct`. + +--- + +## Do Not + +- Do not inherit from `Controller` — that pulls in View support; use `ControllerBase`. +- Do not return `ActionResult` directly — use the `WebApi` helper for consistent error translation and status-code mapping. +- Do not inject `IUnitOfWork` into controllers or endpoint handlers — it belongs in the application service. +- Do not put business logic in controllers or endpoint handlers — delegate immediately to the application service. +- Do not call `HttpClient` or adapters directly from controllers — go through the application service. +- Do not put mutating and read endpoints in one controller — split into `XxxController` (mutations, `IXxxService`) and `XxxReadController` (reads, `IXxxReadService`). +- Do not expose the CQRS split externally — give both controllers the **same** `[OpenApiTag("Xxx")]` so they surface as one OpenAPI group; do not use distinct tags/route bases per controller. +- Do not omit the `PATCH` when exposing a full `PUT` update — offer both by default; add specialized/partial update or patch endpoints only on explicit request. +- Do not add a `POST` without confirming idempotency — apply `[IdempotencyKey]` (`.WithIdempotencyKey()` for Minimal APIs) when it is idempotent (create-style POSTs almost always are); omit only when the user confirms it is not. +- Do not omit or discard the `CancellationToken` — **every** action/handler takes `CancellationToken cancellationToken = default` (MVC) or a `CancellationToken` handler parameter (Minimal API), passes it to the `WebApi` helper via `cancellationToken:`, and calls the service with the **lambda's** `ct` (`(ro, ct) => …`). Do not write `(ro, _) => _service.XxxAsync(…)` (drops the token) or call the service without a token argument. + +## Further Reading + +- [Hosts Layer Guide](https://github.com/Avanade/CoreEx/blob/main/samples/docs/hosts-layer.md) — API host composition, controller patterns, and `Program.cs` shape. +- [CoreEx.AspNetCore README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.AspNetCore/README.md) — `WebApi` helper API reference. +- [CoreEx.AspNetCore Mvc README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.AspNetCore/Mvc/README.md) — MVC `WebApi` (`IActionResult`-returning), action attributes, and controller patterns. +- [CoreEx.AspNetCore Http README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.AspNetCore/Http/README.md) — Minimal API `WebApi` (`IResult`-returning) and `RouteHandlerBuilder` extensions. diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-application-services.instructions.md b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-application-services.instructions.md new file mode 100644 index 00000000..baf88c48 --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-application-services.instructions.md @@ -0,0 +1,480 @@ +--- +applyTo: "**/Application/**/*.cs" +description: "Application service conventions: ScopedService registration, dependency injection, validation, unit of work, CQRS, policies, adapters, and Result pipelines" +tags: ["services", "application-layer", "dependency-injection", "validation", "unit-of-work", "cqrs", "policies", "adapters"] +--- + +# Application Service Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx` | `[ScopedService]`, `Runtime`, `NotFoundException`, `BusinessException`, `ValidationException`, `.ThrowIfNull()`, `.ThrowIfNullOrEmpty()`, `QueryArgs`, `PagingArgs`, `ItemsResult`, `Result`, `Result.GoAsync()`, `.ThenAs()`, `.ThenAsAsync()` | +| `CoreEx.Data` | `IUnitOfWork`, `DataResult` | +| `CoreEx.Events` | `EventData`, `EventAction` | +| `CoreEx.Validation` | `Validator`, `Validator`, `.ValidateAndThrowAsync()`, `.ValidateWithResultAsync()` | +| `CoreEx.RefData` | `ReferenceDataOrchestrator` | + +## Structure + +- Define a public interface (e.g., `IProductService`) in the Application project, typically under an `Interfaces/` sub-folder — not a hard requirement, but a clean convention that keeps the public surface of the Application layer easy to navigate. +- Implement with `[ScopedService]` attribute so it registers itself via dynamic DI — no manual registration required. +- Inject dependencies via primary constructor and guard every injected parameter with `.ThrowIfNull()`. + +```csharp +[ScopedService] +public class ProductService(IUnitOfWork unitOfWork, IProductRepository repository) : IProductService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork.ThrowIfNull(); + private readonly IProductRepository _repository = repository.ThrowIfNull(); +} +``` + +> **Do not inject validators (or mappers/policies) into the service constructor.** A `Validator` is invoked via its static `Default` singleton, so it is never a constructor parameter. +> +> ```csharp +> // ❌ Wrong — validator injected +> public class EmployeeService(IUnitOfWork unitOfWork, IEmployeeRepository repository, EmployeeValidator validator) : IEmployeeService +> +> // ✅ Correct — only the unit of work and repository (validator called via EmployeeValidator.Default) +> public class EmployeeService(IUnitOfWork unitOfWork, IEmployeeRepository repository) : IEmployeeService +> ``` + +## Service Operations — Confirm Scope + +Before generating a service, confirm which operations it should expose — never silently assume the full set. + +> **Agent instruction:** +> - When asked to create a service **without** specifying the operations, confirm the standard CRUD set with the user — **Get**, **Create**, **Update**, **Delete** — presenting each as **default-selected** so the user can deselect any that are not wanted. +> - Even when the user asks for "CRUD" (or "the usual"), still confirm the four operations (Get / Create / Update / Delete, each default-selected) rather than assuming all four — they may want only a subset. +> - **Never add a Query (collection/search) operation unless it is explicitly requested.** Querying needs deliberate, additional design — `QueryArgsConfig` filter/order fields, paging, and a purpose-built read shape (see [CQRS — Read Services](#cqrs--read-services)) — so it must not be inferred from a generic "create a service"/"CRUD" request. If querying is asked for, gather those specifics first. + +## Guard Clauses + +`.ThrowIfNull()` and `.ThrowIfNullOrEmpty()` **return the guarded value** when the check passes, so they can be used inline at the point of first use rather than as separate pre-checks. This keeps code tight without sacrificing safety: + +```csharp +// Constructor injection — the assignment is the first use; guard inline +private readonly IProductRepository _repository = repository.ThrowIfNull(); + +// Inline at point of first use in a method body +var current = await _repository.GetAsync(product.Id.ThrowIfNullOrEmpty(), cancellationToken).ConfigureAwait(false); + +// Guards chain — each returns the value if it passes, so further checks can follow +public BasketStatus Status { get; private set => field = value.ThrowIfNull().ThrowIfInactive(); } +``` + +Use a top-of-method pre-check (non-inline) only when the value is not immediately consumed: + +```csharp +public async Task UpdateAsync(Product product, CancellationToken cancellationToken = default) +{ + product.ThrowIfNull(); // checked here; not passed anywhere yet + await ProductValidator.Default.ValidateAndThrowAsync(product, cancellationToken).ConfigureAwait(false); + var current = await _repository.GetAsync(product.Id.ThrowIfNullOrEmpty(), cancellationToken).ConfigureAwait(false); + // ... +} +``` + +## Validation + +Validators live in `Application/Validators/` and are **not registered in DI** — they are not injected into services (see [DI Registration Principle](#di-registration-principle) below). Choose the base class based on whether the validator needs injected dependencies: + +**`Validator`** — use when no constructor injection is required. Exposes a static `Default` singleton; always call via the singleton: + +```csharp +public class ProductValidator : Validator +{ + public ProductValidator() + { + Property(p => p.Sku).Mandatory().MaximumLength(50); + Property(p => p.SubCategory).Mandatory().IsValid(); // typed ref-data nav property, not SubCategoryCode + Property(p => p.Price).PrecisionScale(null, 2).GreaterThanOrEqualTo(0, _ => "zero"); + } +} + +// Call via Default singleton — never use new ProductValidator() at the call site: +await ProductValidator.Default.ValidateAndThrowAsync(product, cancellationToken); +``` + +**Choose the invocation that matches the flow — never bare `ValidateAsync`:** + +| Flow | Method | Behaviour | +|---|---|---| +| Exception style (non-ROP) | `ValidateAndThrowAsync(value)` | Throws `ValidationException` on failure — stops execution | +| `Result` pipeline (ROP) | `ValidateWithResultAsync(value)` | Returns a `Result` to compose / short-circuit on | + +`ValidateAsync(value)` merely **returns the validation result without throwing** — calling it and ignoring the return *swallows the errors and continues*. Do **not** use it for fail-fast validation. In a non-ROP service use `ValidateAndThrowAsync`; in a `Result` pipeline use `ValidateWithResultAsync`. + +```csharp +// ❌ Wrong — does not throw; the failure is swallowed and execution continues +await EmployeeValidator.Default.ValidateAsync(employee, cancellationToken).ConfigureAwait(false); + +// ✅ Correct (non-ROP) — throws ValidationException on failure +await EmployeeValidator.Default.ValidateAndThrowAsync(employee, cancellationToken).ConfigureAwait(false); +``` + +**`Validator`** — use when constructor injection is required (e.g., a repository for async I/O). No singleton; instantiate directly at the call site using dependencies already in scope in the service: + +```csharp +public class MovementRequestValidator : Validator +{ + private readonly IProductRepository _repository; + + public MovementRequestValidator(IProductRepository repository) + { + _repository = repository.ThrowIfNull(); + Property(x => x.Id).Mandatory().MaximumLength(50); + // ... declarative rules + } + + protected async override Task OnValidateAsync(ValidationContext context, CancellationToken cancellationToken) + { + if (context.HasErrors) return; // fail fast — skip I/O if declarative phase found errors + + var ids = context.Value.Products!.Select(kvp => kvp.Key).ToArray(); + var products = await _repository.GetForReservationAsync(ids, cancellationToken).ConfigureAwait(false); + + await context.ValidateFurtherAsync(c => c + .HasProperty(x => x.Products, c => c.Dictionary(c => c + .WithKeyValidator("Product", k => k + .NotFound().WhenValue(v => !products.ContainsKey(v))))), + cancellationToken).ConfigureAwait(false); + } +} + +// Instantiate directly — _repository is already injected into the service: +await new MovementRequestValidator(_repository).ValidateAndThrowAsync(request, cancellationToken); +``` + +Both phases apply to both base classes. For `Result` pipelines, use `ValidateWithResultAsync` instead of `ValidateAndThrowAsync`: + +```csharp +var result = await Result.GoAsync(() => MyValidator.Default.ValidateWithResultAsync(value, cancellationToken)); +if (result.IsFailure) return result.AsResult(); +``` + +## Not Found Handling + +After loading an entity, throw immediately if it does not exist: + +```csharp +var current = await _repository.GetAsync(id, cancellationToken).ConfigureAwait(false); +NotFoundException.ThrowIfDefault(current); +``` + +## Business Rule Exceptions + +Use `BusinessException` for domain rule violations that are the caller's fault but are not validation errors: + +```csharp +if (!product.IsInactive) + throw new BusinessException("A product must first be deactivated before it can be deleted."); +``` + +`BusinessException` (and all CoreEx exceptions that extend `ExtendedException`) support optional fluent extension methods that enrich the error with machine-readable context. All methods return the exception so they can be chained directly on the `throw` expression: + +| Method | Purpose | +|---|---| +| `.WithErrorCode(string)` | Adds a machine-readable code the caller can key on (e.g. `"product-not-inactive"`) | +| `.WithKey(object)` | Attaches the entity key — surfaces in the problem-details response under `key` | +| `.WithDetail(string)` | Adds extended human-readable detail beyond the main message | +| `.WithStatusCode(HttpStatusCode)` | Overrides the default HTTP status code (use sparingly) | +| `.WithExtension(string, object)` | Adds arbitrary key/value metadata to `extensions` in the problem-details response | +| `.AsTransient(TimeSpan?)` | Marks the error as transient so retry infrastructure knows it is safe to retry | + +```csharp +// Minimal — message only +if (!product.IsInactive) + throw new BusinessException("A product must first be deactivated before it can be deleted."); + +// With machine-readable error code and entity key +if (!product.IsInactive) + throw new BusinessException("A product must first be deactivated before it can be deleted.") + .WithErrorCode("product-not-inactive") + .WithKey(product.Id); + +// With additional detail +if (basket.HasExpiredItems) + throw new BusinessException("Basket cannot be checked out.") + .WithErrorCode("basket-has-expired-items") + .WithDetail("One or more items in the basket have expired and must be removed before checkout."); +``` + +## Assigning the identifier on Create + +The **service** assigns the new identifier on `Create` — the database does **not** generate it (the migration scaffold's value-generation default is dropped unless a key is explicitly DB-generated; see the tooling guidance). Set it from the ambient `Runtime` **after validation, before the transaction**, alongside any other server-controlled fields: + +```csharp +public async Task CreateAsync(Product product, CancellationToken cancellationToken = default) +{ + product.ThrowIfNull(); + await ProductValidator.Default.ValidateAndThrowAsync(product, cancellationToken).ConfigureAwait(false); + + product.Id = Runtime.NewId(); // service-assigned identity — never left to the caller or the DB + product.CategoryCode = product.SubCategory!.CategoryCode; // derive any other server-set fields here + product.IsInactive = true; + + return await _unitOfWork.TransactionAsync(async ct => + { + var dr = await _repository.CreateAsync(product, ct).ConfigureAwait(false); + return dr.WhereMutated(v => + _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Created))); + }, cancellationToken).ConfigureAwait(false); +} +``` + +- **Match the generator to the identifier type:** `string` key → `Runtime.NewId()`; `Guid` key → `Runtime.NewGuid()`. Never use `Guid.NewGuid()` / `Guid.NewGuid().ToString()` directly — always go through `Runtime` so the clock/GUID source stays test-controllable. +- **Always assign on Create** so the value is deterministic and present before the event is published — don't rely on the caller-supplied `Id` and don't defer to the database. +- **Exception — DB-generated keys only:** if a key is explicitly an identity/sequence column (the rare, explicitly-requested case), the database assigns it; the service does **not** set `Id` and reads it back from the create result instead. + +## Unit of Work and Events + +Wrap all side-effectful database operations in `_unitOfWork.TransactionAsync(...)`. Both the database write and the outbox event publication are committed atomically inside this scope — events are only dispatched if the transaction commits successfully. + +```csharp +return await _unitOfWork.TransactionAsync(async ct => +{ + var dr = await _repository.CreateAsync(product, ct).ConfigureAwait(false); + return dr.WhereMutated(v => + _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Created))); +}, cancellationToken).ConfigureAwait(false); +``` + +**When eventing is enabled, every mutating operation must publish its event inside the transaction.** `Create`, `Update`, and `Delete` each add their event (`EventAction.Created` / `Updated` / `Deleted`) via `_unitOfWork.Events.Add(...)` within the `TransactionAsync` scope — a transaction that writes but adds no event is a bug. (Eventing is enabled when the solution was scaffolded with it, or whenever the domain publishes domain events; if a domain is genuinely event-free, omit it.) + +```csharp +// ❌ Wrong — writes inside the transaction but never adds the event +return await _unitOfWork.TransactionAsync(async ct => +{ + var result = await _repository.CreateAsync(employee, ct).ConfigureAwait(false); + return result.Value!; +}, cancellationToken).ConfigureAwait(false); + +// ✅ Correct — event added within the same transaction, only on mutation +return await _unitOfWork.TransactionAsync(async ct => +{ + var dr = await _repository.CreateAsync(employee, ct).ConfigureAwait(false); + return dr.WhereMutated(v => + _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Created))); +}, cancellationToken).ConfigureAwait(false); +``` + +- `WhereMutated(action)` — executes `action` only when the data result records a mutation; add the event inside this callback. **Mind the overload:** `DataResult` (from `Create`/`Update`) carries the value, so use `WhereMutated(v => ...)`; `DataResult` (from `Delete`) has **no value**, so use the parameterless `WhereMutated(() => ...)` — not `WhereMutated(_ => ...)`. +- `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(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(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. + +For delete the `DataResult` has no value, so use the parameterless `WhereMutated(() => ...)` and carry the identity via `.WithKey(id)`: + +```csharp +public async Task DeleteAsync(string id, CancellationToken cancellationToken = default) +{ + await _unitOfWork.TransactionAsync(async ct => + { + var dr = await _repository.DeleteAsync(id.ThrowIfNullOrEmpty(), ct).ConfigureAwait(false); + + // ❌ Wrong — fabricates a throwaway value to carry the id; attaches a (near-empty) body, + // adds a version suffix delete must not have, and puts the id in the body, not the key. + dr.WhereMutated(() => + _unitOfWork.Events.Add(EventData.CreateEventWith(new Contracts.Employee { Id = id }, EventAction.Deleted))); + + // ✅ Correct — no value; type + action + key only. + dr.WhereMutated(() => // () — no value on a delete DataResult + _unitOfWork.Events.Add( + EventData.CreateEvent(EventAction.Deleted).WithKey(id))); + }, cancellationToken).ConfigureAwait(false); +} +``` + +## Result<T> Pipeline Style + +Using `Result` chains is a developer choice — it is not restricted to DDD aggregate services. It can be applied to any service method where explicit, composable failure propagation is preferred over exceptions. Compose with `Result.GoAsync`, `.ThenAs`, `.ThenAsAsync`. The unit of work is still `TransactionAsync`: + +```csharp +public Task> CreateAsync(string customerId, CancellationToken cancellationToken = default) +{ + var aggregate = Domain.Basket.CreateNew(customerId.ThrowIfNullOrEmpty()); + + return _unitOfWork.TransactionAsync(async ct => + { + var br = await _repository.CreateAsync(aggregate, ct).ConfigureAwait(false); + return br.ThenAs(b => + { + var contract = BasketMapper.Map(b); + _unitOfWork.Events.Add(EventData.CreateEventWith(contract, EventAction.Created)); + return contract; + }); + }, cancellationToken); +} +``` + +For multi-step orchestration with early exit on the first failure: + +```csharp +var pr = await Result.GoAsync(() => SomeValidator.Default.ValidateWithResultAsync(input, cancellationToken)) + .ThenAsAsync(v => _someAdapter.EnsureExistsAsync(v.Id!, cancellationToken)); + +if (pr.IsFailure) + return pr.AsResult(); +``` + +#### Operator reference and the `As` / `Async` naming convention + +The pipeline operators come in **families**, each with consistent modifier suffixes. **Read the suffix to know what an operator does:** + +- **`As`** — the operation **changes the result type** (`Result` → `Result`, `Result` → `Result`, or `Result` → `Result`). The non-`As` form keeps the same type. This is **by design**: you must explicitly opt into a type change, so a `T` flowing through unchanged uses `Then`, while producing a different type uses `ThenAs`. If the compiler complains a delegate returns the "wrong" type, you almost certainly want the `As` variant. +- **`Async`** — the supplied delegate is asynchronous (returns a `Task`). +- **`AsAsync`** — both: an async delegate that also changes the type. + +| Family | Runs the delegate when… | Same-type / type-changing | +|---|---|---| +| `Then` | result is **success** | `Then` / `ThenAs` | +| `When` | success **and** a condition holds | `When` / `WhenAs` | +| `Any` | **always** (success or failure) | `Any` / `AnyAs` | +| `OnFailure` | result is **failure** | `OnFailure` / `OnFailureAs` | +| `Match` | branches on success vs failure, returning a value | `Match` / `MatchAs` | + +Each has `Async` and `AsAsync` variants too (e.g. `ThenAsync`, `ThenAsAsync`). Start a pipeline with `Result.Go(...)` / `Result.GoAsync(...)`; also available: `Bind`, `Combine`, and `.AsResult()` to drop a `Result` to a `Result`. + +Failure factories (return a failed result of the matching type): `Result.ValidationError(...)`, `NotFoundError(...)`, `BusinessError(...)`, `ConflictError(...)`, `ConcurrencyError(...)`, `DuplicateError(...)`, `AuthenticationError(...)`, `AuthorizationError(...)`, `TransientError(...)`. Success factories: `Result.Success`, `Result.Ok(value)`. See the [CoreEx Results README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx/Results/README.md) for the full set. + +## CQRS — Read Services + +Split a domain's service operations **by mutation** — this is the convention: + +- **Mutating** operations — `Create`, `Update`, `Delete`, and any other state-changing operation — live in **`XxxService`** (`IXxxService`). These own validation, the unit of work, and event publication. `XxxService` **also** exposes a primary by-id `GetAsync(id)` to support its own mutation flows (the PATCH pre-fetch, fetch-then-update, concurrency/not-found checks). +- **Query and read-model** operations — `QueryAsync` (collections/search) and other purpose-built read shapes — live in **`XxxReadService`** (`IXxxReadService`), which also exposes the by-id `GetAsync` for the read API. + +This is the surface expression of CQRS: the write model (mutations + events) and the read model (queries returning purpose-built shapes) are designed and scaled independently. Both interfaces live in `Interfaces/` side by side (`IProductService.cs`, `IProductReadService.cs`); both implementations live together in the service folder. + +A primary by-id `GetAsync` therefore appears on **both** services — this is intentional. Each is a single line delegating to the shared `IXxxRepository.GetAsync`, so there is no real logic duplication; having `XxxService` own a `GetAsync` keeps the mutating controller dependent on **one** service (no need to also resolve `IXxxReadService` just for the PATCH fetch). The meaningful divergence — queries and read-optimized shapes — stays exclusive to `XxxReadService`. + +```csharp +[ScopedService] +public class ProductReadService(IProductRepository repository) : IProductReadService +{ + private readonly IProductRepository _repository = repository.ThrowIfNull(); + + public Task GetAsync(string id, CancellationToken cancellationToken = default) => _repository.GetAsync(id, cancellationToken); + public Task> QueryAsync(QueryArgs? query, PagingArgs? paging, CancellationToken cancellationToken = default) + => _repository.QueryAsync(query, paging, cancellationToken); +} +``` + +**The repository stays singular — CQRS is a service-layer split, not a data-layer one.** Both `XxxService` and `XxxReadService` inject the **same** `IXxxRepository` when they share a data source (e.g. the one SQL database) — do **not** split the repository to mirror the services. Only when a specific operation targets a **different** data source (e.g. a read served from a separate store or search index) is an additional repository introduced; the owning service injects and calls the appropriate repository per operation. + +## Anti-Corruption Layer (Adapters) + +When a service needs to call another domain's API, inject an adapter interface (e.g., `IProductAdapter`) rather than calling `HttpClient` directly. Implement the adapter in the Infrastructure layer using a typed HTTP client. The interface surface should be domain-idiomatic — not a mirror of the remote API. + +Adapter interfaces live in `Application/Adapters/` (one interface per external domain). The Infrastructure implementation lives in `Infrastructure/Adapters/`. + +```csharp +// Application/Adapters/IProductAdapter.cs — interface only (domain-idiomatic, not a mirror of the remote API) +public interface IProductAdapter +{ + Task> GetAsync(string id, CancellationToken cancellationToken = default); + Task ReserveInventoryAsync(Domain.Basket basket, CancellationToken cancellationToken = default); + Task CancelReservationAsync(Domain.Basket basket, CancellationToken cancellationToken = default); +} + +// Infrastructure layer — implementation +[ScopedService] +public class ProductAdapter(ProductsHttpClient httpClient) : IProductAdapter { ... } +``` + +A second adapter interface (`IXxxSyncAdapter`) handles **event-driven data replication** — receiving published events from another domain and maintaining a local eventually-consistent copy in the consuming domain's own store. + +## Policies + +Policies (`Application/Policies/`) encapsulate **domain-level guard logic** that requires I/O (adapter or repository calls). They provide a named, independently testable home for rules that depend on external state and cannot be expressed in a validator alone (synchronous) or enforced directly in the domain model (no async I/O). A policy can be called from any point in service orchestration where the condition needs to be verified. + +Policies are **not registered in DI** — they are instantiated directly at the call site using dependencies already injected into the calling service (see [DI Registration Principle](#di-registration-principle) below). + +Policies return `Result` or `Result` and compose naturally into `Result` pipelines via `.GoAsync()` / `.ThenAsAsync()`: + +```csharp +// Application/Policies/ProductPolicy.cs +public class ProductPolicy(IProductAdapter productAdapter) +{ + private readonly IProductAdapter _productAdapter = productAdapter.ThrowIfNull(); + + public Task> 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.")) + : r); +} + +// In the calling service — _productAdapter is already injected into the service: +var result = await new ProductPolicy(_productAdapter).EnsureExistsAsync(productId, cancellationToken); +``` + +## Application-Level Mapping + +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. + +Use `Mapper` (uni-directional). Mappers are **not registered in DI** — call them via the static `Map()` method directly at the point of use (see [DI Registration Principle](#di-registration-principle) below): + +```csharp +// Application/Mapping/BasketMapper.cs +public class BasketMapper : Mapper +{ + protected override Contracts.Basket OnMap(Domain.Basket source) => new() + { + Id = source.Id, + StatusCode = source.Status, + Items = [.. source.Items.Select(i => BasketItemMapper.Map(i))] + }; +} + +// Call via static Map() — no injection, no new(): +var contract = BasketMapper.Map(aggregate); +``` + +Infrastructure-level mapping (Contract ↔ Persistence model) uses `BiDirectionMapper` and lives in `Infrastructure/Mapping/`. Do not conflate the two layers. + +## DI Registration Principle + +Only register a type in DI when there is a current, concrete intent to mock or replace it. Applying YAGNI, the following Application-layer types are **not** DI-registered — they are called or instantiated directly at the point of use: + +| Type | How to use | +|---|---| +| `Validator` | Call via static `Default` singleton: `MyValidator.Default.ValidateAndThrowAsync(...)` | +| `Validator` | Instantiate directly with already-injected deps: `new MyValidator(_repo).ValidateAndThrowAsync(...)` | +| `Mapper` | Call via static `Map()` method: `MyMapper.Map(source)` | +| Policy classes | Instantiate directly with already-injected deps: `new MyPolicy(_adapter).EnsureExistsAsync(...)` | + +Keeping these out of DI avoids bloating service constructors with dependencies that are not realistic substitution points, and defers that complexity until there is a real need for it. + +## ConfigureAwait + +Always call `.ConfigureAwait(false)` on every `await` inside service and repository methods. + +## Do Not + +- Do not publish events outside of `_unitOfWork.TransactionAsync(...)` — events must be committed atomically with the database write. +- Do not put **query/collection or read-model** operations in `XxxService`, nor mutating operations in `XxxReadService` — queries belong in `XxxReadService`. (A primary by-id `GetAsync` legitimately appears on **both**: the write service uses it to support its own mutations, e.g. the PATCH pre-get.) +- Do not split the repository to mirror the CQRS services — both share one `IXxxRepository` per data source; add another repository only for a genuinely different data source. +- Do not call `HttpClient` directly from services — always go through an adapter interface. +- Do not reference Infrastructure assemblies from the Application layer — all persistence and transport concerns are reached through interfaces. +- Do not implement rules in `OnValidateAsync` that require I/O without first guarding with `if (context.HasErrors) return;`. +- Do not add business logic to controllers — services own all use-case orchestration. +- Do not register Validators, Mappers, or Policies in DI or inject them into service constructors — call or instantiate them directly at the point of use (YAGNI: refactor to DI only when there is a real need to mock or replace them). +- Do not use `new ProductValidator()` at the call site when `Validator` provides a `Default` singleton — use `ProductValidator.Default`. +- Do not inject a validator into a service constructor — a `Validator` is invoked via its `Default` singleton, never as a constructor dependency. +- Do not call bare `ValidateAsync(...)` for fail-fast validation — it returns the result without throwing, silently swallowing errors. Use `ValidateAndThrowAsync` (non-ROP) or `ValidateWithResultAsync` (ROP). +- Do not perform a mutating `TransactionAsync` without adding its event (`_unitOfWork.Events.Add(...)`) when eventing is enabled — the write and the event must be committed together. +- Do not use `WhereMutated(_ => ...)` on a delete — `DataResult` (delete) has no value; use the parameterless `WhereMutated(() => ...)`. The value-carrying `WhereMutated(v => ...)` is only for `DataResult` (create/update). +- Do not reach for a non-`As` Result operator when the delegate changes the result type — use the `As` variant (e.g. `ThenAs`/`ThenAsAsync`); the `As` suffix exists to make the type change explicit. +- Do not generate service operations the user did not confirm — confirm the CRUD set (Get/Create/Update/Delete, each default-selected) when operations are unspecified, and never add a Query operation without an explicit request. + +## Further Reading + +- [Application Layer Guide](https://github.com/Avanade/CoreEx/blob/main/samples/docs/application-layer.md) — full walkthrough of services, validators, adapters, policies, mapping, and the unit-of-work pattern. +- [Pattern Catalog](https://github.com/Avanade/CoreEx/blob/main/samples/docs/patterns.md) — CQRS, Service, Unit of Work, Validator, Policy, Adapter, and Event patterns with cross-links. +- [Layer Dependencies](https://github.com/Avanade/CoreEx/blob/main/samples/docs/layers.md) — layer dependency rules: Application depends inward only on Contracts and its own interfaces. +- [CoreEx.Validation README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Validation/README.md) — `Validator`, rule set, `OnValidateAsync`, and `ValidateFurtherAsync`. +- [CoreEx README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx/README.md) — `IUnitOfWork`, `Result`, `[ScopedService]`, and CoreEx exception types. +- [CoreEx Results README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx/Results/README.md) — `Result` type, pipeline operators (`.GoAsync`, `.ThenAs`, `.ThenAsAsync`), and error propagation semantics. diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-contracts.instructions.md b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-contracts.instructions.md new file mode 100644 index 00000000..b2891d69 --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-contracts.instructions.md @@ -0,0 +1,410 @@ +--- +applyTo: "**/Contracts/**/*.cs" +description: "Contract (DTO) conventions: source generation, marker attributes, reference data, ETag, and ChangeLog support" +tags: ["contracts", "dto", "source-generation", "reference-data", "etag"] +--- + +# Contract (DTO) Conventions + +## File Placement + +Contracts live **flat in the root of the `*.Contracts` project** — both hand-authored (`Product.cs`) and generated (`Product.g.cs`) files. **Do not create sub-folders** such as `Entities/`, `Models/`, or `RefData/` to group them; the samples place every contract at the project root regardless of whether it is a root entity, subordinate, request/response DTO, or reference-data type. Only introduce a sub-folder if the **user explicitly asks** for one. The same flat-root convention applies to the matching files in the other layers (validators, services, repositories, mappers) unless their instructions state otherwise. + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx` | `[Contract]`, `IIdentifier`, `ICompositeKey`, `IETag`, `IChangeLog`, `ChangeLog`, `[ReadOnly]`, `[Localization]`; includes the `CoreEx.Generator` Roslyn source generator — no separate package reference required | +| `CoreEx.RefData` | `ReferenceData`, `ReferenceDataCollection`, `[ReferenceData]`, `[ReferenceData]`, `ReferenceDataSortOrder` | + +```xml + + + + +``` + +## Root vs. Subordinate (contract or entity) + +Note that the terms Contract and Entity are interchangeable in this context. The conventions below apply to all DTOs that represent a resource, whether they are persisted entities or plain request/response models. + +If the user specifies that it is Reference Data then treat as defined by [Reference Data Contracts](#reference-data-contracts). + +Before generating any contract, determine whether it is a **root** or a **subordinate** contract. +Always ask the user if this is not explicit in their request. + +| Concern | Root | Subordinate | +|---|---|---| +| `IIdentifier` | ✅ Always (unless explicitly requested otherwise). The user may also specify a different type | ❌ Omit | +| `IETag` | ✅ Always (unless explicitly requested otherwise) | ❌ Omit | +| `IChangeLog` | ✅ When audit trail is needed (ask) | ❌ Omit | +| `[ReadOnly(true)]` on `Id`, `ETag`, `ChangeLog` | ✅ Required | N/A | +| `[Contract]` + `partial` | ✅ Always for generated members | Only if generated members are needed | + +**Root** — owns its own identity, is persisted independently, and is retrieved/mutated via its own API endpoint (e.g. `Product`, `Order`, `Person`). + +**Subordinate** — a child, line-item, value object, or request/response DTO that is generally accessed through a root (e.g. `OrderLine`, `Address`, `BasketItemAddRequest`). + +> **Agent instruction:** When asked to create a contract and the category is not explicit, +> ask: *"Is `{Name}` a root contract (it has its own identity) or a subordinate contract (accessed only through a parent)?"* +> Do not assume root. Do not generate `IIdentifier`, `IETag`, or `IChangeLog` until confirmed. + +> **Identifier type — do not substitute.** The identifier type is `IIdentifier` by **default**. Use `string?` unless the user **explicitly** states a different type (e.g. "use a `Guid` id", "the key is an `int`"). **Never** silently change it — in particular, do **not** default to `Guid` because it is common elsewhere. The chosen type is authoritative and must flow through unchanged to every downstream artefact: the `ref-data.yaml` `idType`, the persistence model, and the database primary-key column type (a `string?` id → `NVARCHAR(50)`/`VARCHAR(50)`, **not** `UNIQUEIDENTIFIER`/`UUID`). If a plan or prior step states one type, the implementation must match it exactly — flag any discrepancy rather than resolving it by changing the type. + +## Unified API and Messaging Surface + +The same contract type is used for both the HTTP API response **and** the event message payload. A `Product` returned from `GET /api/products/{id}` is the same `Contracts.Product` type published as a `product.created` event body. Do not split a resource into separate API and event DTOs. + +When a domain **consumes** events from another domain, declare a local internal representation (e.g., `Application\Adapters\Products\Product`) rather than taking a dependency on the publishing domain's Contracts assembly. Keep the shape consistent with the published contract, but own it locally to preserve the anti-corruption boundary. + +## Source Generation + +Mark entity contract classes with the `[Contract]` attribute and declare them `partial`. The `CoreEx` package ships with a bundled Roslyn source generator ([`CoreEx.Generator`](https://github.com/Avanade/CoreEx/tree/main/gen/CoreEx.Generator)) that activates automatically — no extra package reference is needed. It emits serialization, equality, and change-tracking code into a paired `*.g.cs` file at compile time. Never manually implement those generated members. + +> Both `[Contract]` and `[ReferenceData]` trigger this Roslyn source generator. For reference data types the flow is two-stage: the `*.CodeGen` project (OnRamp/Handlebars) first generates the class decorated with `[ReferenceData]`, then the Roslyn generator processes that attribute at compile time to emit additional members -- see [Reference Data Contracts](#reference-data-contracts). + +```csharp +[Contract] +public partial class Product : ProductBase, IETag, IChangeLog { } +``` + +### How the generator runs (do not chase phantom build problems) + +The Roslyn source generator runs **automatically as part of compilation** — every `dotnet build` and every IDE design-time build. You do **not** trigger it manually, and there is no separate step to "make it generate". + +Common misconceptions to avoid: +- **Missing generated members before a build is normal — not an error to fix by hand.** If a `partial` property or class has no visible implementation, it is because the project simply hasn't been built yet. Build it; do **not** hand-author the generated partial implementation or create the `.g.cs` file to "unblock" things. +- **Other compilation errors do not stop the generator.** Roslyn runs source generators on the parsed compilation regardless of unrelated errors; there is **no build-ordering requirement and no "circular dependency"** whereby errors elsewhere prevent generation. Do not invent such a dependency — fix the actual reported errors and rebuild. +- **If a member is still missing after a clean build, the contract declaration is malformed**, not the build process. Check that the class has `[Contract]` and is `partial`, the property is `partial`, and the attributes are correct (see below) — then rebuild. Never substitute a hand-written implementation for the generated one. + +Plain value-object or request contracts that do not need generated members (equality, cloning, etc.) can be declared as ordinary, non-`partial` classes without `[Contract]`: + +```csharp +// No [Contract] needed — no generated members required. +public class BasketItemAddRequest +{ + public string? ProductId { get; set; } + public decimal Quantity { get; set; } +} +``` + +## Interfaces + +Implement the appropriate CoreEx marker interfaces depending on the entity's behavior: + +| Interface | When to use | +|---|---| +| `IIdentifier` | Entity has a single primary key | +| `ICompositeKey` | Entity has a multi-part key | +| `IETag` | Entity participates in optimistic concurrency / IF-MATCH | +| `IChangeLog` | Entity records created/updated audit metadata | + +All three are typically combined on mutable entities: + +```csharp +[Contract] +public partial class Product : ProductBase, IETag, IChangeLog +{ + [ReadOnly(true)] + public string? Id { get; set; } + + [ReadOnly(true)] + public string? ETag { get; set; } + + [ReadOnly(true)] + public ChangeLog? ChangeLog { get; set; } +} +``` + +## Documentation Comments + +Give **every contract property a ``** (and the contract class itself). The standard `Id`/`ETag`/`ChangeLog` members — which implement `IIdentifier`/`IETag`/`IChangeLog` — may use `` instead. See [XML Documentation Comments](#) in the conventions. (Common mistake: leaving the contract properties undocumented — they each need a summary.) + +```csharp +/// Represents the Employee contract. +[Contract] +public partial class Employee : IIdentifier, IETag, IChangeLog +{ + /// + [ReadOnly(true)] + public string? Id { get; set; } + + /// Gets or sets the employee's first name. + public string? FirstName { get; set; } + + /// Gets or sets the employee's gender (reference data). + [ReferenceData] + public partial string? GenderCode { get; set; } + + /// + [ReadOnly(true)] + public string? ETag { get; set; } + + /// + [ReadOnly(true)] + public ChangeLog? ChangeLog { get; set; } +} +``` + +## ReadOnly Properties + +Decorate server-assigned properties with `[ReadOnly(true)]` to signal that clients cannot supply them. Common examples: `Id`, `ETag`, `ChangeLog`, `CategoryCode` (derived from SubCategory). NSwag/OpenAPI automatically excludes these from inbound request schemas. + +## Reference Data Properties + +Use `[ReferenceData]` on code properties that back a reference data relationship. Two conditions must both be met for the source generator to emit the navigation accessor: + +1. The **class** is decorated with `[Contract]` and declared `partial`. +2. The **property** is declared `partial`. + +```csharp +[Contract] +public partial class ProductBase : IIdentifier +{ + [ReferenceData] + [Localization("Sub-category")] + public partial string? SubCategoryCode { get; set; } + + [ReferenceData] + [Localization("Unit-of-measure")] + public partial string? UnitOfMeasureCode { get; set; } +} +``` + +The generated code exposes a strongly-typed `SubCategory` property alongside the raw `SubCategoryCode` string. If either `[Contract]` or `partial` is missing from the class, the navigation property will not be generated and the code will not compile correctly. + +> **Only `[ReferenceData]` properties are `partial` — not every property in the class.** The class is `partial` (it has a generated `.g.cs` part), but among its **properties** *only* the `[ReferenceData]`-decorated code properties are declared `partial` (the generator supplies their implementation part — the typed navigation). Every other property is an **ordinary auto-property** with no `partial` keyword. Do **not** assume that because the class is `partial` its properties must be too — marking a plain property `partial` with no generated implementation fails to compile with **CS9248** *("partial property … must have an implementation part")*. +> +> ```csharp +> public partial class Employee : IIdentifier +> { +> public string? FirstName { get; set; } // ✅ plain — NOT partial +> public decimal Salary { get; set; } // ✅ plain — NOT partial +> +> [ReferenceData] +> public partial string? GenderCode { get; set; } // ✅ partial — generator emits the `Gender` navigation +> +> // public partial string? FirstName { get; set; } // ❌ CS9248 — no generated implementation for a non-ref-data property +> } +> ``` + +> **Agent instruction — property type resolution:** When generating contract properties, apply this hierarchy for every property first, then generate the complete contract in a single pass. Do not ask about individual properties mid-list. +> +> **Step 1 — Honour explicit types** +> If the user specifies a CLR type (e.g. `string Gender`, `int Rating`), use it as-is. No lookup. No question. +> +> **Step 2 — Infer obvious primitives by name pattern (silent — no question)** +> +> | Name pattern | Inferred type | +> |---|---| +> | `First*`, `Last*`, `*Name`, `*Description`, `*Text`, `*Notes`, `*Comment`, `Sku`, `Email`, `Phone`, `Url` | `string?` | +> | `Is*`, `Has*`, `Can*`, `Allow*` | `bool` | +> | `*Price`, `*Amount`, `*Cost`, `*Rate`, `*Total`, `*Balance`, `*Percentage` | `decimal` | +> | `*Date`, `*On`, `*At`, `Created*`, `Updated*`, `Deleted*` | `DateTime?` | +> | `*Quantity`, `*Qty` | `decimal` | +> | `*Count`, `*Number`, `*Sequence` | `int` | +> +> **Step 3 — Check `ref-data.yaml` for any remaining untyped noun properties** +> Search `entities:` in `tools/[domain].CodeGen/ref-data.yaml` for each unresolved property name: +> - **Found** — wire up silently as `[ReferenceData]` `public partial string? {Name}Code { get; set; }`. No question. +> - **Not found** — add to the candidates list for Step 4. +> +> **Step 4 — Ask once, for all remaining candidates, at the end** +> After processing every property, if any candidates remain unresolved, ask a single question: +> *"The following properties could be reference data types — which should I add to `ref-data.yaml`? (select any, or none to treat as plain properties): `Gender`, `Status`, `Priority`"* +> Never ask per-property. Never interrupt before all properties have been analysed. +> +> **Step 5 — Single batch edit and one CodeGen run** +> For all properties the user confirms as reference data: +> 1. Add **all** confirmed types to `ref-data.yaml` under `entities:` in a **single edit**. +> 2. Offer to run `dotnet run` from the `*.CodeGen` directory **once** to generate all of them in one pass. +> 3. On success, summarise the generated artefacts; on failure relay the **complete output verbatim** then fix `ref-data.yaml` and offer to re-run. Do not create `.g.cs` files manually. +> +> **Step 6 — Generate the complete contract in one pass** +> Once all types are resolved and CodeGen has run (if needed), emit the full contract: +> - Reference data properties: `[ReferenceData]` `public partial string? {Name}Code { get; set; }` — the property name is always `{Name}Code`; the navigation property `{Name}` is Roslyn-generated and must not be hand-authored. +> - Plain properties: use the inferred or explicit CLR type. +> - Apply `[Localization("Human label")]` **only** where the auto-derived label would be wrong/undesired (e.g. `SubCategoryCode` → `"Sub-category"`). Do **not** add it when the value would equal the default (e.g. `[Localization("Salary")]` on `Salary` is redundant). +> - For any property confirmed as plain (Step 4, user selected none or a subset), use `string?` as the default if no better type can be inferred. + +## Localization Labels + +CoreEx automatically derives a human-friendly label from the property name (the PascalCase name is split into words — e.g. `DateOfBirth` → "Date Of Birth"). **Only** decorate a property with `[Localization("Human label")]` when that default would be wrong or undesired — typically to drop a `Code` suffix or hyphenate (e.g. `SubCategoryCode` → "Sub-category"). + +```csharp +// ✅ Needed — default "Sub Category Code" is undesired +[Localization("Sub-category")] +public partial string? SubCategoryCode { get; set; } +// Validation error: "Sub-category is required." (not "Sub Category Code is required.") + +// ❌ Redundant — the default already yields "Salary"; do not annotate +[Localization("Salary")] +public decimal Salary { get; set; } +``` + +Omit `[Localization]` whenever the attribute value would equal the auto-derived label — it is noise. Add it only to change the label. + +## Inheritance for Shared Fields + +Extract shared fields into an abstract `XxxBase` class when multiple contracts share the same core properties. This keeps validation and mapping code DRY. + +A projection subclass that adds no source-generated behavior (no `IETag`, `IChangeLog`, etc.) does **not** need `[Contract]` or `partial`: + +```csharp +[Contract] +public abstract partial class ProductBase : IIdentifier +{ + public string? Id { get; set; } + public string? Sku { get; set; } + public string? Text { get; set; } + public decimal Price { get; set; } +} + +[Contract] +public partial class Product : ProductBase, IETag, IChangeLog { /* additions only */ } + +// Projection — plain class, no generated members needed. +public class ProductLite : ProductBase +{ + public decimal QtyOnHand { get; set; } +} +``` + +## Reference Data Contracts + +Reference data contracts are **generated, not hand-authored**. The source of truth is the `entities:` section of `ref-data.yaml` in the domain's `*.CodeGen` project. Running the CodeGen generates all artefacts across every layer -- contract class, API endpoint, service method, repository interface, repository implementation, and mapper -- as `.g.cs` files that must never be edited directly. + +> **Agent instruction:** When asked to create or modify a reference data type: +> 1. Edit `ref-data.yaml` in `tools/[domain].CodeGen/` -- add or update the entry under `entities:`. +> 2. Offer to run `dotnet run` from the CodeGen directory on the user's behalf. +> 3. If confirmed, execute it and summarise the generated artefacts on success; on failure relay the **complete output verbatim** — it provides the diagnostic needed to fix the entry. +> 4. On failure, fix the issue in `ref-data.yaml` and offer to re-run -- do not create or edit `.g.cs` files to work around a generation error. +> 5. If the user declines, remind them to run `dotnet run` from the `*.CodeGen` directory before the new types are available. +> +> If the user **explicitly requests** hand-authoring instead of CodeGen, use the pattern shown in [Hand-authored contracts (explicit request only)](#hand-authored-contracts-explicit-request-only) below. + +### `ref-data.yaml` -- entity definition + +The standard `IReferenceData` properties (`Id`, `Code`, `Text`, `Description`, `SortOrder`, `IsActive`, `StartsOn`, `EndsOn` etc.) are automatically included in every generated type -- do not declare them under `properties:`. Only additional domain-specific columns need to be listed, and most reference data entities require none at all. + +```yaml +entities: +- name: Brand # minimal form -- no extra properties needed +- name: Category # same; just name is sufficient for most entities +- name: SubCategory + properties: + - name: CategoryCode + type: ^Category # ^ prefix = ref-data navigation property (typed accessor generated) +- name: UnitOfMeasure + plural: UnitsOfMeasure # override irregular pluralization + idType: Guid # override identifier type; defaults to string + properties: + - name: Scale + type: int # additional stored column (not part of IReferenceData) + - name: DiscountPercentage + type: decimal + excludeContract: true # exclude from generated contract (persistence model only) +``` + +Key `entities:` options: + +| Key | Required | Default | Purpose | +|---|---|---|---| +| `name` | Yes | -- | Entity name (PascalCase) | +| `plural` | No | Auto-pluralized | Override when pluralization is irregular | +| `idType` | No | `string` | Identifier type override (e.g. `Guid`, `int`) | +| `properties[].name` | Yes (if any) | -- | Additional stored property name | +| `properties[].type` | Yes (if any) | -- | CLR type; prefix `^` for a ref-data navigation accessor | +| `properties[].excludeContract` | No | `false` | Exclude from the generated contract (persistence only) | + +### Hand-authored extensions (optional) + +After CodeGen runs, an optional hand-authored `partial` class in the same namespace can add constants or computed members. Do not redeclare `[ReferenceData]`, the base class, or the collection class -- those are owned by the generator. + +```csharp +// MovementKind.cs -- hand-authored extension; MovementKind.g.cs is generated, do not edit. +public partial class MovementKind +{ + public const string Adjust = "A"; + public const string Issue = "I"; + public const string Receive = "R"; +} + +// UnitOfMeasure.cs -- computed property derived from a generated stored field. +public partial class UnitOfMeasure +{ + [JsonIgnore] + public int Precision => 16 - Scale; // Scale is a generated stored field +} +``` + +### Hand-authored contracts (explicit request only) + +If the user explicitly requests a hand-authored reference data contract (i.e., without CodeGen), declare the type and its paired collection class directly: + +```csharp +[ReferenceData] +public partial class Category : ReferenceData; + +public class CategoryCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code); +``` + +Use `ReferenceData` when a non-string identifier type is required: + +```csharp +[ReferenceData] +public partial class Priority : ReferenceData; + +public class PriorityCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code); +``` + +## Casing Transformations + +Apply casing transforms in the property setter, not in the validator, when a field has a canonical form: + +```csharp +public string? Sku { get => field; set => field = value?.ToUpper(); } +``` + +## JsonIgnore + +Use `[JsonIgnore]` for computed or internal properties that must not appear in the API response or request body: + +```csharp +[JsonIgnore] +public bool IsQuantityValidForKind => KindCode switch { ... }; +``` + +## No Business Logic in Contracts + +Contracts are data transfer objects. Keep them free of domain rules, validation logic, and service calls. Read-only computed helpers (like `IsQuantityValidForKind` above) are acceptable shorthands but must not mutate state. + +## Generated Code + +Never create or edit `*.g.cs` files directly. + +| File pattern | Generator | Change instead | +|---|---|---| +| `*.g.cs` (ref-data types, cross-layer) | `*.CodeGen` project (`ref-data.yaml` + Handlebars/OnRamp) | Edit `ref-data.yaml` and re-run `dotnet run` | +| `*.g.cs` (contract members) | Roslyn source generator (`CoreEx.Generator`) | The `[Contract]`-decorated partial class | + +## Do Not + +- Do not reference another domain's Contracts assembly to consume its events — declare a local adapter model instead. +- Do not add `[Contract]` or `partial` to plain value-object or request classes that need no generated members. +- Do not implement members that the Roslyn source generator emits (equality, cloning, serialization helpers). +- Do not place domain rules, validators, or service calls in contract classes. +- Do not leave contract properties without a `` — every property gets one (standard `Id`/`ETag`/`ChangeLog` may use ``). See the *Documentation Comments* section. +- Do not add a redundant `[Localization]` attribute whose value equals the auto-derived label (e.g. `[Localization("Salary")]` on `Salary`) — only annotate to change the label. +- Do not edit `*.g.cs` files directly — regenerate via the appropriate tooling. +- Do not hand-author a generated partial implementation (or create its `.g.cs`) because it is "missing" — it appears after a build; the generator runs automatically during compilation. +- Do not invent a build-ordering or "circular dependency" excuse for missing generated code — Roslyn runs the generator even when other errors exist; fix the real errors and rebuild. +- Do not emit `#nullable enable` or `#nullable restore` pragma directives — nullable is enabled project-wide via `Directory.Build.props`. +- Do not create a sub-folder (e.g. `Entities/`, `Models/`, `RefData/`) to house contracts — place every contract flat in the `*.Contracts` root (see *File Placement*); only nest when the user explicitly asks. + +## Further Reading + +- [Contracts Layer Guide](https://github.com/Avanade/CoreEx/blob/main/samples/docs/contracts-layer.md) — unified API/event surface, source generation, reference data, and internal adapter models. +- [CoreEx.Generator](https://github.com/Avanade/CoreEx/tree/main/gen/CoreEx.Generator) — Roslyn source generator that processes `[Contract]` and `[ReferenceData]` annotations. +- [CoreEx.RefData README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.RefData/README.md) — reference data types, collections, and sort order. +- [Tooling Guide](https://github.com/Avanade/CoreEx/blob/main/samples/docs/tooling.md) — `*.CodeGen` project usage and `ref-data.yaml` configuration. diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-conventions.instructions.md b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-conventions.instructions.md new file mode 100644 index 00000000..947a16d2 --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-conventions.instructions.md @@ -0,0 +1,213 @@ +--- +applyTo: "**/*.cs" +description: "Universal C# coding conventions: nullable, implicit usings, GlobalUsing.cs, file-scoped namespaces, brace style, expression bodies, and private field naming" +tags: ["conventions", "style", "nullable", "usings", "naming"] +--- + +# C# Coding Conventions + +## Project Configuration + +Every project must have `Nullable` and `ImplicitUsings` enabled. For consuming projects these are typically set once in a root `Directory.Build.props`: + +```xml + + enable + enable + +``` + +Nullable warnings are treated as errors. Never suppress a nullable warning with the null-forgiving operator (`!`) without a clear reason in a comment. + +## Global Usings + +Every project has a single `GlobalUsing.cs` at the project root that declares all namespace imports. Do not add `using` statements to individual source files. + +```csharp +// GlobalUsing.cs — all usings for the project declared here (sorted alphabetically) +global using Contoso.Products.Application.Interfaces; +global using CoreEx; +global using Microsoft.Extensions.Logging; +global using System; +global using System.Collections.Generic; +global using System.Threading; +global using System.Threading.Tasks; +``` + +**Always re-sort after editing**: Whenever you add or change an entry in `GlobalUsing.cs`, re-sort the **entire** file alphabetically (ordinal) so the order stays deterministic — all namespaces sorted equally, with no special grouping for `System.*`, one `global using` per line. Never simply append a new entry at the end. + +**Why this matters for code generation**: The `*.CodeGen` project emits no `using` statements in generated files. Every namespace referenced by generated code must already be declared in `GlobalUsing.cs`, or the generated output will not compile. When adding a new namespace dependency, add it to `GlobalUsing.cs` — not to the generated file. + +**Global usings follow the code (the scaffold ships clean).** A fresh `coreex` scaffold deliberately does **not** pre-declare `global using`s for its own still-empty project namespaces (`{Solution}.Contracts`, `{Solution}.Application`, `{Solution}.Application.Repositories`) — a `global using` of a namespace with no types yet is a **CS0234** compile error, and shipping placeholder/marker types just to avoid that is an anti-pattern. Instead, **add each `global using` to the consuming project's `GlobalUsing.cs` at the moment you create the code that needs it** — the first contract, the first service, or immediately after CodeGen emits the repository interfaces. This keeps the scaffold an honest, compiling starting point and is a normal, expected step when adding code. (The one exception is the `AssemblyMarker` types in Application/Infrastructure, which exist for `AddDynamicServicesUsing` assembly anchoring — not for namespace population.) + +## File-Scoped Namespaces + +Use file-scoped namespace declarations. Never use block-scoped namespaces. + +```csharp +// Correct — file-scoped +namespace Contoso.Products.Application; + +public class ProductService { } +``` + +```csharp +// Wrong — do not use block-scoped +namespace Contoso.Products.Application +{ + public class ProductService { } +} +``` + +## Braces on `if` Statements + +Single-line `if` bodies do not require braces. + +```csharp +// Correct — no braces needed +if (product == null) return null; + +// Correct — no braces needed - prefer muli-line bodies even when they fit on one line +if (context.HasErrors) + return; + +// Correct — braces required when body spans multiple lines +if (condition) +{ + DoSomething(); + DoSomethingElse(); +} +``` + +## Expression-Bodied Members + +Use `=>` syntax whenever the entire body is a single expression — methods, properties, constructors, operators, and accessors. Use a block body when there are multiple statements. The choice is entirely the developer's; the IDE makes no suggestion in either direction. + +```csharp +// Method delegation — use => +public Task GetAsync(string id, CancellationToken cancellationToken = default) => _repository.GetAsync(id, cancellationToken); + +// Multi-line single expression — use => +public Task> QueryAsync(QueryArgs? query, PagingArgs? paging, CancellationToken cancellationToken = default) + => _repository.QueryAsync(query, paging, cancellationToken); + +// Constructor with single expression — use => +public DataResult(bool wasMutated) => WasMutated = wasMutated; + +// Computed property — use => +public string DisplayName => $"{First} {Last}"; + +// Multiple statements — block body required +public async Task UpdateAsync(Product product, CancellationToken cancellationToken = default) +{ + product.ThrowIfNull(); + await ProductValidator.Default.ValidateAndThrowAsync(product, cancellationToken).ConfigureAwait(false); + return await _repository.UpdateAsync(product, cancellationToken).ConfigureAwait(false); +} +``` + +## Line Length and Method Declarations + +Keep declarations and statements on a **single line**. Do not wrap a method (or constructor/delegate) declaration across multiple lines by placing each parameter on its own line — keep the signature on one line even when it has several parameters. Only break a line when it would otherwise exceed **250 characters**. + +```csharp +// Correct — single line, even with multiple parameters +protected override Task OnValidateAsync(ValidationContext context, CancellationToken cancellationToken) + +// Incorrect — needlessly split across lines while under 250 characters +protected override Task OnValidateAsync( + ValidationContext context, + CancellationToken cancellationToken) +``` + +## Ambient Runtime (Clock and GUIDs) + +Obtain the current time and new `Guid` values from CoreEx's ambient `Runtime` (`CoreEx` namespace) rather than the BCL statics. `Runtime` is `ExecutionContext`-aware and provider-backed (`TimeProvider` / `IdentifierGenerator`), making values consistent across a request and substitutable in tests. + +```csharp +DateTimeOffset now = Runtime.UtcNow; // DateTimeOffset +DateTime utc = Runtime.UtcNow.UtcDateTime; // DateTime (when a DateTime is required) +Guid id = Runtime.NewGuid(); // new Guid +``` + +- Need a `DateTimeOffset` → `Runtime.UtcNow` (**never** `DateTimeOffset.UtcNow`). +- Need a `DateTime` → `Runtime.UtcNow.UtcDateTime` (**never** `DateTime.UtcNow`). +- Need a `Guid` → `Runtime.NewGuid()` (**never** `Guid.NewGuid()`). + +## Cancellation Tokens + +**Every `async`/`Task`-returning method takes a `CancellationToken` and passes it on.** Add it as the **last parameter** — `CancellationToken cancellationToken = default` on public/library methods (default so callers aren't forced to supply one). Flow it through to **every** downstream awaitable call (repositories, `HttpClient`, EF Core, `_unitOfWork`, other services) — never call an overload that accepts a token without passing it, and never silently drop it. + +```csharp +public async Task CreateAsync(Employee value, CancellationToken cancellationToken = default) +{ + await EmployeeValidator.Default.ValidateAndThrowAsync(value, cancellationToken).ConfigureAwait(false); + return await _repository.CreateAsync(value, cancellationToken).ConfigureAwait(false); // pass it on +} +``` + +- **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. + +## XML Documentation Comments + +Document the public surface with XML doc comments, and **never duplicate** a description that an interface already provides: + +- **Interfaces** — give **every** member (method, property, event) a `` describing the operation. Document parameters/returns (``, ``) where it adds clarity. +- **Contract / DTO properties** — every property on a contract (including `[Contract]` partial classes) gets a ``. (Standard `Id`/`ETag`/`ChangeLog` members that implement `IIdentifier`/`IETag`/`IChangeLog` may use ``.) +- **Implementing / overriding members** — on the concrete class member that implements an interface member (or overrides a base member), use **``** rather than repeating the summary. +- **Everything else** — a public type or member that is **not** an interface implementation/override gets its own `` (classes, standalone methods, properties, etc.). + +> **Do not invert this** (a common mistake): the `` goes on the **interface** member and on **contract properties**; the concrete class member that *implements* an interface gets **``**, not a fresh summary. Leaving interfaces or contract properties undocumented while summarising the implementing class is **backwards** — fix it the right way round. + +```csharp +public interface IEmployeeService +{ + /// Gets the for the specified . + Task GetAsync(string id); + + /// Creates a new . + Task> CreateAsync(Employee employee); +} + +[ScopedService] +public class EmployeeService(IUnitOfWork unitOfWork, IEmployeeRepository repository) : IEmployeeService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork.ThrowIfNull(); + private readonly IEmployeeRepository _repository = repository.ThrowIfNull(); + + /// + public Task GetAsync(string id, CancellationToken cancellationToken = default) => _repository.GetAsync(id, cancellationToken); + + /// + public Task> CreateAsync(Employee employee) => /* ... */; +} +``` + +## Private Field Naming + +Private instance fields are always prefixed with `_`. No exceptions. + +```csharp +private readonly IProductRepository _repository; +private readonly IUnitOfWork _unitOfWork; +private readonly ILogger _logger; +``` + +## Do Not + +- Do not emit `#nullable enable` or `#nullable restore` pragma directives in hand-authored files — nullable is enabled project-wide via `enable` in `Directory.Build.props`. These pragmas are reserved for auto-generated `.g.cs` files produced by code generators. +- Do not add `using` statements to individual `.cs` files — declare all imports in `GlobalUsing.cs`. +- Do not leave `GlobalUsing.cs` unsorted after editing — re-sort the whole file alphabetically (all namespaces equally, no `System.*` grouping) rather than appending new entries at the end. +- Do not use block-scoped namespace declarations. +- Do not add braces to single-line `if` bodies. +- Do not suppress nullable warnings with `!` without a comment explaining why. +- Do not name private fields without the `_` prefix. +- Do not split method/constructor declarations (one parameter per line) or otherwise wrap statements across lines unless the line would exceed 250 characters. +- 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 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. diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-domain.instructions.md b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-domain.instructions.md new file mode 100644 index 00000000..b4a742c2 --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-domain.instructions.md @@ -0,0 +1,208 @@ +--- +applyTo: "**/Domain/**/*.cs" +description: "Domain layer conventions: aggregates, entities, value objects, PersistenceState tracking, and mutation methods" +tags: ["domain", "ddd", "aggregates", "entities", "value-objects", "result"] +--- + +# Domain Layer Conventions + +The Domain layer is **optional**. It is introduced only when a domain contains aggregates with meaningful business rules and invariants that must be enforced at the model level — not in orchestration code. For example, a checkout/basket domain with state-machine transitions and nested item rules benefits from this layer; a simple CRUD-oriented domain (like a product catalog) typically does not. + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx.DomainDriven` | `Aggregate`, `Entity`, `PersistenceState`, `.AsNew()`, `.AsNotModified()`, `.SetPersistenceState()` | +| `CoreEx` | `Result`, `Result`, `Result.GoAsync()`, `.ThenAs()`, `.ThenAsAsync()`, `Result.BusinessError()`, `Result.NotFoundError()`, `Result.ValidationError()`, `Runtime.NewId()`, `.ThrowIfNull()`, `.ThrowIfNullOrEmpty()`, `.ThrowIfInactive()`, `.ThrowIfLessThanZero()`, `ValidationException` | + +## Aggregates + +Aggregates are clusters of related entities treated as a single consistency boundary. Extend `Aggregate`: + +```csharp +public sealed class Basket : Aggregate +{ + private List _items = []; + + // Factory methods are the only public construction paths. + public static Basket CreateNew(string customerId) => new Basket(Runtime.NewId()) + { + CustomerId = customerId, + Status = BasketStatus.Empty + }.AsNew(); + + public static Basket CreateFrom(string id, string customerId, BasketStatus status, + IEnumerable? items, ChangeLog? changeLog, string? etag) => new Basket(id) + { + CustomerId = customerId, + Status = status, + _items = items is null ? [] : [.. items.Select(i => i.Clone(PersistenceState.NotModified))], + ChangeLog = changeLog, + ETag = etag + }.AsNotModified(); + + private Basket(string id) : base(id) { } + + public string CustomerId { get; private set => field = value.ThrowIfNullOrEmpty(); } = null!; + public BasketStatus Status { get; private set => field = value.ThrowIfNull().ThrowIfInactive(); } = null!; + public IReadOnlyList Items => _items; + public decimal Total => _items.Where(i => i.PersistenceState.IsNotRemoved).Sum(i => i.Pricing.Total); +} +``` + +### Factory Methods + +Provide two factory methods per aggregate: + +- `CreateNew(...)` — constructs a new aggregate with a generated ID, initial state, and calls `.AsNew()` to mark it as `PersistenceState.New`. +- `CreateFrom(...)` — reconstructs from persisted data and calls `.AsNotModified()`. + +Both are the **only** public construction paths. The constructor is `private` to prevent partially-constructed instances. + +### Mutation Guards — `OnCheckCanMutate` + +Override `OnCheckCanMutate()` to enforce the conditions under which the aggregate may accept mutations. Return `Result.BusinessError(...)` (not an exception) when the condition is not met: + +```csharp +protected override Result OnCheckCanMutate() => Status.CanBeMutated + ? Result.Success + : Result.BusinessError($"Basket has a status of '{Status}' and cannot be modified.", + c => c.WithKey(Id).WithErrorCode("invalid-status")); +``` + +### Post-Mutation Recalculation — `OnMutate` + +Override `OnMutate()` to re-derive any dependent state after a mutation is applied. This is called automatically by `Modify(...)` after the mutation succeeds: + +```csharp +protected override void OnMutate() +{ + if (Status.CanBeMutated) + Status = _items.Any(i => i.PersistenceState.IsNotRemoved) ? BasketStatus.Active : BasketStatus.Empty; +} +``` + +### Public Mutation Methods + +Public mutation methods should return `Result` or `Result` — this is the preferred style because it makes failures explicit and composable in `Result` pipelines in the Application layer. `BusinessException` can be thrown where that feels more natural, but the `Result` return style is recommended for consistency with the aggregate's `OnCheckCanMutate()` pattern. Use `Modify(...)` to apply the mutation, which enforces the `OnCheckCanMutate()` guard: + +```csharp +public Result ItemAdd(BasketItem item) => Modify(() => +{ + item.ThrowIfNull(); + if (_items.FirstOrDefault(i => i.ProductId == item.ProductId && i.PersistenceState.IsNotRemoved) is BasketItem existing) + existing.IncreaseQuantity(item.Pricing.Quantity); + else + _items.Add(item.Clone(PersistenceState.New)); + + return Result.Success; +}); + +public Result ItemUpdate(string basketItemId, decimal quantity, string? etag) +{ + var item = _items.FirstOrDefault(i => i.Id == basketItemId.ThrowIfNullOrEmpty() && i.PersistenceState.IsNotRemoved); + if (item is null) + return Result.NotFoundError(); + + if (quantity != item.Pricing.Quantity) + Modify(() => + { + item.OverrideQuantity(quantity); + item.SetETag(etag); + }); + + return Result.Success; +} +``` + +## Entities + +Child entities within an aggregate extend `Entity`. Apply the same factory-method and private-constructor pattern: + +```csharp +public sealed class BasketItem : Entity +{ + public static BasketItem CreateNew(string productId, string sku, string text, ItemPricing pricing) + => new BasketItem(Runtime.NewId()) { ProductId = productId, Sku = sku, Text = text, Pricing = pricing }.AsNew(); + + public static BasketItem CreateFrom(string id, string productId, string sku, string text, ItemPricing pricing, string? etag) + => new BasketItem(id) { ProductId = productId, Sku = sku, Text = text, Pricing = pricing, ETag = etag }.AsNotModified(); + + private BasketItem(string id) : base(id) { } + + public string ProductId { get; private set => field = value.ThrowIfNullOrEmpty(); } = null!; + public string Sku { get; private set => field = value.ThrowIfNullOrEmpty(); } = null!; + public ItemPricing Pricing { get; private set => field = value.ThrowIfNull().EnsureIsValid(); } = null!; + + // Internal mutation helpers — only callable by the owning aggregate. + internal void OverrideQuantity(decimal quantity) => Modify(() => Pricing = Pricing with { Quantity = quantity }); + internal void Delete() => Remove(); +} +``` + +Keep mutation methods on child entities `internal` so they can only be invoked by the owning aggregate — never directly from the Application layer. + +Guard methods (`.ThrowIfNull()`, `.ThrowIfNullOrEmpty()`, `.ThrowIfInactive()`, `.ThrowIfLessThanZero()`) **return the guarded value** when the check passes, making them natural for inline use in property setters, `init` expressions, and method calls — as shown throughout the examples above. They also chain: `value.ThrowIfNull().ThrowIfInactive()` checks both conditions and returns the value if both pass. + +## PersistenceState + +`PersistenceState` tracks the lifecycle of each aggregate and entity so the Infrastructure layer knows exactly what to persist without being told explicitly: + +| State | Meaning | +|---|---| +| `New` | Newly created; insert on next commit | +| `NotModified` | Loaded from store; no action required | +| `Modified` | Changed since load; update on next commit | +| `Removed` | Marked for deletion; delete on next commit | + +Use the helpers on `PersistenceState` for filtering: + +```csharp +_items.Where(i => i.PersistenceState.IsNotRemoved) // active items +_items.Any(i => i.PersistenceState.IsNewOrModified) // HasChanges check +``` + +## Value Objects + +Value objects represent concepts with no independent identity — defined entirely by their values. Implement as `sealed record` to get structural equality and `with`-expression mutation for free. Enforce invariants in property initialisers: + +```csharp +public sealed record class ItemPricing +{ + public required Contracts.UnitOfMeasure UnitOfMeasure { get; init => field = value.ThrowIfInactive(); } + public decimal UnitPrice { get; init => field = value.ThrowIfLessThanZero(); } + public decimal Quantity { get; init => field = value.ThrowIfLessThanZero(); } + public decimal Total => UnitPrice * Quantity; + + public ItemPricing EnsureIsValid() => DecimalRuleHelper.CheckScale(Quantity, UnitOfMeasure.Scale) ? this + : throw new ValidationException($"Quantity decimal places exceed the unit-of-measure scale of {UnitOfMeasure.Scale}."); +} +``` + +Place value objects in a `ValueObjects/` sub-folder within the Domain project. + +## When to Introduce the Domain Layer + +Only introduce a Domain layer when the domain genuinely has: + +- Aggregates with invariants that must be enforced at the model level (e.g., state-machine transitions, child-collection rules). +- Business rules that depend on the current aggregate state, not on external I/O. +- The need to protect consistency boundaries across multiple child entities. + +For CRUD-oriented domains, skip the Domain layer entirely and let the Application service orchestrate directly against repository interfaces. + +## Do Not + +- Do not perform async I/O (repository calls, HTTP requests) inside domain classes — async work belongs in Application services or Policies. +- Do not expose child entity mutation methods as `public` — use `internal` so only the owning aggregate can drive mutations. +- Prefer returning `Result.BusinessError(...)` or `Result.NotFoundError()` over throwing exceptions for expected business failures in domain methods — this keeps failures explicit and composable. Throwing `BusinessException` is acceptable where it feels more natural, but the `Result` style is recommended for consistency. +- Do not reference Infrastructure, Application, or host assemblies from the Domain layer — it depends only on Contracts and CoreEx. +- Do not model value objects as classes with mutable properties — use `sealed record` with `init` setters and invariant enforcement at construction. + +## Further Reading + +- [Domain Layer Guide](https://github.com/Avanade/CoreEx/blob/main/samples/docs/domain-layer.md) — aggregates, entities, value objects, and `PersistenceState` walkthrough. +- [Pattern Catalog](https://github.com/Avanade/CoreEx/blob/main/samples/docs/patterns.md) — Aggregate, Entity, and Value Object pattern entries with cross-links. +- [Layer Dependencies](https://github.com/Avanade/CoreEx/blob/main/samples/docs/layers.md) — when to introduce the Domain layer and its position in the dependency graph. +- [CoreEx.DomainDriven README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.DomainDriven/README.md) — `Aggregate`, `Entity`, and `PersistenceState`. +- [CoreEx Results README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx/Results/README.md) — `Result` type, pipeline operators (`.GoAsync`, `.ThenAs`, `.ThenAsAsync`), and error propagation semantics. diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-event-subscribers.instructions.md b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-event-subscribers.instructions.md new file mode 100644 index 00000000..63637f9a --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-event-subscribers.instructions.md @@ -0,0 +1,255 @@ +--- +applyTo: "**/Subscribe/**/*.cs" +description: "Event subscriber conventions: SubscribedBase, SubscribedBase, ValueValidator, ErrorHandler, subject naming, and Subscribe host Program.cs composition" +tags: ["subscribers", "messaging", "service-bus", "event-handling", "integration", "subscribe-host"] +--- + +# Event Subscriber Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx.Events` | `SubscribedBase`, `SubscribedBase`, `[Subscribe(...)]`, `EventSubscriberArgs`, `ErrorHandler`, `ErrorHandling`, `EventData`, `.Key` | +| `CoreEx.Azure.Messaging.ServiceBus` | `ServiceBusSessionReceiverOptions`, `.AzureServiceBusReceiving()`, `.WithSessionReceiver()`, `.WithSubscribedSubscriber()`, `.WithHostedService()` | +| `CoreEx` | `[ScopedService]`, `.ThrowIfNull()`, `.Required()`, `Result`, `Result.Success`, `IValidator` | + +## Subscriber Structure + +Each subscriber is a small, focused class that: + +1. Opts in to one or more message subjects via `[Subscribe("subject")]` attributes. +2. Extends `SubscribedBase` (untyped) or `SubscribedBase` (typed payload with optional validation). +3. Delegates immediately to an Application-layer service or adapter — no business logic in the subscriber. +4. Returns `Result` or `Result` so that error handling and dead-lettering decisions can be expressed declaratively. + +All subscribers are decorated with `[ScopedService]` for automatic DI discovery. Dependencies are injected via primary constructor and guarded with `.ThrowIfNull()`. + +### Untyped subscriber — `SubscribedBase` + +Use when the relevant data is carried in the message key rather than a typed payload. Extract the key with `.Required()`, which throws a `ValidationException` if the key is absent: + +```csharp +[ScopedService, Subscribe("contoso.products.product.deleted")] +public class ProductDeleteSubscriber(IProductSyncAdapter adapter) : SubscribedBase +{ + private readonly IProductSyncAdapter _adapter = adapter.ThrowIfNull(); + + protected override Task OnReceiveAsync( + EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default) + => _adapter.DeleteAsync(@event.Key.Required(), cancellationToken); +} +``` + +For custom exception handling (e.g. converting a specific `NotFoundException` to a silent completion rather than dead-lettering), set `ErrorHandler` in the constructor — see [Error Handling](#error-handling) below. + +### Typed subscriber — `SubscribedBase` + +Use when the message carries a typed payload that should be deserialized and optionally validated before `OnReceiveAsync` is called. Wire a `ValueValidator` to validate the deserialized value: + +```csharp +[ScopedService] +[Subscribe("contoso.products.product.created.v1")] +[Subscribe("contoso.products.product.updated.v1")] +public class ProductModifySubscriber(IProductSyncAdapter adapter) : SubscribedBase +{ + private readonly IProductSyncAdapter _adapter = adapter.ThrowIfNull(); + + public override IValidator? ValueValidator => ProductValidator.Default; + + protected override Task OnReceiveAsync( + Product value, EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default) + => _adapter.ModifyAsync(value, cancellationToken); +} +``` + +Multiple `[Subscribe]` attributes on a single class handle multiple subjects with the same logic — no duplication required. + +## Subject Naming + +Use dot-separated lowercase subject strings: + +``` +{solution}.{domain}.{entity}.{action}[.v{n}] +``` + +The version suffix `[.v{n}]` is driven by whether the message carries a **payload** (a CloudEvent data element), not by whether it is an integration event or a command message: + +- **With payload** → include a version suffix. The payload has a schema that can evolve; consumers need to know which version to deserialise: `contoso.products.product.created.v1` +- **Without payload (key-only)** → no version suffix. There is no data schema to version: `contoso.products.reservation.confirm` + +Command messages follow the same rule — a key-only command carries no version; a command that includes a payload does. + +Examples: +- `contoso.products.product.created.v1` — has payload → versioned +- `contoso.products.product.updated.v1` — has payload → versioned +- `contoso.products.product.deleted` — key-only (no payload) → no version +- `contoso.products.reservation.confirm` — key-only (no payload) → no version +- `contoso.products.reservation.cancel` — key-only (no payload) → no version + +> **Note:** CoreEx supports integration events only — domain events (aggregate-internal) are not provided out of the box. + +### Publishing + +This same subject convention governs publishing. The `EventFormatter` in `CoreEx.Events` derives the subject automatically from the entity type and action when `EventData.CreateEventWith(value, action)` is called. Publishing is an **application service concern** — the service adds events to the unit of work inside `_unitOfWork.TransactionAsync(...)`, and they are committed atomically with the database write: + +```csharp +_unitOfWork.Events.Add(EventData.CreateEventWith(product, EventAction.Created)); +``` + +The **outbox** is purely a transactional relay mechanism — it durably captures the events inside the same database transaction, then an Outbox Relay host forwards them to the broker asynchronously. The application service is unaware of the broker; it only adds events to the unit of work. + +## Error Handling + +Define a static `ErrorHandler` to control how specific exceptions are treated — for example, converting a known `NotFoundException` to an informational completion rather than dead-lettering: + +```csharp +internal static readonly ErrorHandler DefaultErrorHandler = new ErrorHandler() + .Add(ex => ex.ErrorCode == "pending-reservation-not-found" + ? ErrorHandling.CompleteAsInformation // consume silently; log as informational + : null); // null = fall through to default handling (retry / dead-letter) +``` + +Assign it in the constructor: `ErrorHandler = DefaultErrorHandler;` + +Share the same `ErrorHandler` instance across related subscribers (e.g., both Confirm and Cancel subscribers can reference the same static instance). + +## Accessing Event Data + +```csharp +var key = @event.Key.Required(); // Returns the key, or throws ValidationException if null/default +``` + +In typed subscribers (`SubscribedBase`), the deserialized value is passed directly as the first parameter to `OnReceiveAsync` — no manual deserialization needed. That is the preferred approach whenever a payload is expected. + +For the rare case where an untyped subscriber (`SubscribedBase`) needs to deserialize the payload, use the protected `DeserializeValue` helper inherited from the base class: + +```csharp +var result = DeserializeValue(@event, args, valueIsRequired: true); +if (result.IsFailure) return result.AsResult(); +var value = result.Value; +``` + +## Program.cs Composition + +The Subscribe host `Program.cs` follows a predictable CoreEx shape. Key sections in order: + +```csharp +// 1. Execution context and dynamic service discovery +builder.Services + .AddExecutionContext() + .AddReferenceDataOrchestrator() // non-generic — binds the IReferenceDataProvider from DI at runtime + .AddMvcWebApi() + .AddHttpWebApi() + .AddHostedServiceManager(); + +builder.Services.AddDynamicServicesUsing(typeof(Program).Assembly, typeof(MyApp.Application.AssemblyMarker).Assembly, typeof(MyApp.Infrastructure.AssemblyMarker).Assembly); + +// 2. Caching — L1 memory cache + L2 Redis + FusionCache hybrid + idempotency provider +builder.Services.AddMemoryCache(); +builder.AddRedisDistributedCache("redis"); +builder.Services.AddFusionCache() + .WithRegisteredMemoryCache() + .WithRegisteredDistributedCache() + .WithBackplane(sp => new RedisBackplane(new RedisBackplaneOptions { Configuration = ... })) + .WithSystemTextJsonSerializer(JsonDefaults.SerializerOptions); +builder.Services + .AddFusionHybridCache() + .AddDefaultCacheKeyProvider() + .AddHybridCacheIdempotencyProvider(); + +// 3. Infrastructure — database, EF, outbox publisher (for transactional writes inside subscribers) +// SQL Server variant: +builder.AddSqlServerClient("SqlServer"); +builder.Services + .AddSqlServerDatabase() + .AddSqlServerUnitOfWork() + .AddSqlServerOutboxPublisher() // outbox publisher becomes the default IEventPublisher + .AddDbContext() + .AddEfDb(); + +// PostgreSQL variant (use instead): +// builder.AddAzureNpgsqlDataSource("Postgres"); +// builder.Services +// .AddPostgresDatabase() +// .AddPostgresUnitOfWork() +// .AddEventFormatter() +// .AddPostgresOutboxPublisher() +// .AddDbContext() +// .AddEfDb(); + +// 4. Azure Service Bus publisher — direct publish capability (not the default IEventPublisher) +builder.AddAzureServiceBusClient("ServiceBus"); +builder.Services.AddAzureServiceBusPublisher((_, c) => +{ + c.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId; +}, addAsDefaultIEventPublisher: false); // false because outbox publisher is already the default + +// 5. Event formatter + subscriber manager +builder.Services + .AddEventFormatter() + .AddSubscribedManager((_, c) => c.AddSubscribersUsing()); + +// 6. Azure Service Bus receiver wiring +builder.Services.AzureServiceBusReceiving() + .WithSessionReceiver(_ => + { + var o = ServiceBusSessionReceiverOptions.CreateForTopicSubscription(); + o.SessionProcessorOptions.MaxConcurrentSessions = 4; + return o; + }) + .WithSubscribedSubscriber() // routes received messages through the SubscribedManager + .WithHostedService() // runs the receiver as a BackgroundService + .Build(); + +// 7. External API clients (if needed — for domains with inter-domain HTTP calls) +builder.AddTypedHttpClient("ProductsApi"); + +// 8. Health checks, OpenTelemetry +builder.Services.PostConfigureAllHealthChecks(); +builder.Services.AddControllers(); +builder.Services.AddOpenApiDocument(s => +{ + s.Title = builder.Environment.ApplicationName; + s.AddCoreExConfiguration(); +}); + +builder.WithCoreExTelemetry() + .WithCoreExServiceBusTelemetry() + .WithCoreExSqlServerTelemetry() // or .WithCoreExPostgresTelemetry() for PostgreSQL + .UseOtlpExporter(); + +// 9. Build and middleware pipeline +var app = builder.Build(); + +app.UseCoreExExceptionHandler(); +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.UseExecutionContext(); +app.MapControllers(); + +app.UseOpenApi(); +app.UseSwaggerUi(); +app.MapHealthChecks(); +app.MapHostedServices(); // exposes pause/resume management endpoints per partition + +app.Run(); +``` + +`AddSubscribersUsing()` scans the assembly containing `T` and auto-registers every `[Subscribe]`-decorated class — adding a new subscriber requires only creating the class, no `Program.cs` edits needed. + +`MapHostedServices()` exposes runtime management endpoints to **pause and resume** the receiver per partition without restarting the process. + +## Do Not + +- Do not embed business logic in subscriber classes — delegate immediately to an Application-layer service or adapter. +- Do not use MediatR or in-process event dispatchers — subscribers react to integration events from the broker only. +- Do not manually register subscriber classes in DI — `AddSubscribersUsing()` discovers them automatically via `[ScopedService]`. +- Do not omit `AddEventFormatter()` from `Program.cs` — it is required for message parsing and deserialization. +- Do not set `addAsDefaultIEventPublisher: true` for the Service Bus publisher when the outbox publisher is the intended default `IEventPublisher`. + +## Further Reading + +- [Hosts Layer Guide — Subscribe Host](https://github.com/Avanade/CoreEx/blob/main/samples/docs/hosts-layer.md) — Subscribe host architecture, Program.cs shape, and subscriber patterns. +- [Pattern Catalog](https://github.com/Avanade/CoreEx/blob/main/samples/docs/patterns.md) — Subscribe, Publish, Transactional Outbox, and Event-Driven Replication pattern entries. +- [CoreEx.Azure.Messaging.ServiceBus README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Azure.Messaging.ServiceBus/README.md) — `SubscribedBase`, `ErrorHandler`, and Service Bus receiver configuration. diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-host-setup.instructions.md b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-host-setup.instructions.md new file mode 100644 index 00000000..898181d0 --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-host-setup.instructions.md @@ -0,0 +1,424 @@ +--- +applyTo: "**/Program.cs" +description: "Host setup conventions for Program.cs: API host, Subscribe host, Outbox Relay host, middleware, service registration, and distributed caching" +tags: ["program-cs", "host-setup", "middleware", "dependency-registration", "caching"] +--- + +# Host Setup Conventions (Program.cs) + +The host is a **composition root only** — no business logic. There are three host types in a CoreEx solution depending on the capabilities required. Each follows the same opening skeleton, then diverges based on its responsibilities. + +> **Further Reading**: [Hosts Layer Guide](https://github.com/Avanade/CoreEx/blob/main/samples/docs/hosts-layer.md) · [Layer Dependencies](https://github.com/Avanade/CoreEx/blob/main/samples/docs/layers.md) · [Pattern Catalog](https://github.com/Avanade/CoreEx/blob/main/samples/docs/patterns.md) + +--- + +## Scaffolding an API host + +An API host is **not** part of the base `coreex` solution — it is added on demand when the user asks to expose functionality over HTTP (e.g. *"create a CRUD API for Employee"*, or *"create the Employee service **and** an API"*). Creating it is an **explicit-ask action** — confirm before scaffolding (per the always-on "Do Not Create Projects" rule); never auto-create it to satisfy a feature request. + +> **Agent instruction:** When an API is requested and no Api host exists: +> 1. **Detect** the host: look for `**/*.Api/*.Api.csproj`. The file system is authoritative for project existence (unlike database state) — no further checking is needed. +> 2. **If present**, skip to authoring controllers (see [coreex-api-controllers](./coreex-api-controllers.instructions.md)). +> 3. **If absent, confirm creation** with the user — default the name to `{Solution}.Api` and the physical location to `src/` (the template default). Do not create it without confirmation. +> 4. **Recover the original selections** from the solution-root `AGENTS.md` "Feature Configuration" (cross-check `dbex.yaml`). These default the `coreex-api` template — which takes **`data-provider`, `refdata-enabled`, `outbox-enabled`** (a subset of the solution options). Re-state the resolved values for confirmation rather than re-prompting; if `AGENTS.md` and `dbex.yaml` disagree, **stop and flag** rather than guessing. +> 5. **Scaffold** with the recovered values, naming consistently with the solution so the derived `domain-name`/`solution-name` tokens align with the existing projects: +> ``` +> dotnet new coreex-api -n {Solution}.Api --data-provider --refdata-enabled --outbox-enabled +> ``` +> **Run it from the solution root** — the directory that already contains `src/` and `tests/`. The template is rooted at `src/...` and `tests/...` (and uses `preferNameDirectory: false`, so it does **not** create a name-based subfolder); it merges its `src/`/`tests/` into the existing ones. Running it from inside `src/` produces nested `src/src/...` paths. If that happens, **delete the misplaced output and re-run from the solution root** — do **not** hand-move the generated files to "fix" the layout. Summarise the output on success; relay it **verbatim** on failure. +> 6. **Author the controller(s)** for the requested entity/operations per [coreex-api-controllers](./coreex-api-controllers.instructions.md), exposing only the confirmed operations. +> 7. **Verify by building the project directly — not the solution.** The host is not yet in the `.slnx` (that is step 9), so a *solution-wide* build would silently **skip** it and give false confidence. Build and test the new project(s) **by path** — this compiles them and their referenced projects regardless of solution membership: +> ``` +> dotnet build .csproj +> dotnet test .csproj +> ``` +> Fix any errors here, in-session, before handing off — compilation does **not** require the project to be in the solution. +> 8. **Update the recording:** amend the solution-root `AGENTS.md` — add the Api host to the *Project Structure* block and note it under hosts. (The feature selections themselves do not change.) +> 9. **Add the new project(s) to the solution — final step, in-session.** Once the changes are verified (step 7), wire the projects into the `.slnx` from the solution root, batched by target folder, using the **actual generated** paths: +> ``` +> dotnet sln .slnx add .csproj --solution-folder hosts +> dotnet sln .slnx add .csproj --solution-folder tests +> ``` +> Always via `dotnet sln add` — **never hand-edit the `.slnx` XML** (manual edits are error-prone and have wiped solution files). This is the **last** action, after the code is verified and `AGENTS.md` is updated, so it cannot interrupt pending work. A final `dotnet build .slnx` confirms the wiring. +> **Exception — Visual Studio with the solution open:** writing the `.slnx` triggers an IDE reload that can interrupt and discard pending changes. *Only* in that case, defer these commands to an end-of-task **Manual steps** list for the user to run instead. The default environment (Claude Code / Copilot / CLI) runs them in-session. + +The scaffolded `Program.cs` wiring is described below; it is generated **complete and ready-to-compile** via the option-driven `#if` blocks — there is **no post-CodeGen uncomment / "phase 2" step**. + +> **Reference-data wiring is automatic — nothing to uncomment.** The host wires reference data and dynamic services **without referencing any CodeGen-generated type**, so it compiles at scaffold time *and* is correct once CodeGen runs: +> ```csharp +> // refdata-enabled hosts only (template #if): +> builder.Services.AddReferenceDataOrchestrator(); // non-generic — resolves the IReferenceDataProvider from DI at runtime +> // all hosts — scans the assemblies (via the stable AssemblyMarker types) for [ScopedService] types, registering them all (incl. CodeGen-generated): +> builder.Services.AddDynamicServicesUsing(typeof({Solution}.Application.AssemblyMarker).Assembly, typeof({Solution}.Infrastructure.AssemblyMarker).Assembly); +> ``` +> The generated `ReferenceDataService` is `[ScopedService]`-decorated, so the assembly scan picks it up automatically and the orchestrator binds to it via `IReferenceDataProvider` — no manual step. Before CodeGen there are simply no ref-data entities to serve (requests return empty), which is correct. **Do not** add `AddReferenceDataOrchestrator()` or `AddDynamicServicesUsing<…generated types…>()` — that reintroduces the compile-time dependency the marker approach removes. + +--- + +## Scaffolding a Subscribe host + +A Subscribe host is **not** part of the base `coreex` solution — it is added on demand when the user **explicitly** asks to *"add/create the subscribe host"*, *"consume events"*, or similar. Creating it is an **explicit-ask action** — confirm before scaffolding (per the always-on "Do Not Create Projects" rule); never auto-create it. + +Like the Relay host, the Subscribe host is **fully template-generated**: the `coreex-subscribe` template emits a complete, compilable `Program.cs` and test project with no Phase-2 / uncomment step. Unlike the Relay, the Subscribe host **does** have follow-on authoring work — subscribers are added to it over time as new event types are consumed. + +> **Critical naming rule — always pass `-n` with the full host-suffixed name:** +> The `coreex-subscribe` template's `sourceName` is `app-name.Subscribe` — the `.Subscribe` suffix is **part of the template source token**, not appended automatically. The `-n` value replaces that entire token, so you **must** include the suffix: +> ``` +> dotnet new coreex-subscribe -n {Solution}.Subscribe ... ✓ correct +> dotnet new coreex-subscribe -n {Solution} ... ✗ wrong — project loses the .Subscribe suffix +> and derived solution-name / domain-name tokens +> resolve incorrectly, breaking cross-project references +> ``` +> Omitting `-n` entirely is equally wrong — `dotnet new` falls back to the directory name (typically the solution root, e.g. `Foo.Bar`), which has the same effect as passing the bare solution name. + +> **Agent instruction:** When the user asks to add/create the Subscribe host: +> 1. **Detect** it: look for `**/*.Subscribe/*.Subscribe.csproj`. The file system is authoritative (unlike database state) — no further checking is needed. +> 2. **If present**, skip to authoring subscribers (see [coreex-event-subscribers](./coreex-event-subscribers.instructions.md)). +> 3. **If absent, confirm creation** with the user — default the name to `{Solution}.Subscribe` and the physical location to `src/` (the template default). Do not create it without confirmation. +> 4. **Recover the original selections** from the solution-root `AGENTS.md` "Feature Configuration" (cross-check `dbex.yaml`). The `coreex-subscribe` template takes **`data-provider`**, **`messaging-provider`**, and **`refdata-enabled`** — default all three from the recorded `coreex` selections. Re-state the resolved values for confirmation rather than re-prompting; if `AGENTS.md` and `dbex.yaml` disagree, **stop and flag** rather than guessing. +> 5. **Scaffold** with the recovered values, naming consistently with the solution so the derived `domain-name`/`solution-name` tokens align with the existing projects: +> ``` +> dotnet new coreex-subscribe -n {Solution}.Subscribe --data-provider --messaging-provider --refdata-enabled +> ``` +> **Run it from the solution root** — the directory that already contains `src/` and `tests/`. The template is rooted at `src/...`/`tests/...` (and uses `preferNameDirectory: false`, so it merges into the existing folders). Running it from inside `src/` produces nested `src/src/...` paths; if that happens, **delete the misplaced output and re-run from the solution root** — do **not** hand-move the files. Summarise the output on success; relay it **verbatim** on failure. +> 6. **Author the subscriber(s)** for any event types the user has asked to consume, per [coreex-event-subscribers](./coreex-event-subscribers.instructions.md). +> 7. **Verify by building the project directly — not the solution.** The host is not yet in the `.slnx` (it is added in step 9), so a *solution-wide* build would silently skip it: +> ``` +> dotnet build .csproj +> dotnet test .csproj +> ``` +> Fix any errors here, in-session, before handing off — compilation does **not** require the project to be in the solution. +> 8. **Update the recording:** amend the solution-root `AGENTS.md` — add the Subscribe host to the *Project Structure* block and note it under hosts. (The feature selections themselves do not change.) +> 9. **Add the new project(s) to the solution — final step, in-session.** Once the changes are verified (step 7), wire the projects into the `.slnx` from the solution root: +> ``` +> dotnet sln .slnx add .csproj --solution-folder hosts +> dotnet sln .slnx add .csproj --solution-folder tests +> ``` +> Always via `dotnet sln add` — **never hand-edit the `.slnx` XML**. A final `dotnet build .slnx` confirms the wiring. **Exception — Visual Studio with the solution open:** defer these to a **Manual steps** list (the IDE-reload caveat from the Api host workflow applies identically). + +The scaffolded Subscribe `Program.cs` wiring is described in [Subscribe Host](#subscribe-host) below; the template emits it complete via the option-driven `#if` blocks — there is **no post-CodeGen uncomment / "phase 2" step**. + +--- + +## Scaffolding an Outbox Relay host + +An Outbox Relay host is **not** part of the base `coreex` solution — it is added on demand when the user **explicitly** asks to *"add/create the outbox relay"* (or similar). Creating it is an **explicit-ask action** — confirm before scaffolding (per the always-on "Do Not Create Projects" rule); never auto-create it. + +Unlike the Api host, the Relay is a **one-off, fully template-generated** host: it has **no controllers, no reference-data wiring, no application services, and no Phase-2 / uncomment step**. Once scaffolded there is **nothing further to author** — do **not** add any logic, registration, or test beyond what the `coreex-relay` template emits. + +> **Agent instruction:** When the user asks to add/create the Outbox Relay host: +> 1. **Detect** it: look for `**/*.Relay/*.Relay.csproj`. The file system is authoritative (unlike database state) — no further checking is needed. +> 2. **If present, STOP — it already exists.** The Relay is a single per-solution one-off; **immediately report it as pre-existing and do nothing else** — do not re-scaffold, modify, or "augment" it. (Contrast the Api host, where an existing host means "go author controllers"; the Relay has **no** follow-on work.) +> 3. **If absent, confirm creation** with the user — default the name to `{Solution}.Relay` and the physical location to `src/` (the template default). Do not create it without confirmation. +> 4. **Recover the original selections** from the solution-root `AGENTS.md` "Feature Configuration" (cross-check `dbex.yaml`). The `coreex-relay` template takes **`data-provider`** and **`messaging-provider`** — default **both** from the recorded `coreex` selections. Re-state the resolved values for confirmation rather than re-prompting; if `AGENTS.md` and `dbex.yaml` disagree, **stop and flag** rather than guessing. +> 5. **Scaffold** with the recovered values, naming consistently with the solution so the derived `domain-name`/`solution-name` tokens align with the existing projects: +> ``` +> dotnet new coreex-relay -n {Solution}.Relay --data-provider --messaging-provider +> ``` +> **Run it from the solution root** — the directory that already contains `src/` and `tests/`. The template is rooted at `src/...`/`tests/...` (and uses `preferNameDirectory: false`, so it merges into the existing folders). Running it from inside `src/` produces nested `src/src/...` paths; if that happens, **delete the misplaced output and re-run from the solution root** — do **not** hand-move the files. Summarise the output on success; relay it **verbatim** on failure. +> 6. **Verify by building the project directly — not the solution.** The host is not yet in the `.slnx` (it is added in step 8), so a *solution-wide* build would silently skip it: +> ``` +> dotnet build .csproj +> dotnet test .csproj +> ``` +> Fix any errors here, in-session. There is **no** further wiring — the template output is complete as-is. +> 7. **Update the recording:** amend the solution-root `AGENTS.md` — add the Relay host to the *Project Structure* block and note it under hosts. (The feature selections themselves do not change.) +> 8. **Add the new project(s) to the solution — final step, in-session** (exactly as for the Api host), batched by target folder, using the **actual generated** paths: +> ``` +> dotnet sln .slnx add .csproj --solution-folder hosts +> dotnet sln .slnx add .csproj --solution-folder tests +> ``` +> Always via `dotnet sln add` — **never hand-edit the `.slnx` XML**. A final `dotnet build .slnx` confirms the wiring. **Exception — Visual Studio with the solution open:** defer these to a **Manual steps** list (the IDE-reload caveat from the Api host workflow applies identically). + +The scaffolded Relay `Program.cs` wiring is described in [Outbox Relay Host](#outbox-relay-host) below; the template emits it complete via the option-driven `#if` blocks (`data-provider` + `messaging-provider`), so a correctly-defaulted scaffold compiles and runs without any manual fix-up. + +--- + +## Key Registrations by Host Type + +### API Host + +| Package | Key registrations | +|---|---| +| `CoreEx.AspNetCore` | `AddMvcWebApi()`, `AddHttpWebApi()`, `AddExecutionContext()`, `UseCoreExExceptionHandler()`, `UseExecutionContext()`, `UseIdempotencyKey()`, `MapHealthChecks()` | +| `CoreEx.AspNetCore.NSwag` | `AddOpenApiDocument()`, `AddCoreExConfiguration()`, `UseOpenApi()`, `UseSwaggerUi()` | +| `CoreEx.Caching.FusionCache` | `AddFusionCache()`, `AddFusionHybridCache()`, `AddDefaultCacheKeyProvider()`, `AddHybridCacheIdempotencyProvider()` | +| `CoreEx.Database.SqlServer` | `AddSqlServerDatabase()`, `AddSqlServerUnitOfWork()`, `AddSqlServerOutboxPublisher()`, `AddSqlServerClient("SqlServer")` | +| `CoreEx.Database.Postgres` | `AddPostgresDatabase()`, `AddPostgresUnitOfWork()`, `AddPostgresOutboxPublisher()`, `AddAzureNpgsqlDataSource("Postgres")` | +| `CoreEx.EntityFrameworkCore` | `AddDbContext()`, `AddEfDb()` | +| `CoreEx.Events` | `AddEventFormatter()` | +| `CoreEx.RefData` | `AddReferenceDataOrchestrator()` | +| `Aspire.StackExchange.Redis.DistributedCaching` | `AddRedisDistributedCache("redis")` | +| `FusionCache.Backplane.StackExchangeRedis` | `RedisBackplane`, `RedisBackplaneOptions` | +| `OpenTelemetry.*` | `WithCoreExTelemetry()`, `WithCoreExSqlServerTelemetry()` / `WithCoreExPostgresTelemetry()`, `UseOtlpExporter()` | + +### Subscribe Host + +| Package | Key registrations | +|---|---| +| `CoreEx.AspNetCore` | `AddMvcWebApi()`, `AddHttpWebApi()`, `AddExecutionContext()`, `AddHostedServiceManager()`, `UseCoreExExceptionHandler()`, `UseExecutionContext()`, `MapHealthChecks()`, `MapHostedServices()` | +| `CoreEx.Caching.FusionCache` | `AddFusionCache()`, `AddFusionHybridCache()`, `AddDefaultCacheKeyProvider()`, `AddHybridCacheIdempotencyProvider()` | +| `CoreEx.Events` | `AddEventFormatter()`, `AddSubscribedManager()` | +| `CoreEx.Database.SqlServer` | `AddSqlServerDatabase()`, `AddSqlServerUnitOfWork()`, `AddSqlServerOutboxPublisher()`, `AddSqlServerClient("SqlServer")` | +| `CoreEx.Database.Postgres` | `AddPostgresDatabase()`, `AddPostgresUnitOfWork()`, `AddPostgresOutboxPublisher()`, `AddAzureNpgsqlDataSource("Postgres")` | +| `CoreEx.EntityFrameworkCore` | `AddDbContext()`, `AddEfDb()` | +| `CoreEx.RefData` | `AddReferenceDataOrchestrator()` | +| `CoreEx.Azure.Messaging.ServiceBus` | `AddAzureServiceBusClient("ServiceBus")`, `AddAzureServiceBusPublisher(..., addAsDefaultIEventPublisher: false)`, `AzureServiceBusReceiving()`, `WithCoreExServiceBusTelemetry()` | +| `Aspire.StackExchange.Redis.DistributedCaching` | `AddRedisDistributedCache("redis")` | +| `FusionCache.Backplane.StackExchangeRedis` | `RedisBackplane`, `RedisBackplaneOptions` | +| `OpenTelemetry.*` | `WithCoreExTelemetry()`, `WithCoreExServiceBusTelemetry()`, `WithCoreExSqlServerTelemetry()` / `WithCoreExPostgresTelemetry()`, `UseOtlpExporter()` | + +### Outbox Relay Host + +| Package | Key registrations | +|---|---| +| `CoreEx.AspNetCore` | `AddMvcWebApi()`, `AddHttpWebApi()`, `AddExecutionContext()`, `AddHostedServiceManager()`, `UseCoreExExceptionHandler()`, `UseExecutionContext()`, `MapHealthChecks()`, `MapHostedServices()` | +| `CoreEx.Database.SqlServer` | `AddSqlServerDatabase()`, `AddSqlServerUnitOfWork()`, `AddSqlServerOutboxRelay()`, `AddSqlServerOutboxRelayHostedService()` | +| `CoreEx.Database.Postgres` | `AddPostgresDatabase()`, `AddPostgresUnitOfWork()`, `AddPostgresOutboxRelay()`, `AddPostgresOutboxRelayHostedService()` | +| `CoreEx.Azure.Messaging.ServiceBus` | `AddAzureServiceBusClient("ServiceBus")`, `AddAzureServiceBusPublisher(...)`, `ServiceBusSessionStrategy` | +| `OpenTelemetry.*` | `WithCoreExTelemetry()`, `WithCoreExSqlServerTelemetry()` / `WithCoreExPostgresTelemetry()`, `WithCoreExServiceBusTelemetry()`, `UseOtlpExporter()` | + +--- + +## API Host + +The API host is the primary HTTP composition root. It exposes controllers, OpenAPI docs, reference-data endpoints, and idempotency support. + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.AddHostSettings(); + +builder.Services + .AddPrecisionTimeProvider() + .AddExecutionContext() + .AddReferenceDataOrchestrator() // non-generic — binds the IReferenceDataProvider (CodeGen-generated ReferenceDataService) from DI at runtime + .AddMvcWebApi() + .AddHttpWebApi(); + +builder.Services.AddDynamicServicesUsing(typeof(MyApp.Application.AssemblyMarker).Assembly, typeof(MyApp.Infrastructure.AssemblyMarker).Assembly); + +// L1/L2 caching with FusionCache + Redis backplane. +builder.Services.AddMemoryCache(); +builder.AddRedisDistributedCache("redis"); +builder.Services.AddFusionCache() + .WithRegisteredMemoryCache() + .WithRegisteredDistributedCache() + .WithBackplane(sp => new RedisBackplane(new RedisBackplaneOptions { Configuration = ... })) + .WithSystemTextJsonSerializer(JsonDefaults.SerializerOptions); +builder.Services + .AddFusionHybridCache() + .AddDefaultCacheKeyProvider() + .AddHybridCacheIdempotencyProvider(); + +// Database, EF, outbox publisher. +// SQL Server variant: +builder.AddSqlServerClient("SqlServer"); +builder.Services + .AddSqlServerDatabase() + .AddSqlServerUnitOfWork() + .AddEventFormatter() + .AddSqlServerOutboxPublisher() + .AddDbContext() + .AddEfDb(); + +// PostgreSQL variant (use instead of SQL Server): +// builder.AddAzureNpgsqlDataSource("Postgres"); +// builder.Services +// .AddPostgresDatabase() +// .AddPostgresUnitOfWork() +// .AddEventFormatter() +// .AddPostgresOutboxPublisher() +// .AddDbContext() +// .AddEfDb(); + +builder.Services.PostConfigureAllHealthChecks(); +builder.Services.AddControllers(); +builder.Services.AddOpenApiDocument(s => { s.Title = builder.Environment.ApplicationName; s.AddCoreExConfiguration(); }); + +builder.WithCoreExTelemetry().WithCoreExSqlServerTelemetry().UseOtlpExporter(); + +var app = builder.Build(); +app.UseCoreExExceptionHandler(); +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.UseExecutionContext(); +app.UseIdempotencyKey(); // After UseExecutionContext. +app.MapControllers(); +app.UseOpenApi(); +app.UseSwaggerUi(); +app.MapHealthChecks(); +app.Run(); +``` + +Key points: +- **`AddDynamicServicesUsing(...)` registers per _assembly_, not per service.** It scans each supplied assembly for all `[ScopedService]`-decorated types and registers them. The template anchors each assembly on its neutral **`AssemblyMarker`** type (`typeof(MyApp.Application.AssemblyMarker).Assembly`, `…Infrastructure…`) — these are the **only** markers the clean scaffold ships, and they exist **solely** for this anchoring. Use the neutral marker, **not** a domain type like `typeof(ReferenceDataService).Assembly`: the marker reads as "scan this whole assembly," never misleads a reader into thinking the scan is scoped to one type, always exists (so there is no compile-time dependency on CodeGen output and no bootstrap/uncomment step), and survives renames. **Adding a new entity does not change this line** — the new service/repository is picked up automatically by the existing assembly scan. Add another assembly only when you introduce a **new project** containing `[ScopedService]` types (e.g. the Subscribe host passes `typeof(Program).Assembly` for its subscribers). Do **not** add per-namespace placeholder/marker types to satisfy `global using`s — those follow the code (see `coreex-conventions.instructions.md`). +- Prefer the **non-generic `AddReferenceDataOrchestrator()`** (binds `IReferenceDataProvider` from DI at runtime) over `AddReferenceDataOrchestrator()` — the non-generic form needs no generated type, so it compiles from scaffold time. +- `AddReferenceDataOrchestrator()` and `AddDynamicServicesUsing(...)` are shared with Subscribe hosts — both API and Subscribe hosts are full application-layer consumers. +- FusionCache (L1/L2) and `AddHybridCacheIdempotencyProvider()` are shared with Subscribe hosts — both need caching for reference data and idempotency for safe duplicate handling. +- `AddEventFormatter()` is required wherever events are published or parsed. +- `AddSqlServerOutboxPublisher()` / `AddPostgresOutboxPublisher()` take no generic type parameter. +- `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`. + +--- + +## Subscribe Host + +The Subscribe host receives broker messages and delegates to Application-layer services. Subscribers are **full application-layer consumers** — they invoke application services that may validate, persist data, and publish outbound events. Therefore, Subscribe hosts include reference data, caching, database, and idempotency support. + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.AddHostSettings(); + +builder.Services + .AddPrecisionTimeProvider() + .AddExecutionContext() + .AddReferenceDataOrchestrator() // non-generic — binds the IReferenceDataProvider from DI at runtime + .AddMvcWebApi() + .AddHttpWebApi() + .AddHostedServiceManager(); + +builder.Services.AddDynamicServicesUsing(typeof(Program).Assembly, typeof(MyApp.Application.AssemblyMarker).Assembly, typeof(MyApp.Infrastructure.AssemblyMarker).Assembly); // this host (subscribers) + Application + Infrastructure + +// L1/L2 caching with FusionCache + Redis backplane. +builder.Services.AddMemoryCache(); +builder.AddRedisDistributedCache("redis"); +builder.Services.AddFusionCache() + .WithRegisteredMemoryCache() + .WithRegisteredDistributedCache() + .WithBackplane(sp => new RedisBackplane(new RedisBackplaneOptions { Configuration = ... })) + .WithSystemTextJsonSerializer(JsonDefaults.SerializerOptions); +builder.Services + .AddFusionHybridCache() + .AddDefaultCacheKeyProvider() + .AddHybridCacheIdempotencyProvider(); + +// Domain database + outbox publisher. +// SQL Server variant: +builder.AddSqlServerClient("SqlServer"); +builder.Services + .AddSqlServerDatabase() + .AddSqlServerUnitOfWork() + .AddSqlServerOutboxPublisher() + .AddDbContext() + .AddEfDb(); + +// PostgreSQL variant (use instead of SQL Server): +// builder.AddAzureNpgsqlDataSource("Postgres"); +// builder.Services +// .AddPostgresDatabase() +// .AddPostgresUnitOfWork() +// .AddPostgresOutboxPublisher() +// .AddDbContext() +// .AddEfDb(); + +// Service Bus: keep outbox publisher as the default IEventPublisher. +builder.AddAzureServiceBusClient("ServiceBus"); +builder.Services.AddAzureServiceBusPublisher((_, c) => +{ + c.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId; +}, addAsDefaultIEventPublisher: false); + +// Subscriber wiring. +builder.Services + .AddEventFormatter() + .AddSubscribedManager((_, c) => c.AddSubscribersUsing()); + +builder.Services.AzureServiceBusReceiving() + .WithSessionReceiver(_ => + { + var o = ServiceBusSessionReceiverOptions.CreateForTopicSubscription(); + o.SessionProcessorOptions.MaxConcurrentSessions = 4; + return o; + }) + .WithSubscribedSubscriber() + .WithHostedService() + .Build(); + +builder.Services.PostConfigureAllHealthChecks(); +builder.Services.AddControllers(); +builder.Services.AddOpenApiDocument(s => { s.Title = builder.Environment.ApplicationName; s.AddCoreExConfiguration(); }); + +builder.WithCoreExTelemetry() + .WithCoreExServiceBusTelemetry() + .WithCoreExSqlServerTelemetry() // or .WithCoreExPostgresTelemetry() for PostgreSQL + .UseOtlpExporter(); + +var app = builder.Build(); +app.UseCoreExExceptionHandler(); +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.UseExecutionContext(); +app.MapControllers(); +app.UseOpenApi(); +app.UseSwaggerUi(); +app.MapHealthChecks(); +app.MapHostedServices(); // Exposes pause/resume management endpoints — must follow MapHealthChecks. +app.Run(); +``` + +Key points: +- Subscribe hosts **do** include `AddReferenceDataOrchestrator()` and `AddDynamicServicesUsing(...)` — subscribers call application services that need reference data for validation and business logic. The Subscribe host passes **three** assemblies to `AddDynamicServicesUsing`: its own (`typeof(Program).Assembly`, for the subscriber types) plus the Application and Infrastructure `AssemblyMarker` assemblies. +- Subscribe hosts **do** include FusionCache (L1/L2) and `AddHybridCacheIdempotencyProvider()` — caching is required for reference data; idempotency is required to safely handle duplicate message delivery. +- Subscribe hosts **do** include database/EF Core and outbox publisher — subscribers persist domain data and publish outbound events. +- `AddHostedServiceManager()` must be registered before `AzureServiceBusReceiving()`. +- `AddSubscribersUsing()` scans the assembly of `T` and auto-registers all `[Subscribe]`-decorated classes — no manual registration per subscriber. +- `AddAzureServiceBusPublisher(..., addAsDefaultIEventPublisher: false)` keeps the outbox publisher as the default `IEventPublisher` for transactional writes. +- `MapHostedServices()` must come **after** `MapHealthChecks()`. + +--- + +## Outbox Relay Host + +The Outbox Relay host is minimal: it polls the outbox table and forwards committed events to Azure Service Bus. It has **no application logic** — no controllers, no OpenAPI, no FusionCache, no reference data, no EF Core DbContext. It only needs database connectivity to read the outbox table and Service Bus connectivity to publish. + +```csharp +builder.Services + .AddPrecisionTimeProvider() + .AddExecutionContext() + .AddMvcWebApi() + .AddHttpWebApi() + .AddHostedServiceManager(); + +// SQL Server example; use Postgres equivalents for PostgreSQL domains. +builder.AddSqlServerClient("SqlServer"); +builder.Services + .AddSqlServerDatabase() + .AddSqlServerUnitOfWork() + .AddSqlServerOutboxRelay(); // No configuration lambda required. + +builder.AddSqlServerOutboxRelayHostedService(); + +// PostgreSQL variant: +// builder.AddAzureNpgsqlDataSource("Postgres"); +// builder.Services +// .AddPostgresDatabase() +// .AddPostgresUnitOfWork() +// .AddPostgresOutboxRelay(); +// builder.AddPostgresOutboxRelayHostedService(); + +// Service Bus publisher — this IS the default IEventPublisher for the relay. +builder.AddAzureServiceBusClient("ServiceBus"); +builder.Services.AddAzureServiceBusPublisher((_, c) => +{ + c.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId; +}); + +builder.Services.PostConfigureAllHealthChecks(); + +builder.WithCoreExTelemetry().WithCoreExSqlServerTelemetry().WithCoreExServiceBusTelemetry().UseOtlpExporter(); + +var app = builder.Build(); +app.UseCoreExExceptionHandler(); +app.UseHttpsRedirection(); +app.UseExecutionContext(); +app.MapHealthChecks(); +app.MapHostedServices(); +app.Run(); +``` + +Key points: +- The Relay host has **no application-layer dependencies** — no `AddReferenceDataOrchestrator`, no `AddDynamicServicesUsing`, no FusionCache, no EF Core DbContext, no domain services. +- `AddSqlServerOutboxRelay()` / `AddPostgresOutboxRelay()` take no configuration lambda. +- `AddSqlServerOutboxRelayHostedService()` / `AddPostgresOutboxRelayHostedService()` register the background relay pump — call these on `builder`, not `builder.Services`. +- No `AddControllers()`, no `AddOpenApiDocument()`, no `UseOpenApi()`, no `UseSwaggerUi()`, no `UseIdempotencyKey()`, no `UseAuthorization()`. diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-repositories.instructions.md b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-repositories.instructions.md new file mode 100644 index 00000000..1a1b8df9 --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-repositories.instructions.md @@ -0,0 +1,336 @@ +--- +applyTo: "**/Infrastructure/**/*.cs" +description: "Repository and infrastructure conventions: EFCore, mapping, typed HTTP clients, adapter implementations, and data-access patterns" +tags: ["repositories", "infrastructure", "data-access", "efcore", "mapping", "adapters"] +--- + +# Repository & Infrastructure Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx` | `[ScopedService]`, `.ThrowIfNull()`, `ItemsResult`, `Result`, `.GoAsync()`, `.ThenAs()`, `.ThenAsAsync()` | +| `CoreEx.Events` | `EventData` | +| `CoreEx.Data` | `IUnitOfWork`, `DataResult`, `QueryArgsConfig`, `QueryFilterOperator`, `.Where(parsed)`, `.OrderBy(parsed)` | +| `CoreEx.EntityFrameworkCore` | `EfDb`, `EfDbModel`, `EfDbMappedModel`, `EfDbOptions`, `.GetAsync()`, `.CreateAsync()`, `.UpdateAsync()`, `.DeleteAsync()`, `.GetWithResultAsync()`, `.CreateWithResultAsync()`, `.UpdateWithResultAsync()`, `.Query()`, `.ToMappedItemsResultAsync()` | +| `CoreEx.Database.SqlServer` | SQL Server outbox publisher, ADO.NET helpers | +| `CoreEx.Database.Postgres` | PostgreSQL outbox publisher, ADO.NET helpers | + +> **Polyglot data**: Use `CoreEx.Database.Postgres` + `Npgsql.EntityFrameworkCore.PostgreSQL` for PostgreSQL domains. Use `CoreEx.Database.SqlServer` + `Microsoft.EntityFrameworkCore.SqlServer` for SQL Server domains. Layers above Infrastructure are database-agnostic. + +## Structure + +The Infrastructure project is organised into focused sub-folders. The table below shows the standard layout and where each type lives: + +| Sub-folder | Contents | +|---|---| +| `Repositories/` | `IXxxRepository` implementations (registered with `[ScopedService]`); `*EfDb.cs` — typed model accessor; `*DbContext.cs` — hand-authored EF `DbContext` (implements `IEfDbContext`, calls `AddGeneratedModels()`); `*DbContext.g.cs` — **generated** ModelBuilder configuration produced by the `*.Database` tooling. | +| `Mapping/` | Bidirectional mappers (`BiDirectionMapper`) between Contract types and Persistence model types. | +| `Adapters/` | Implementations of `IXxxAdapter` interfaces defined in `Application/Adapters/`. Registered with `[ScopedService]`. | +| `Clients/` | Typed HTTP client wrappers — one class per external service. Registered via `AddTypedHttpClient()` in `Program.cs`. | +| `Persistence/` | EF entity/model classes. These are **generated** (`*.g.cs`) by the `*.Database` tooling project — do not create or edit manually. | + +Repository and adapter implementations follow the same primary-constructor + guard pattern: + +```csharp +[ScopedService] +public class ProductRepository(ProductsEfDb ef) : IProductRepository +{ + private readonly ProductsEfDb _ef = ef.ThrowIfNull(); +} +``` + +**One repository per entity — the CQRS split is at the service layer, not here.** A single `XxxRepository` serves both the write `XxxService` and the read `XxxReadService` when they share a data source (the usual case for a SQL-backed domain) — do **not** create a separate read repository to mirror the read service. Introduce an additional repository only when an operation targets a **genuinely different** data source (e.g. a read served from a separate store or search index); the owning service then calls the appropriate repository per operation. + +## Return Types + +| Operation | Return type | Notes | +|---|---|---| +| Single entity lookup | `Task` | Returns `null` when not found; service checks | +| Create / Update | `Task>` | Includes mutation flag for event decisions | +| Delete | `Task` | Carries mutation flag only | +| Collection query | `Task>` | Items + optional total count | +| Result pipeline (optional) | `Task>` | Developer choice — can be used on any repository method; enables explicit failure propagation without exceptions | + +## EfDb and DbContext + +### DbContext + +`*DbContext` inherits from EF Core's `DbContext` and **must** implement `IEfDbContext`. This interface exposes the `IDatabase` instance to `EfDb`, which uses it to synchronise EF Core's transaction with the underlying ADO.NET connection — ensuring raw SQL and EF operations share the same connection and transaction: + +```csharp +public partial class ProductsDbContext(DbContextOptions options, SqlServerDatabase database) // PostgreSQL: PostgresDatabase + : DbContext(options), IEfDbContext +{ + public IDatabase BaseDatabase { get; } = database.ThrowIfNull(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + AddGeneratedModels(modelBuilder); // wires in the generated *DbContext.g.cs ModelBuilder configurations + } + + // Declared here, implemented in the generated *DbContext.g.cs (partial method — the call above is elided until CodeGen has run, so this compiles as-is). + partial void AddGeneratedModels(ModelBuilder modelBuilder); +} +``` + +`*DbContext.g.cs` (generated by the `*.Database` tooling) supplies the **`partial void AddGeneratedModels(ModelBuilder)`** *implementation* containing the `ModelBuilder` entity configurations. The hand-authored `*DbContext.cs` must **declare** the matching `partial void AddGeneratedModels(ModelBuilder modelBuilder);` and **call** `AddGeneratedModels(modelBuilder)` (an instance method on the `DbContext`, **not** an extension on `ModelBuilder`) from `OnModelCreating`. Because it is a partial method, the call is elided until CodeGen emits the implementation — so the scaffold compiles before CodeGen has run. + +### EfDb + +`*EfDb` extends `EfDb` and acts as a **typed accessor** over the `DbContext`. It exposes strongly-typed `EfDbModel` and `EfDbMappedModel` properties per entity, and uses `EfDbOptions` to configure per-model behaviour. Repositories inject `*EfDb` directly — not the `DbContext` itself: + +```csharp +public sealed class ProductsEfDb(ProductsDbContext dbContext) : EfDb(dbContext, _options) +{ + private static readonly EfDbOptions _options = new EfDbOptions() + .WithModel(m => m.WithLogicalDeleteFilter()); + + public EfDbMappedModel Products + => Model().ToMappedModel(ProductMapper.Default); + + public EfDbModel SubCategories => Model(); + public EfDbModel Inventory => Model(); + // ... +} +``` + +`EfDbOptions` configures key behaviours: default `EfDbArgs`, per-model options (e.g. `WithLogicalDeleteFilter()`), and tenant filtering. See the [CoreEx.Database README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Database/README.md) for full detail. + +> **Unit of work**: `EfDb` is **not** the unit of work. Transactions are managed by `IUnitOfWork` (from `CoreEx.Data`), with concrete SQL Server and PostgreSQL implementations wired to `IDatabase` and registered in DI. Application services inject `IUnitOfWork`; repositories inject `*EfDb`. + +## EF Delegate Shortcuts + +Use the built-in EF delegate methods for single-entity CRUD — do not write raw `DbContext` queries for simple operations: + +```csharp +public Task GetAsync(string id, CancellationToken cancellationToken = default) => _ef.Products.GetAsync(id, cancellationToken); +public Task> CreateAsync(Contracts.Product product, CancellationToken cancellationToken = default) => _ef.Products.CreateAsync(product, cancellationToken); +public Task> UpdateAsync(Contracts.Product product, CancellationToken cancellationToken = default) => _ef.Products.UpdateAsync(product, cancellationToken); +public Task DeleteAsync(string id, CancellationToken cancellationToken = default) => _ef.Products.DeleteAsync(id, cancellationToken); +``` + +### EfDb method reference and the `WithResult` convention + +The model accessors (`EfDbModel` and `EfDbMappedModel<...>`) expose two variants of every operation. **Read the suffix:** + +- **`...Async`** — returns the value directly (e.g. `TValue?`, `DataResult`) and throws on error (exception flow). +- **`...WithResultAsync`** — returns a `Result<...>` for ROP pipelines (failure is returned, not thrown). Use these in `Result` chains. + +| Operation | Exception flow | ROP flow | +|---|---|---| +| Get by key | `GetAsync(key)` → `TValue?` | `GetWithResultAsync(key)` → `Result` | +| Create | `CreateAsync(value)` → `DataResult` | `CreateWithResultAsync(value)` → `Result>` | +| Update | `UpdateAsync(value)` → `DataResult` | `UpdateWithResultAsync(value)` → `Result>` | +| Delete | `DeleteAsync(key)` → `DataResult` | `DeleteWithResultAsync(key)` → `Result` | + +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()`. + +Per-model behaviour is configured on `EfDbOptions` / `EfDbModelOptions`: `WithModel(...)`, `WithLogicalDeleteFilter()`, `WithTenantFilter()`, `WithFilter(...)`, `WithGetKey(...)`, `WithArgs(...)`, `WithOnBeforeCreateOrUpdate(...)`, `WithUpdateModelMapper(...)`. + +## Dynamic Query Configuration + +> **GlobalUsing requirement:** `QueryArgsConfig` and the related query types live in the `CoreEx.Data.Querying` namespace. When introducing query configuration, ensure `global using CoreEx.Data.Querying;` is present in the Infrastructure project's `GlobalUsing.cs` — add it if missing. This prevents avoidable compilation errors (per the Global Usings convention, imports go in `GlobalUsing.cs`, never in individual files). + +Define a `static readonly QueryArgsConfig _queryConfig` once at class level for OData-style filtering and ordering: + +```csharp +private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() + .WithFilter(filter => filter + .WithDefaultModelPrefix("Product") + .AddField(nameof(ProductBase.Sku), c => c + .WithOperators(QueryFilterOperator.EqualityOperators | QueryFilterOperator.StartsWith) + .AsUpperCase()) + .AddField(nameof(ProductBase.Text), c => c + .WithOperators(QueryFilterOperator.StringFunctions) + .AsUpperCase()) + .AddReferenceDataField(nameof(ProductBase.Category), "CategoryCode", + c => c.WithModelPrefix(null))) + .WithOrderBy(orderby => orderby + .WithDefaultModelPrefix("Product") + .AddField(nameof(ProductBase.Sku), c => c.WithDefault().WithAlwaysInclude()) + .AddField(nameof(ProductBase.Text)) + .AddField(nameof(ProductBase.Brand))); +``` + +In the query method, compose the full base query first (including any required joins), then apply `Where(parsed)` and `OrderBy(parsed)`: + +```csharp +public async Task> QueryAsync(QueryArgs? query, PagingArgs? paging, CancellationToken cancellationToken = default) +{ + var parsed = _queryConfig.Parse(query).ThrowOnError(); + + // Compose the base query with required joins before applying parsed filters. + var q = + from p in _ef.Products.Model.Query() + join sc in _ef.SubCategories.Query() on p.SubCategoryCode equals sc.Code into scg + from sc in scg.DefaultIfEmpty() + join i in _ef.Inventory.Query() on p.Id equals i.Id into ig + from i in ig.DefaultIfEmpty() + select new { Product = p, sc.CategoryCode, QtyOnHand = i == null ? 0 : i.QtyOnHand }; + + return await q + .Where(parsed) + .OrderBy(parsed) + .ToMappedItemsResultAsync(x => new Contracts.ProductLite + { + Id = x.Product.Id, + Sku = x.Product.Sku, + CategoryCode = x.CategoryCode, + QtyOnHand = x.QtyOnHand + }, paging, cancellationToken); +} +``` + +Expose the query schema for the `$query` endpoint via `ToJsonSchema()`: + +```csharp +public Task QuerySchemaAsync(CancellationToken cancellationToken = default) => Task.FromResult(_queryConfig.ToJsonSchema()); +``` + +## Result<T> Pipeline in Repositories + +Using the `Result` pipeline is a developer choice — it can be applied in repositories, application services, or domain methods wherever explicit failure propagation is preferred over exceptions. When used in a repository, chain operations using `.GoAsync` / `.ThenAs` / `.ThenAsAsync`. The example below also shows Domain ↔ Persistence mapping via infrastructure mappers: + +```csharp +public Task> GetAsync(string id, CancellationToken cancellationToken = default) => Result + .GoAsync(() => _ef.Baskets.GetWithResultAsync(id, cancellationToken)) + .ThenAs(model => BasketMapper.Map(model)); + +public Task> CreateAsync(Domain.Basket basket, CancellationToken cancellationToken = default) => Result + .Go(() => + { + var model = new Persistence.Basket(); + BasketIntoMapper.MapInto(basket, model); + return model; + }) + .ThenAsAsync(model => _ef.Baskets.CreateWithResultAsync(model, cancellationToken)) + .ThenAs(b => BasketMapper.Map(b)); +``` + +## Mapping + +> **GlobalUsing requirement:** Mappers are declared in the `.Infrastructure.Mapping` namespace but referenced elsewhere in the project (repositories, adapters, `*EfDb`). Whenever you add a Contract ↔ Persistence mapper, ensure `global using .Infrastructure.Mapping;` is present in the Infrastructure project's `GlobalUsing.cs` — add it if missing. This prevents avoidable compilation errors when other classes reference the mapper (e.g. `ProductMapper.Default`). + +The `Mapping/` sub-folder contains **bidirectional mappers** between Contract types and Persistence model types. Extend `BiDirectionMapper` — do not use AutoMapper or reflection-based conventions: + +```csharp +// Infrastructure/Mapping/ProductMapper.cs +public class ProductMapper : BiDirectionMapper +{ + protected override Persistence.Product OnMap(Contracts.Product source) => new() + { + Id = source.Id!, + Sku = source.Sku!, + SubCategoryCode = source.SubCategoryCode, + Price = source.Price + }; + + protected override Contracts.Product OnMap(Persistence.Product source) => new() + { + Id = source.Id, + Sku = source.Sku, + SubCategoryCode = source.SubCategoryCode, + Price = source.Price + }; +} +``` + +> **The override is `OnMap` — there is no `OnMapToPrimary` / `OnMapToSecondary` / `MapTo…`.** `BiDirectionMapper` declares **two `OnMap` overloads with the same name**, distinguished only by **source type**: `OnMap(TFrom source)` returns `TTo` (Contract → Persistence), and `OnMap(TTo source)` returns `TFrom` (Persistence → Contract). Override **both**. Do not invent differently-named methods: +> ```csharp +> // ❌ Wrong — no such methods on BiDirectionMapper +> protected override Persistence.Product OnMapToSecondary(Contracts.Product source) => ...; +> protected override Contracts.Product OnMapToPrimary(Persistence.Product source) => ...; +> ``` +> And **`Id` is mapped explicitly** (the base does not auto-map it) — only `ETag`/`ChangeLog` are left out (see below). + +Generated persistence models inherit from `ModelBase` — or `ReferenceDataModelBase` for reference data (which extends `ModelBase`) — both in the `CoreEx.Data.Models` namespace. `ModelBase` supplies the standard `Id`, `CreatedBy`, `CreatedOn`, `UpdatedBy`, `UpdatedOn`, and `ETag` members; the EF code-generation maps the database `RowVersion` (`TIMESTAMP`) column onto the `string? ETag` property, so there is **no** `RowVersion` member on the model. `ReferenceDataModelBase` adds the standard reference-data members (`Code`, `Text`, `Description`, `SortOrder`, `IsActive`, `StartsOn`, `EndsOn`). + +Consequently, do **not** map the `IETag` (`ETag`) or `IChangeLog` (`ChangeLog`) surface in the `OnMap` overrides — the base `BiDirectionMapper` maps the inherited `ModelBase` change-log and ETag members to/from the contract automatically, in both directions. Map `Id` and the domain-specific properties explicitly (as shown above); leave the inherited base-class members to the base mapper. + +```csharp +// ❌ DO NOT do this — none of it belongs in OnMap; the base mapper already handles it. +protected override Contracts.Employee OnMap(Persistence.Employee source) => new() +{ + Id = source.Id, + // ...domain properties... + ETag = source.RowVersion is null ? null : Convert.ToBase64String(source.RowVersion), // ❌ no RowVersion member — model exposes ETag (string); base mapper owns it anyway + ChangeLog = new ChangeLog // ❌ base mapper owns the change-log surface + { + CreatedBy = source.CreatedBy, + CreatedDate = source.CreatedDate, // ❌ no such member (it is CreatedOn) AND should not be mapped at all + UpdatedBy = source.UpdatedBy, + UpdatedDate = source.UpdatedDate, // ❌ no such member (it is UpdatedOn) AND should not be mapped at all + } +}; +``` + +> Note: the `ModelBase` change-log members are `CreatedOn`/`UpdatedOn` (`DateTimeOffset?`), **never** `CreatedDate`/`UpdatedDate` — but in a mapper the entire block is removed regardless, since the base mapper owns it. + +Infrastructure-level mapping covers either **Contract ↔ Persistence** (CRUD domains) or **Domain ↔ Persistence** (domains with a Domain layer, where the aggregate is mapped to/from the persistence model). Application-level mapping (Domain aggregate ↔ Contract) lives in `Application/Mapping/` and uses `Mapper`. Do not conflate the two. + +## External Clients and Adapter Implementations + +When a domain calls another domain's API over HTTP, split the concern across two focused classes: + +- **Typed HTTP client** (`Clients/`) — thin wrapper around `HttpClient` handling serialization and response mapping to `Result` types. One class per external service. +- **Adapter implementation** (`Adapters/`) — implements the Application-layer `IXxxAdapter` interface. May combine the typed client with local EF reads and event publication. + +```csharp +// Infrastructure/Clients/ProductsHttpClient.cs +public class ProductsHttpClient(HttpClient httpClient) +{ + private readonly HttpClient _httpClient = httpClient.ThrowIfNull(); + + public async Task CreateReservationAsync(MovementRequest request, CancellationToken cancellationToken = default) + { + var response = await _httpClient.PostAsJsonAsync("api/inventory/reserve", request, JsonDefaults.SerializerOptions, cancellationToken); + return await response.ToResultAsync(); + } +} + +// Infrastructure/Adapters/ProductAdapter.cs +[ScopedService] +public class ProductAdapter(ShoppingEfDb ef, ProductsHttpClient client, IEventPublisher eventPublisher) : IProductAdapter +{ + // GetAsync — reads from the local event-replicated EF store (eventually consistent). + public Task> GetAsync(string id, CancellationToken cancellationToken = default) => Result + .GoAsync(() => _ef.Products.GetWithResultAsync(id, cancellationToken)) + .ThenAs(p => ProductMapper.From.Map(p)); + + // ReserveInventoryAsync — calls the remote API in real time (synchronous integration). + public async Task ReserveInventoryAsync(Domain.Basket basket, CancellationToken cancellationToken = default) + => await _client.CreateReservationAsync(BuildRequest(basket), cancellationToken).ConfigureAwait(false); +} +``` + +Keep the typed HTTP client and the adapter orchestration in separate, independently testable classes. + +## Generated Code + +Persistence model classes (`Persistence/*.g.cs`) and the EF `DbContext` partial (`Repositories/*DbContext.g.cs`) are generated by the domain's `*.Database` project. Never create or edit these files directly — run `dotnet run -- CodeGen` (or `dotnet run -- All`) in the `*.Database` project to regenerate. + +## ConfigureAwait + +Always call `.ConfigureAwait(false)` on every `await` inside repository and adapter methods. + +## Do Not + +- 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 `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. +- Do not edit `*.g.cs` persistence or DbContext files directly — regenerate via the `*.Database` tooling project. +- Do not add a mapper without ensuring the `.Infrastructure.Mapping` namespace is in the Infrastructure `GlobalUsing.cs`; likewise ensure `CoreEx.Data.Querying` is present when adding `QueryArgsConfig` query configuration. +- Do not mix EfDb flows — use the `...WithResultAsync` methods inside `Result` pipelines and the plain `...Async` methods for exception flow; do not wrap a throwing `...Async` call to fake a `Result`. + +## Further Reading + +- [Infrastructure Layer Guide](https://github.com/Avanade/CoreEx/blob/main/samples/docs/infrastructure-layer.md) — full walkthrough of persistence models, repositories, mapping, and external client/adapter patterns. +- [Pattern Catalog](https://github.com/Avanade/CoreEx/blob/main/samples/docs/patterns.md) — Adapter, Repository, Mapper, Persistence, and HTTP Client pattern entries. +- [Layer Dependencies](https://github.com/Avanade/CoreEx/blob/main/samples/docs/layers.md) — layer dependency rules and the role of the Infrastructure layer. +- [Tooling Guide](https://github.com/Avanade/CoreEx/blob/main/samples/docs/tooling.md) — `*.Database` project: schema, persistence-model generation, and outbox provisioning. +- [CoreEx.EntityFrameworkCore README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.EntityFrameworkCore/README.md) — `EfDb`, `EfDbModel`, `EfDbMappedModel`, and `EfDbOptions`. +- [CoreEx Results README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx/Results/README.md) — `Result` type, pipeline operators (`.GoAsync`, `.ThenAs`, `.ThenAsAsync`), and error propagation semantics. diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-tests.instructions.md b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-tests.instructions.md new file mode 100644 index 00000000..c1e27f03 --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-tests.instructions.md @@ -0,0 +1,792 @@ +--- +applyTo: "**/*.Test*/**/*.cs" +description: "Test conventions: test project types (Api/Unit/Subscribe/Relay), base classes, one-time setup patterns, and assertion helpers" +tags: ["testing", "unit-tests", "integration-tests", "test-helpers", "nunit"] +--- + +# Test Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx.UnitTesting` | Base testers and common helpers: `WithApiTester`, `WithGenericTester`, `Test.Http()`, `Test.Http()`, `Test.Scoped()`, `Test.ScopedType()`, `Test.ClearFusionCacheAsync()`, `Test.ReplaceHttpClientFactory()`; database helpers: `Test.MigrateSqlServerDataAsync()`, `Test.UseExpectedSqlServerOutboxPublisher()`, `.ExpectSqlServerOutboxEvents()`, `.ExpectNoSqlServerOutboxEvents()`, `Test.MigratePostgresDataAsync()`, `Test.UseExpectedPostgresOutboxPublisher()`, `.ExpectPostgresOutboxEvents()`, `.ExpectNoPostgresOutboxEvents()`; messaging helpers: `Test.UseExpectedAzureServiceBusPublisher()`, `Test.GetAndClearAzureServiceBusAsync()`; ASP.NET Core assertions: `.ExpectIdentifier()`, `.ExpectETag()`, `.ExpectChangeLogCreated()`, `.ExpectJsonFromResource()`, `.AssertCreated()`, `.AssertOK()`, `.AssertBadRequest()`, `.AssertErrors()`, `.AssertJsonFromResource()`, `.AssertLocationHeader()` | +| `UnitTestEx` | `MockHttpClientFactory`, `MockHttpClientRequest`, `.WithJsonResourceBody()`, `.WithAnyBody()`, `.Respond.With()`, `.Respond.WithJsonResource()`, `.Verify()` | +| `NUnit` | `[TestFixture]`, `[Test]`, `[OneTimeSetUp]` | +| `AwesomeAssertions` | `.Should()`, `.Be()`, `.HaveCount()` | + +## Project Types + +| Project suffix | Base class | Scope | +|---|---|---| +| `*.Test.Api` | `WithApiTester` | Full integration — real DB, cache, outbox, HTTP | +| `*.Test.Unit` | `WithGenericTester` | Component/unit — isolated, no infrastructure | +| `*.Test.Subscribe` | `WithApiTester` | Integration over subscriber host | +| `*.Test.Relay` | `WithApiTester` | Integration over relay host | + +**Rule**: intra-domain dependencies (database, cache, outbox) are real; inter-domain HTTP calls and direct broker publishes are always mocked. + +--- + +## Test Responsibility — what goes where (do not duplicate) + +The two test projects have **distinct, non-overlapping jobs**. Decide where a behaviour belongs by what it needs to exercise, and assert it in **one** place only. + +| Concern | Where | Why | +|---|---|---| +| **Service orchestration & repository logic** — CRUD round-trips, identifier assignment on create, persistence + mapping, query/filter behaviour, **soft-delete filtering**, eventing (outbox subject/destination), concurrency (ETag/`If-Match`), idempotency, error→HTTP mapping (404/409/412/428) | **`*.Test.Api`** (intra-domain integration) | These are *integration* behaviours — they only have meaning over the real DB/cache/outbox and the real host pipeline. Mocking them would test the mock, not the system. | +| **Isolated component logic** — **validators**, pure mappers, calculations, value-conversion helpers, and other logic with no infrastructure dependency | **`*.Test.Unit`** | Fast, exhaustive, no DB. The natural home for enumerating every rule/branch of a validator or a pure function. | + +**Do not repeat the same assertion in both projects.** Concretely: + +- **Validator rules** are proven **exhaustively in unit tests** (every mandatory/range/format/cross-field rule). In the API tests, do **not** re-enumerate them — assert **one** representative `AssertBadRequest()` + `AssertErrors(...)` case to confirm the validator is *wired into the pipeline*, then move on. **But `AssertErrors` is an exact match — not a subset.** It fails unless the listed errors are **exactly** the set the input produces (none missing, none extra). So "one representative case" means **craft the input to produce exactly the errors you assert** — e.g. send a value valid in every respect *except* the one rule under test — rather than posting an empty object (which fires *all* mandatory errors) and listing only some. (See the validators guidance — "Unit Tests".) +- **Service/repository behaviour** is proven in the **API tests** over the real database. Do **not** re-create it in unit tests with a mocked repository/UoW — that would assert the mock's configured behaviour, not the real persistence/mapping/eventing. +- When a behaviour *could* sit in either, prefer the layer that exercises it **without mocking the thing under test**: validator → unit; anything touching the DB, cache, outbox, or HTTP pipeline → API. + +The goal is a single source of truth per behaviour: unit tests guard the rules and pure logic; API tests guard the wired-up, persisted, evented system. + +--- + +## One-Time Setup + +Every integration test class must have a `[OneTimeSetUp]` method. Order of operations is fixed: + +1. Migrate + seed the domain database. +2. Clear the hybrid cache. +3. Register event-capture publishers. +4. Set up inter-domain HTTP mocks (for domains with cross-domain adapters). + +**(SQL Server example):** +```csharp +[OneTimeSetUp] +public async Task OneTimeSetUpAsync() +{ + await Test.MigrateSqlServerDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + + Test.UseExpectedSqlServerOutboxPublisher(); + Test.UseExpectedAzureServiceBusPublisher(); + + var mcf = MockHttpClientFactory.Create(); + _mockHttpReserveRequest = mcf.CreateClient("ProductsApi") + .Request(HttpMethod.Post, "api/inventory/reserve"); + Test.ReplaceHttpClientFactory(mcf); +} +``` + +**(PostgreSQL example):** +```csharp +[OneTimeSetUp] +public async Task OneTimeSetUpAsync() +{ + await Test.MigratePostgresDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + + Test.UseExpectedPostgresOutboxPublisher(); +} +``` + +**Outbox assertion helpers are database-specific.** Use `UseExpectedPostgresOutboxPublisher` / `ExpectPostgresOutboxEvents` for PostgreSQL domains; use `UseExpectedSqlServerOutboxPublisher` / `ExpectSqlServerOutboxEvents` for SQL Server domains. Never mix them. + +`DataResetFilterPredicate` in `DbMigration.ConfigureMigrationArgs` scopes the reset to the domain's own schema — multiple domains' test runs do not corrupt each other even when run concurrently. + +For the **API read/mutate test classes** (see [API Tests — Structure & Generation](#api-tests--structure--generation)), pass the class's specific dataset via the **named-file overload** so read and mutate classes load only their own data: `MigrateSqlServerDataAsync(["read-data.seed.yaml"], …)` / `MigratePostgresDataAsync(["mutate-data.seed.yaml"], …)`. The no-argument overload loads every `Data/*.seed.yaml`, which would mix the read and mutate datasets. + +--- + +## Test Data (seed files) + +Test seed data lives under `Data/` in the `*.Test.Common` project, located via the `TestData` marker class (do not rename/move it). These are **test datasets**, distinct from the production `*.Database/Data/ref-data.seed.yaml`. + +Use **one read dataset and one mutate dataset per domain** — `read-data.seed.yaml` and `mutate-data.seed.yaml` — shared across all of the domain's entities to avoid duplication. The relevant test class loads its dataset by name in `[OneTimeSetUp]`: + +```csharp +await Test.MigrateSqlServerDataAsync(["read-data.seed.yaml"], DbMigration.ConfigureMigrationArgs); // or MigratePostgresDataAsync +``` + +A developer may override/extend by passing additional files in the list when a class needs bespoke data. + +**Format** — `schema:` → `- :` → rows, where each row is an **inline object** keyed by column name. Unlike the production `ref-data.seed.yaml` (which uses `$^
` + auto-id + `code: text` shorthand), test data uses a **plain `-
:`** entry (no `$`/`$^` — it is inserted into a freshly-reset DB) and rows that **list the columns explicitly**. + +> **⚠️ All identifiers — schema, table, AND column names — must match the database's actual casing for the chosen provider** (i.e. exactly what the migration scripts created). DbEx does not case-fold them, so a wrong-cased schema/table fails the seed with *"Table '…' does not exist"*: +> - **PostgreSQL (the default provider)** → **lowercase `snake_case`**: schema `bar`, table `employee`, columns `employee_id`, `first_name`, `gender_code`. +> - **SQL Server** → **`PascalCase`**: schema `Bar`, table `Employee`, columns `EmployeeId`, `FirstName`, `GenderCode`. + +```yaml +# PostgreSQL (default) — lowercase schema/table, snake_case columns +bar: + - employee: # table — plain, no $^ prefix + - { employee_id: ^1, first_name: Bob, last_name: Smith, gender_code: M, salary: 50000, date_of_birth: 1990-01-01 } + - { employee_id: ^2, first_name: Jane, last_name: Doe, gender_code: F, salary: 60000, date_of_birth: 1985-06-15 } +``` + +```yaml +# SQL Server — PascalCase schema/table/columns +Bar: + - Employee: + - { EmployeeId: ^1, FirstName: Bob, LastName: Smith, GenderCode: M, Salary: 50000, DateOfBirth: 1990-01-01 } +``` + +- **`^N` is a deterministic GUID** — `^1` equals `1.ToGuid()`. Use it for the **identifier** and for any **GUID foreign-key reference** to another seeded row (e.g. a `movement` row with `{ product_id: ^1 }` points at the product seeded as `^1`). This is what lets a test target a specific row by the same `N.ToGuid()`. +- Reference data is linked **by code** — `gender_code: M` (PostgreSQL) / `GenderCode: M` (SQL Server), not an id/FK. +- Set scenario flags explicitly where a test needs them — e.g. `is_deleted: true`, `is_inactive: true` (PostgreSQL casing shown). + +> ### ⚠️ Writing the GUID literal in resource files (`.res.json` / `.event.json`) +> +> In seed YAML and test code, use `^N` / `N.ToGuid()` — **never hand-write the GUID**. But resource files can't call code, so when a deterministic id/FK must appear as a **literal string** you must compute `N.ToGuid()` correctly. Two rules the agent gets wrong: +> +> 1. **The number goes in the FIRST segment, not the last.** `1.ToGuid()` is `00000001-0000-0000-0000-000000000000`, **not** `00000000-0000-0000-0000-000000000001`. +> 2. **The first segment is the number in lowercase HEXADECIMAL, zero-padded to 8 digits — not decimal.** It is *not* a straight digit substitution; convert to hex. (For `N ≤ 9` hex and decimal coincide, which hides the bug; it surfaces at `N ≥ 10`.) +> +> | `N` | first segment (hex) | full `N.ToGuid()` literal | +> |---|---|---| +> | 1 | `00000001` | `00000001-0000-0000-0000-000000000000` | +> | 2 | `00000002` | `00000002-0000-0000-0000-000000000000` | +> | 12 | `0000000c` | `0000000c-0000-0000-0000-000000000000` | +> | 16 | `00000010` | `00000010-0000-0000-0000-000000000000` | +> | 255 | `000000ff` | `000000ff-0000-0000-0000-000000000000` | +> | 1000 | `000003e8` | `000003e8-0000-0000-0000-000000000000` | +> +> Formula: `N.ToString("x8") + "-0000-0000-0000-000000000000"`. When in doubt, prefer excluding the volatile `id` from the resource (the `Expect*` helpers / `.AssertJsonFromResource(..., "id")` auto-exclude it) so no literal is needed; only write the literal for a **non-id** field that genuinely carries a deterministic GUID (e.g. a foreign-key column in a query result or event payload). + +In the test, reference the seeded row by the same number: + +```csharp +Test.Http().Run(HttpMethod.Get, $"/api/employees/{1.ToGuid()}").AssertOK(); // the EmployeeId: ^1 row +``` + +--- + +## API Tests — Structure & Generation + +Intra-domain API (integration) tests run the real host over the real DB/cache/outbox (`WithApiTester<{Solution}.Api.Program>` — reference `Program` fully-qualified; it is `public`, no extra `using` needed). The `*.Test.Api` project's `GlobalUsing.cs` (shipped by the template) already provides the imports and the two aliases the setup uses — **`DbMigration`** (= `{Solution}.Database.Program`, for `DbMigration.ConfigureMigrationArgs`) and **`TestData`** (= `{Solution}.Test.Common.TestData`, the embedded-data marker) — plus `{Solution}.Contracts`, CoreEx, UnitTestEx, AwesomeAssertions, NUnit. Use those aliases; don't re-derive them. + +Organise them **per entity**, split read vs mutate, one file per operation: + +| Class (per entity) | Endpoints | `[OneTimeSetUp]` seeds | +|---|---|---| +| `XxxReadTests` | reads (GET/query) | `read-data.seed.yaml` | +| `XxxMutateTests` | mutations (POST/PUT/PATCH/DELETE) | `mutate-data.seed.yaml` | + +Each is a **`partial class`**; the `[OneTimeSetUp]` lives in `XxxReadTests.cs` / `XxxMutateTests.cs`, and **each operation goes in its own partial sub-file** `Xxx{Read|Mutate}Tests.{Operation}.cs` (Operation = the controller operation: `Get`, `Query`, `Create`, `Update`, `Patch`, `Delete`, …) — isolating operations for discoverability and maintenance. Example for `Employee`: + +``` +EmployeeReadTests.cs // [OneTimeSetUp] → read-data.seed.yaml + ClearFusionCacheAsync +EmployeeReadTests.Get.cs +EmployeeReadTests.Query.cs +EmployeeMutateTests.cs // [OneTimeSetUp] → mutate-data.seed.yaml (+ outbox publisher, HTTP mocks) +EmployeeMutateTests.Create.cs +EmployeeMutateTests.Update.cs +EmployeeMutateTests.Patch.cs +EmployeeMutateTests.Delete.cs +``` + +```csharp +// EmployeeMutateTests.cs +public partial class EmployeeMutateTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(["mutate-data.seed.yaml"], DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + Test.UseExpectedSqlServerOutboxPublisher(); // provider-specific; see One-Time Setup + } +} + +// EmployeeMutateTests.Create.cs +public partial class EmployeeMutateTests +{ + [Test] + public void Create_Success() => /* Test.Http()… .AssertCreated()… */; +} +``` + +**Isolation:** read and mutate are separate classes with separate seed files, so mutations never disturb read expectations. Within a `XxxMutateTests` class, write each test to be **independent** — act on a distinct seeded id or create its own data; never depend on another test's side effects or run order. + +> **One seed row per _destructive test_ — not per operation.** Every test that **writes** to the database (Update, Patch, Delete, …) must target its **own** `^N` id. It is **not** enough to give each *operation* a distinct row — two **tests** that mutate the same row fail non-deterministically because **NUnit randomises execution order** (e.g. a Delete test running before an Update test that shares the row). Provision one row per destructive test **up front** in `mutate-data.seed.yaml`; don't add rows reactively after a collision surfaces. Canonical assignment for full CRUD: +> +> | `^N` | Test | Notes | +> |---|---|---| +> | `^1` | `Update_Success` | `Update_NotFound` uses a non-existent id; `Update_ConcurrencyError` only reads `^1` (rolls back) so it may share it | +> | `^2` | `Delete_*` (exists → idempotent flow) | the row is removed by the test | +> | `^3` | `Patch_Success` | | +> +> Non-mutating tests (Get, Query, 304, validation bad-requests) can freely share read rows — the rule is specifically about tests that **commit a write**. + +**Expected `.req.json` / `.res.json` resources** (the JSON representation of the request/response) live under `Resources/{TestClass}/…` and are referenced via `.ExpectJsonFromResource(...)` / `.AssertJsonFromResource("EmployeeReadTests.Employee_Get_Found.res.json", "etag", "changelog")` (exclude volatile fields). **Pre-author them from the seed values** — you control the seed, so you can write the expected JSON up front (remember to exclude/expect the volatile `id`/`etag`/`changelog`); then **run once and reconcile** any remaining differences from the actual output (they are intentionally copy-paste-friendly). Expect the first run to need a small fix-up; that's normal, not a failure to avoid. They scale better than inline assertions as entities grow. + +> **Agent instruction — co-design seed, tests, and resources together.** These three must agree, so author them in order: +> 1. **Seed first** — add/extend the domain's `read-data.seed.yaml` / `mutate-data.seed.yaml` with the known rows the tests will reference, as object rows with deterministic **`^N` ids** (= `N.ToGuid()`); for mutate, the baselines a test needs — e.g. an existing SKU for a duplicate-conflict test, a row to update/delete. +> 2. **Tests next** — one partial file per operation; reference seeded rows via `n.ToGuid()` / known codes; keep mutate tests independent. +> 3. **Resources last** — capture `.res.json`/`.req.json` from the actual run; the response resource must match the seeded values. +> +> Cover, per operation: **Get** (found, not-found, ETag/not-modified); **Query** (filter, order, paging, field selection); **Create** (success + Location + outbox event, bad-data validation, duplicate/conflict, idempotency-key); **Update** (success + ETag/concurrency, not-found); **Patch** (merge success, not-found); **Delete** (idempotent — always 204, never 404): **get → delete → get → delete** = exists → 204+event → now 404 → 204 with **no** event. Assert outbox events on mutating success paths (provider-specific `ExpectXxxOutboxEvents`) and `ExpectNoXxxOutboxEvents` on failure paths. + +--- + +## API Test Pattern + +Use the `Test.Http()` / `Test.Http()` fluent chain: **set expectations → execute → assert**. + +```csharp +// GET +Test.Http() + .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}") + .AssertOK() + .AssertJsonFromResource("ReadTests.Product_Get_Found.res.json", "etag", "changelog"); + +// POST — PostgreSQL domain (Postgres outbox) +var created = Test.Http() + .ExpectIdentifier() + .ExpectETag() + .ExpectChangeLogCreated() + .ExpectJsonFromResource("ProductMutateTests.Create_Success.res.json") + .ExpectPostgresOutboxEvents(e => e + .AssertWithValue("contoso", "contoso.products.product.created.v1")) + .Run(HttpMethod.Post, "/api/products", product) + .AssertCreated() + .AssertLocationHeader(r => new Uri($"/api/products/{r!.Id}", UriKind.Relative)) + .Value!; + +// POST — SQL Server domain (SQL Server outbox) +Test.Http() + .ExpectSqlServerOutboxEvents(e => e + .AssertWithValue("contoso", "contoso.shopping.basket.checkedout.v1")) + .Run(HttpMethod.Post, $"/api/baskets/{basketId}/checkout", checkoutRequest) + .AssertOK(); + +// Validation error +Test.Http() + .Run(HttpMethod.Post, "/api/products", invalidProduct) + .AssertBadRequest() + .AssertErrors("Text is required.", "Price must be greater than or equal to zero."); + +// Assert no events on failure path +Test.Http() + .ExpectNoSqlServerOutboxEvents() + .Run(HttpMethod.Post, $"/api/baskets/{basketId}/checkout") + .AssertBadRequest(); +``` + +### Expectation helpers (assert *and* auto-exclude from the JSON compare) + +These `Test.Http()` expectations both **assert the value is present** and **automatically exclude it from the JSON comparison** — so the `.res.json` should **omit** these fields and you do **not** list them as manual excludes: + +- `ExpectIdentifier()` — asserts `Id` has a value; excludes it from the compare. +- `ExpectETag()` — asserts `ETag` has a value; excludes it. +- `ExpectChangeLogCreated()` / `ExpectChangeLogUpdated()` — assert the `ChangeLog` create/update values are set; exclude `changelog`. + +(For a plain GET via `.AssertJsonFromResource(...)` *without* these expectations, exclude the volatile fields manually — e.g. `"etag", "changelog"` — as in the GET example above.) + +### Update (PUT) and Patch with ETag (concurrency) + +Send the ETag for concurrency-controlled mutations. **Fetch the current entity first** to get its `ETag`, then PUT with `If-Match`: + +```csharp +// Arrange: get the current row (the EmployeeId: ^1 seed) to obtain its ETag. +var val = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}").AssertOK().Value!; +val.Text = "Updated text"; + +// Act/Assert: PUT with If-Match. +Test.Http() + .Run(HttpMethod.Put, $"/api/products/{val.Id}", val, requestModifier: r => r.WithIfMatch(val.ETag)) + .AssertOK(); +``` +> If the serialized request body already contains `ETag` and no `If-Match` header is set, that body `ETag` is used as the concurrency token. + +For **PATCH**, also set the **merge-patch content type** (the request default is plain JSON): + +```csharp +Test.Http() + .Run(HttpMethod.Patch, $"/api/products/{p.Id}", new { text = p.Text }, + requestModifier: r => r.WithIfMatch(val.ETag).WithMergePatchJsonContentType()) + .AssertOK(); +``` + +**Concurrency error (stale ETag) → 412.** Supply a **wrong** `If-Match` and assert `AssertPreconditionFailed()`. The `If-Match` **header takes precedence** over any `ETag` in the body, so you do **not** need to clear `val.ETag` — the header value drives the concurrency check: + +```csharp +var p = Test.Http().Run(HttpMethod.Get, $"/api/products/{6.ToGuid()}").AssertOK().Value!; +p.Text += " Updated"; +Test.Http() + .ExpectNoSqlServerOutboxEvents() // a rejected update commits nothing → emits nothing + .Run(HttpMethod.Put, $"/api/products/{p.Id}", p, requestModifier: r => r.WithIfMatch("AAAAAAAA")) + .AssertPreconditionFailed(); // 412 — NOT AssertConflict()/409 +``` + +This is **412 Precondition Failed** (`ConcurrencyException`), not 409. Use `.AssertPreconditionFailed()`. (409/`AssertConflict()` is for duplicate-key/business conflicts only — see the HTTP assertion table.) + +### Conditional GET with If-None-Match → 304 Not Modified + +For a conditional read, use the **`WithIfNoneMatch(...)`** request-modifier helper (the read-side counterpart of `WithIfMatch`) and assert `AssertNotModified()`. **Fetch once to obtain the response ETag**, then re-GET with it: + +```csharp +// 1. GET to obtain the current ETag from the response headers. +var r = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}") + .AssertOK() + .Response; + +// 2. Conditional GET with If-None-Match → 304. +Test.Http() + .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}", requestModifier: rm => rm.WithIfNoneMatch(r.Headers.ETag!.Tag)) + .AssertNotModified(); +``` + +> **Use the `WithIfNoneMatch(...)` helper — do not set the header by hand.** `If-None-Match` requires a **quoted entity-tag** (`""`, RFC 7232); `r.Headers.Add("If-None-Match", etag)` throws **`FormatException`** on an unquoted value. `WithIfNoneMatch(...)` (and passing `response.Headers.ETag.Tag`, which is already quoted) handles the format for you — mirror it rather than reaching for raw header manipulation. + +### Update of a non-existent id → 404 (ETag + value still required) + +Concurrency is checked **before** existence. So a "PUT a non-existent id → `NotFound`" test must **still send a valid ETag and a full value body** — otherwise the precondition check fires first and you get **`428 Precondition Required`** ("*A concurrency error occurred; an ETag is required either as an If-Match header (preferred) or specified within the request body (where supported).*"), not the `404` the test intends. + +```csharp +// ✅ Correct — supply an ETag (any well-formed value) and a complete body; the 404 comes from the row not existing. +var val = new Product { Id = 404.ToGuid().ToString(), Text = "Does not exist" /* ...all mandatory fields... */ }; +Test.Http() + .Run(HttpMethod.Put, $"/api/products/{val.Id}", val, requestModifier: r => r.WithIfMatch("any-etag")) + .AssertNotFound(); + +// ❌ Wrong — no If-Match / no body ETag → 428 Precondition Required, never reaches the not-found check. +Test.Http() + .Run(HttpMethod.Put, $"/api/products/{404.ToGuid()}", val) + .AssertNotFound(); // fails: actual is 428 +``` + +The ETag value need not match anything (the row doesn't exist) — it only has to be **present** to clear the precondition gate. The body must still satisfy model validation (all mandatory fields), since validation also precedes the repository lookup. + +### Get of a soft-deleted row → 404 (when `IsDeleted` is supported) + +When the entity supports soft-delete (`IsDeleted` column), a row flagged deleted must be **invisible to reads** — a `GET` of it returns **`404 Not Found`**, exactly as if it never existed. This needs its own test because a row that is *present in the table* but filtered out is a different code path from a row that was never seeded. + +1. **Seed a deleted row** — add a row to the domain's read seed file with the soft-delete flag set (provider casing — `is_deleted` PostgreSQL / `IsDeleted` SQL Server): + +```yaml +# PostgreSQL (default) +bar: + - product: + - { product_id: ^9, name: Ghost, sku: GHOST-1, is_deleted: true } # soft-deleted — must not be readable +``` + +2. **Assert the GET 404s** (and that it does **not** appear in a list/query): + +```csharp +// Direct get of the soft-deleted row → 404. +Test.Http().Run(HttpMethod.Get, $"/api/products/{9.ToGuid()}").AssertNotFound(); +``` + +The point of the test is that the soft-delete filter is actually applied on read — a missing filter would return the row with `200 OK` instead of `404`. (Optionally also assert the row is absent from a collection/query result.) + +### Delete — get → delete → get → delete (idempotent) + +The canonical delete test is a **four-step** flow that proves both the delete and its **idempotency**: GET (exists) → DELETE (204 + event) → GET (now 404) → DELETE again (204, **no** event). `DELETE` **always** returns **204 No Content** — **never 404**, even for a non-existent or already-deleted id; only the **first** delete (where the row existed) emits the event. The 404 belongs to the **GET**, not the DELETE. + +```csharp +// 1. Confirm it exists (the EmployeeId: ^2 seed). +Test.Http().Run(HttpMethod.Get, $"/api/products/{2.ToGuid()}").AssertOK(); + +// 2. Delete — 204, and a "deleted" outbox event. Delete returns NO value body → assert METADATA + KEY (not value); +// the key is the deleted id carried via .WithKey(id); the subject has NO version suffix. +Test.Http() + .ExpectPostgresOutboxEvents(e => e.AssertMetadata("contoso", "contoso.products.product.deleted", 2.ToGuid().ToString())) + .Run(HttpMethod.Delete, $"/api/products/{2.ToGuid()}") + .AssertNoContent(); + +// 3. Confirm it is gone — the GET now 404s (it is the GET, not the DELETE, that returns not-found). +Test.Http().Run(HttpMethod.Get, $"/api/products/{2.ToGuid()}").AssertNotFound(); + +// 4. Repeat delete is idempotent — still 204, but emits NO event. +Test.Http() + .ExpectNoPostgresOutboxEvents() + .Run(HttpMethod.Delete, $"/api/products/{2.ToGuid()}") + .AssertNoContent(); +``` + +A delete of a **non-existent** id behaves like step 4 — 204 No Content with no event. **Never assert `AssertNotFound()` on a `DELETE`.** + +### Outbox event assertions — destination & subject + +Assert published events with `ExpectXxxOutboxEvents(e => …)` (provider-specific). **Pick the assertor by whether the event carries a value:** + +- **`.AssertWithValue(destination, subject)`** — for **value-carrying** events (Create/Update). It reconstructs the expected `EventData` **from the API's returned value** and JSON-compares the event body. Only valid when the operation returns a value. +- **`.AssertMetadata(destination, subject, key)`** — for **no-value** events (**Delete**, or any operation returning `204 No Content`). It asserts the **metadata only** — destination + subject — plus the **`key`** (the `CloudEvent.Subject`, i.e. the `EventData.Key` set via `.WithKey(id)` in the service). There is no value to compare, so `AssertWithValue` would have nothing to reconstruct from — use `AssertMetadata` and pass the deleted id (e.g. `2.ToGuid().ToString()`) as the key. + +The `destination` and `subject` strings: + +- **`destination`** — the topic/queue the event is published to: the `CoreEx:Events:Destination` value from `appsettings.json` (the domain's messaging topic, e.g. `contoso`). Do not assume it equals the subject's first segment. +- **`subject`** — composed as **`{solutionname}.{domainname}.{entity}.{action}`** + an **optional `.v{major}` version suffix**, all **lower-case**, dot-separated: + - `solutionname` / `domainname` — from `CoreEx:Host:SolutionName` / `:DomainName` in `appsettings.json` (lower-cased; `SolutionName` may itself contain dots, e.g. `my.foo`). + - `entity` — the entity/contract name (e.g. `employee`). + - `action` — the **past-tense `EventAction`** set in the **service code** (`EventData.CreateEventWith(v, EventAction.Created)` → `created`; `Updated` → `updated`; `Deleted` → `deleted`). + - **`.v{major}` version suffix — present *only when the event carries a value*** (e.g. Create/Update). Its value is the **major** of the contract's `[Schema("v2.0")]` attribute → `.v2`; **unannotated defaults to `.v1`**. An event with **no value** (e.g. Delete) has **no version suffix at all**. + +```csharp +// Create — has a value → AssertWithValue, version suffix (default v1, or the [Schema] major). +.ExpectPostgresOutboxEvents(e => e.AssertWithValue("contoso", "contoso.products.product.created.v1")) +// Delete — no value → AssertMetadata with the key (the deleted id); no version suffix. +.ExpectPostgresOutboxEvents(e => e.AssertMetadata("contoso", "contoso.products.product.deleted", 2.ToGuid().ToString())) +``` + +So a `[Schema("v2.0")] Product` create event is `…product.created.v2`. If unsure of the exact subject, **run the test once** — the assertion failure reports the actual event subject; copy it verbatim. **Delete** events carry no value body but do carry the key (`EventData.CreateEvent(EventAction.Deleted).WithKey(id)`); assert them with **`AssertMetadata(destination, "…deleted", key)`** — the unversioned `…deleted` subject plus the id as the key (there is no value to JSON-compare, so `AssertWithValue` does not apply). + +### HTTP assertion methods + +Use the `Assert*` helper matching the expected status; for anything not listed, assert `.Response.StatusCode` directly. + +| Outcome | Helper | +|---|---| +| 200 OK | `.AssertOK()` | +| 201 Created | `.AssertCreated()` (pair with `.AssertLocationHeader(...)`) | +| 204 No Content (DELETE) | `.AssertNoContent()` | +| 304 Not Modified (ETag / If-None-Match) | `.AssertNotModified()` | +| 400 Bad Request | `.AssertBadRequest()` + `.AssertErrors("…")` | +| 404 Not Found | `.AssertNotFound()` | +| 409 Conflict — **duplicate key / business conflict** (`DuplicateException` / `ConflictException`) | `.AssertConflict()` | +| 412 Precondition Failed — **stale/mismatched ETag** (`ConcurrencyException`) | `.AssertPreconditionFailed()` | +| 428 Precondition Required — **no ETag supplied** on a concurrency-controlled mutation | `.Assert(HttpStatusCode.PreconditionRequired)` | + +> **409 vs 412 — different problems, don't conflate.** A **concurrency** failure (the supplied ETag doesn't match the current row) is **412 Precondition Failed** (`ConcurrencyException`) — use `.AssertPreconditionFailed()`. **409 Conflict** is only a **duplicate-key / unique-constraint or business conflict** (`DuplicateException`/`ConflictException`) — e.g. creating a second row with an existing SKU. A stale-ETag update is **never** 409. (And a mutation that supplies **no** ETag at all fails the precondition gate with **428**, not 412 — see "Update of a non-existent id".) + +### `Test.Http()` vs `Test.Http()` + +Use **`Test.Http()`** when you need the deserialized response `.Value` (e.g. to capture the created entity, then re-`Get` and compare). Use the untyped **`Test.Http()`** when you only assert status / `.AssertJsonFromResource(...)` / errors. + +### Test host configuration — leave appsettings alone + +Do **not** create or edit `appsettings*.json` (including `appsettings.unittest.json`) for API tests. The test host runs via `WebApplicationFactory`, which loads the host's **Development** settings automatically — connection strings, cache, messaging, etc. are already wired. There is nothing for the agent to configure here. + +## Resource-Based JSON Assertions + +Expected response bodies live in `Resources/` as `.res.json` files. Reference them by dot-separated path. Exclude volatile fields as extra parameters: + +```csharp +.AssertJsonFromResource("ReadTests.Product_Get_Found.res.json", "etag", "changelog"); +.AssertJsonFromResource("Basket_Checkout_Insufficient_Quantity.products.res.json", "traceid"); +``` + +Mock request bodies use `.req.json`; mock response bodies from a downstream API use `.{domain}.res.json` (prefixed with the remote domain name by convention). + +**Reference-data properties serialize by their non-`Code` name, value only.** A contract `[ReferenceData] string? GenderCode` property serializes to JSON as **`"gender": "M"`** (the Roslyn generator sets the `JsonPropertyName` to the non-`Code` suffix and emits just the code value). So in `.res.json` / `.req.json` resources — and in any inline request body — use the **non-`Code`** JSON name (`gender`, `subCategory`, `unitOfMeasure`), even though the C# contract property is `GenderCode` / `SubCategoryCode` / `UnitOfMeasureCode`. (Note the three distinct representations: C# property `GenderCode` → JSON `gender` → DB/seed column `GenderCode`/`gender_code`.) + +A ref-data value (e.g. `"gender": "M"`) is **real, deterministic data — include it in the `.res.json` and do not exclude it.** The **only** volatile fields are `id`, `etag`, and `changelog` (server-assigned/timestamped); exclude those — via the `Expect*` helpers on mutations (which auto-exclude), or as explicit `.AssertJsonFromResource(..., "etag", "changelog")` arguments on a plain GET. + +--- + +## HTTP Client Mocking + +Declare `MockHttpClientRequest` fields at class level; configure responses per test; always call `.Verify()` after the action: + +```csharp +// Class level +private MockHttpClientRequest _mockHttpReserveRequest = null!; + +// OneTimeSetUp +var mcf = MockHttpClientFactory.Create(); +_mockHttpReserveRequest = mcf.CreateClient("ProductsApi") + .Request(HttpMethod.Post, "api/inventory/reserve"); +Test.ReplaceHttpClientFactory(mcf); + +// In test — success path +_mockHttpReserveRequest + .WithJsonResourceBody("Basket_Checkout_Success.products.req.json") + .Respond.With(HttpStatusCode.OK); +_mockHttpReserveRequest.Verify(); + +// In test — error path +_mockHttpReserveRequest.WithAnyBody() + .Respond.WithJsonResource( + "Basket_Checkout_Insufficient_Quantity.products.res.json", + HttpStatusCode.BadRequest, + System.Net.Mime.MediaTypeNames.Application.ProblemJson); +_mockHttpReserveRequest.Verify(); +``` + +--- + +## Unit Tests — Validators + +Unit tests are for logic with **no external dependencies**; any injected services are **mocked**. **Validators are the primary unit-test target** — they encode the most conditional logic. Application service orchestration is exercised by the host integration tests (`*.Test.Api` / `*.Test.Subscribe`), **not** here. + +Maintain **one test class per validator**, under `*.Test.Unit/Validators/`, named `{Validator}Tests` and extending `WithGenericTester` — `EntryPoint` (in the template) configures the DI/host services the validator needs. Each `[Test]` runs inside `Test.Scoped(test => { ... })`. Invoke the validator exactly as the application does: + +> **⚠️ `Test.Scoped` takes no type parameter for validator tests.** Use the **non-generic** `Test.Scoped(test => { … })` and invoke the validator via its `Default` singleton (or `new XxxValidator(deps)`). Do **not** write `Test.Scoped(v => …)` — the generic overload **resolves `XxxValidator` from DI**, but validators are **not registered in DI** (see `coreex-validators.instructions.md`), so it fails. The validator is created explicitly (`.Default` / `new`), never resolved. + +> **Do not manage `ExecutionContext` in tests.** `Test.Scoped(...)` (within `WithGenericTester` / `WithApiTester`) establishes a valid `ExecutionContext` for the scope automatically — so do **not** construct, inject, mock, or otherwise set it up in a test. The ambient `Runtime` (clock/GUID) and any `ExecutionContext`-dependent rule "just work" inside the scope. Write the test as if at runtime; the harness handles the context. (The `Test.ScopedType` you may see in **Outbox Relay** tests is a *specialised* technique for writing events directly to the outbox under a scoped context — it is **not** something validator or API tests need.) + +```csharp +public class ProductValidatorTests : WithGenericTester +{ + [Test] + public void Empty_Required() => Test.Scoped(test => + { + var p = new Product(); + ProductValidator.Default.AssertErrors(p, + ("sku", "Sku is required."), + ("text", "Text is required."), + ("subCategory", "Sub-category is required."), + ("unitOfMeasure", "Unit-of-measure is required.")); + }); + + [Test] + public void Success() => Test.Scoped(test => + { + var p = new Product { Sku = "X", Text = "Test", SubCategoryCode = "XC", UnitOfMeasureCode = "EA", Price = 9.99m }; + ProductValidator.Default.AssertSuccess(p); + }); +} +``` + +- **`Validator`** (has a `Default`) → call `XxxValidator.Default`. +- **`Validator`** (injected deps) → mock the dependency with `Mock` configured in `[OneTimeSetUp]`, then `new XxxValidator(_mock.Object)`: + +```csharp +private readonly Mock _mock = new(); + +[OneTimeSetUp] +public void OneTimeSetUp() => _mock.Setup(x => x.GetForReservationAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { ["P1"] = new() { UnitOfMeasureCode = "EA" } }); + +[Test] +public void Invalid_Product() => Test.Scoped(test => + new MovementRequestValidator(_mock.Object).AssertErrors(req, + ("products.P2", "Product is non-stocked and therefore cannot be transacted."))); +``` + +### Asserting outcomes + +- **`AssertSuccess(value)`** — asserts the value passes (no errors). +- **`AssertErrors(value, (jsonName, text)…)`** — asserts the **exact** set of expected errors. Each tuple is `("", "")`. **Order does not matter**, but **every** produced error must be accounted for (and there must be no extras). Use **JSON** property names (camelCase) with these path forms: + - **Nested object** — dotted: `person.address.street`. + - **Array / list item** — `[index]`: `person.addresses[0].street`. + - **Dictionary** — `..`: e.g. `products.P1.unitOfMeasure` means the `products` dictionary, key `P1`, and `unitOfMeasure` is a property of that entry's value. An error on the entry's value itself is just `.` (e.g. `products.P1`). The **actual key** is the path segment — there is no `.value` segment. The literal `key` segment (e.g. `products.key`) appears **only** when the dictionary key is itself null/empty (so there is no key value to name) — it flags the missing/blank key to the consumer. + + If unsure of the exact path a rule produces, confirm it against the validator's actual output rather than guessing. + +### Expected message text + +Error text derives from the standard templates in [`ValidatorStrings.cs`](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Validation/ValidatorStrings.cs) (unless a rule overrides the whole message via `.Error(...)`). Placeholders: `{0}` = the property's localized text (label), `{1}` = the value being validated, `{2}` onward = rule-specific extras (compare-to value, max length, etc.). Compose the expected string from the template + label + extras — e.g. `MandatoryFormat` "{0} is required." → `"Sku is required."`; `CompareGreaterThanEqualFormat` "{0} must be greater than or equal to {2}." with compare-text `"zero"` → `"Price must be greater than or equal to zero."`. + +> **⚠️ Label casing — sentence case, not Title Case.** The `{0}` label is derived from the property name by splitting on CamelCase and **capitalising only the first word** (the rest are lower-cased). Do **not** read the label as a title-cased echo of the property name: +> +> | Property | Label `{0}` | `… is required.` | +> |---|---|---| +> | `FirstName` | `First name` | `"First name is required."` (not "First Name") | +> | `LastName` | `Last name` | `"Last name is required."` | +> | `DateOfBirth` | `Date of birth` | `"Date of birth is required."` | +> | `Salary` | `Salary` | `"Salary is required."` | +> | `Gender` | `Gender` | `"Gender is required."` | +> +> Single-word properties are simply capitalised. Always derive the label from the **split + sentence-case** rule, not from a Title-Case reading of the identifier. (A `[Display(Name = …)]`/`.Text(…)` override replaces the derived label entirely.) + +> **Exact `PrecisionScale` scale message.** A scale (decimal-places) violation uses `DecimalPlacesFormat` → `"{0} exceeds the maximum decimal places ({2})."` — e.g. `Property(p => p.Salary).PrecisionScale(18, 2)` with too many decimals → `"Salary exceeds the maximum decimal places (2)."` Note the **short** form ("maximum decimal places"), **not** "maximum specified number of decimal places". If unsure of any exact string, run the test once and copy the produced message verbatim rather than guessing. + +> **Exact length-rule messages.** The string length rules all end with **"character(s) in length."** (the literal `(s)` is always present, regardless of the count) — do not shorten to "characters.": +> - `MaximumLength(n)` → `MaxLengthFormat` `"{0} must not exceed {2} character(s) in length."` — e.g. `Property(p => p.Text).MaximumLength(250)` → `"Text must not exceed 250 character(s) in length."` +> - `MinimumLength(n)` → `MinLengthFormat` `"{0} must be at least {2} character(s) in length."` +> - `Length(exact)` → `ExactLengthFormat` `"{0} must be exactly {2} character(s) in length."` + +> **Quick reference — rule → exact message** (`{Label}` = sentence-cased property label per the rule above; every string verified against the master `ValidatorStrings.cs` in `CoreEx.Validation`). This covers the common rules; for anything not listed, consult `ValidatorStrings.cs` — it is the authoritative source (don't guess the wording). +> +> | Rule (fluent) | `ValidatorStrings` key | Produced message | +> |---|---|---| +> | `Mandatory()` | `MandatoryFormat` | `{Label} is required.` | +> | `None()` | `NoneFormat` | `{Label} must not be specified.` | +> | `Equal(v)` | `CompareEqualFormat` | `{Label} must be equal to {v}.` | +> | `NotEqual(v)` | `CompareNotEqualFormat` | `{Label} must not be equal to {v}.` | +> | `GreaterThan(v)` | `CompareGreaterThanFormat` | `{Label} must be greater than {v}.` | +> | `GreaterThanOrEqualTo(v)` | `CompareGreaterThanEqualFormat` | `{Label} must be greater than or equal to {v}.` | +> | `LessThan(v)` | `CompareLessThanFormat` | `{Label} must be less than {v}.` | +> | `LessThanOrEqualTo(v)` | `CompareLessThanEqualFormat` | `{Label} must be less than or equal to {v}.` | +> | `Between(min,max)` / `InclusiveBetween` | `BetweenInclusiveFormat` | `{Label} must be between {min} and {max}.` | +> | `ExclusiveBetween(min,max)` | `BetweenExclusiveFormat` | `{Label} must be between {min} and {max} (exclusive).` | +> | `MaximumLength(n)` | `MaxLengthFormat` | `{Label} must not exceed {n} character(s) in length.` | +> | `MinimumLength(n)` | `MinLengthFormat` | `{Label} must be at least {n} character(s) in length.` | +> | `Length(n)` (exact) | `ExactLengthFormat` | `{Label} must be exactly {n} character(s) in length.` | +> | `PrecisionScale(p,s)` — scale | `DecimalPlacesFormat` | `{Label} exceeds the maximum decimal places ({s}).` | +> | `PrecisionScale(p,s)` — precision | `MaxDigitsFormat` | `{Label} exceeds the maximum digits (n).` | +> | `Numeric(allowNegatives: false)` | `AllowNegativesFormat` | `{Label} must not be negative.` | +> | `Email()` | `EmailFormat` | `{Label} is an invalid e-mail address.` | +> | `Matches(regex)` | `RegexFormat` | `{Label} is invalid.` | +> | `Wildcard()` | `WildcardFormat` | `{Label} contains invalid or non-supported wildcard selection.` | +> | `.IsValid()` (ref-data) | `InvalidFormat` | `{Label} is invalid.` | +> | `Collection(minCount: n)` | `MinCountFormat` | `{Label} must have at least {n} item(s).` | +> | `Collection(maxCount: n)` | `MaxCountFormat` | `{Label} must not exceed {n} item(s).` | +> | `Duplicate()` | `DuplicateFormat` | `{Label} already exists and would result in a duplicate.` | +> | `NotFound()` | `NotFoundFormat` | `{Label} was not found.` | +> | `Immutable()` | `ImmutableFormat` | `{Label} is not allowed to change; please reset value.` | +> | `Collection(item: …)` with invalid child items | `InvalidItemsFormat` | `{Label} contains one or more invalid items.` | +> +> For the comparison rules, `{v}`/`{min}`/`{max}` are the literal values — **unless** a message-text delegate is supplied (e.g. `GreaterThanOrEqualTo(0, _ => "zero")` → `"… greater than or equal to zero."`); the delegate text replaces the value token. `AssertErrors` expects **`(jsonName, message)` tuples** (camelCase JSON property path, full message) — never bare message strings. Default texts can be overridden globally via `ValidatorStrings` / localization, so if a project customises them, the project's value wins — but the defaults above are what a stock CoreEx solution produces. + +### Reference data in unit tests + +Validators that use reference data (`.IsValid()`, etc.) resolve it through `EntryPoint.ReferenceDataServiceDecorator`, which loads the **real seeded data** so tests use representative values rather than invented ones. When a validator under test needs a ref-data type the decorator does not yet handle, **add a new arm to** its `GetAsync` switch — inserting it **before** the final `_ => throw …` catch-all: + +```csharp +public override Task GetAsync(Type type, CancellationToken cancellationToken = default) => type switch +{ + _ when type == typeof(Gender) => Task.FromResult((IReferenceDataCollection)jdr.Deserialize("Bar.$^Gender")!), + // ...other ref-data arms... + _ => throw new InvalidOperationException($"Type {type.FullName} is not a known {nameof(IReferenceData)}.") // ← never remove this catch-all +}; +``` + +**Never remove or replace the final `_ => throw …` catch-all arm** — only add arms above it. It is the guard that surfaces an unhandled ref-data type; dropping it (e.g. "replacing the throw-only body") would silently break the decorator. + +**Mirror `ReferenceDataService.g.cs` exactly — dispatch on the item type, not the collection.** The decorator's `switch` must match the generated `ReferenceDataService.g.cs` `GetAsync(Type type)`: it keys on the **reference-data item type** — `typeof(Gender)` — **not** the collection type `typeof(GenderCollection)`. (The collection appears only as the `Deserialize(...)` target.) Copy the case keys from the generated file rather than guessing; using `typeof(GenderCollection)` as the key means the arm never matches and the catch-all throws. + +`Gender` is the reference-data **contract type**; `"Bar.$^Gender"` is the `{schema}.$^{Table}` key into the pre-configured seed data. **The key must mirror the seed YAML's schema and `$^Table` entry exactly, including casing** — it is case-sensitive. So it follows the **provider's casing**: PascalCase for SQL Server (e.g. `"Bar.$^Gender"`, `"Orders.$^OrderStatus"`), lower/snake_case for PostgreSQL (e.g. `"products.$^category"`). Copy the casing from the actual seed `$^
` rather than assuming lower-case. + +**Error path for a ref-data property is the camelCase navigation name.** A ref-data rule is written against the **typed navigation** property — `Property(x => x.Gender).IsValid()` — so its error key is the **camelCase of that navigation name**: `"gender"` (for `SubCategory` → `"subCategory"`). This is the **same** token as the serialized JSON code value (`GenderCode` serialises as `gender`), so the error path and the JSON field name coincide. Assert it as `("gender", "Gender is invalid.")` — not `("genderCode", …)` and not `("Gender", …)`. (The message label still follows the sentence-case / `[Localization]` rules above.) + +**Only test valid vs not-valid — not active/inactive.** A validator's reference-data rule is the `.IsValid()` extension; assert just the two outcomes: a **valid** code (use a real seeded code) and a **not-valid** code (use a code that is not in the seed — it fails naturally, no arranging required). Do **not** write tests targeting `IReferenceData.IsActive`/`IsInactive` — active/inactive handling is built into `IsValid()`, is framework behaviour we trust, and arranging inactive data just to prove it adds cost for no real coverage. + +**Adding test-only entries with `ExtendForTesting`.** The seed YAML is the **production** data set, so it may not contain entries that exercise a validator rule depending on a reference-data entity's **extended property** (a custom property added to the ref-data type). Where such a value is needed, chain `ExtendForTesting(IEnumerable)` (from `UnitTestEx`) onto the deserialized collection to append test-only items. It mutates and returns the collection, so it composes inline in the decorator's `GetAsync`: + +```csharp +_ when type == typeof(Category) => Task.FromResult((IReferenceDataCollection)jdr + .Deserialize("products.$^category")! + .ExtendForTesting([new Category { Id = Runtime.NewId(), Code = "X", OtherProperty = false }])), +``` + +Give each added entry a **unique `Id`** so it cannot collide with a seeded row, using a value appropriate to the reference data's **identifier type**: +- `string` id (the default) → `Runtime.NewId()` (a unique GUID-as-string). +- `Guid` id → `Runtime.NewGuid()`. +- `int` (or other) id → a unique value of that type that won't clash with seeded ids. + +This keeps the production seed clean while letting a test arrange a code carrying the exact extended-property values the scenario needs. + +**Arrange via the `{Name}Code` property, not the typed navigation property.** When constructing the entity/contract under test, set the reference-data relationship using the string **`{Name}Code`** property (e.g. `new Employee { GenderCode = "M" }`), **not** the typed `{Name}` navigation property (e.g. `Gender`). The typed property resolves its value through the `ReferenceDataOrchestrator`, which is not wired up while arranging the input — so assigning it directly is unreliable. The validator's `.IsValid()` reads from the code regardless. + +```csharp +// ✅ Correct — set the code variant when arranging +var e = new Employee { FirstName = "Jo", LastName = "Bloggs", GenderCode = "M" }; + +// ❌ Wrong — typed nav property depends on the orchestrator (not set during arrange) +var e = new Employee { FirstName = "Jo", LastName = "Bloggs", Gender = ... }; +``` + +### Coverage + +Add as many `[Test]` methods as needed for meaningful coverage — confirm both **error** and **success** outcomes. Focus on the validator's own decisions: `Mandatory`/presence where it matters, **inter-field relationships** (`DependsOn`, conditional `When*` rules, cross-property compares), custom `OnValidateAsync` logic, and reference-data **valid vs not-valid** — construct inputs that hit each branch. Prefer clear, scenario-named methods over `[TestCase]`. Aim for coverage that is genuinely representative rather than mirroring any prior hand-crafted set. + +**Skip framework-guaranteed constraints.** Do **not** write boundary tests for **length** rules (`MaximumLength`, `MinimumLength`, `Length`, `String`) — assume the declared length logic works (as with reference-data active/inactive). Such tests only re-prove built-in CoreEx behaviour and add fiddly string-padding for no real coverage; spend the effort on the conditional/business logic instead. + +> For relay-style tests that need a named scoped type, use `Test.ScopedType` (see Outbox Relay Host Tests). + +--- + +## Subscribe Host Tests + +Subscribe test classes extend `WithApiTester` over the subscriber host. The `[OneTimeSetUp]` migrates/seeds the domain DB and clears FusionCache, just like an API test. Subscribe hosts **do** have FusionCache — they are full application-layer consumers that need caching for reference data and idempotency. + +```csharp +public class ProductModifySubscriberTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + + Test.UseExpectedSqlServerOutboxPublisher(); + } +} +``` + +--- + +## Outbox Relay Host Tests + +Relay tests extend `WithApiTester` over the relay host. Use `Test.ScopedType` to write events directly to the outbox, wait for the relay background service to forward them, then assert via `Test.GetAndClearAzureServiceBusAsync()`. + +```csharp +public class RelayTests : WithApiTester +{ + [Test] + public async Task Outbox_Relay() + { + Test.ScopedType(test => + { + test.Run(async _ => + { + var pub = ActivatorUtilities.GetServiceOrCreateInstance(test.Services); + pub.Add("contoso", [ce1, ce2]); + await pub.PublishAsync(); + + for (int i = 0; i < 5; i++) + await Task.Delay(TimeSpan.FromSeconds(1)); + + var list = await Test.GetAndClearAzureServiceBusAsync( + ServiceBusSessionReceiverOptions.CreateForTopicSubscription("contoso", "products")); + + list.Should().HaveCount(2); + }).AssertSuccess(); + }); + } +} +``` + +The relay host exposes hosted-service management endpoints that can also be exercised in tests: + +```csharp +Test.Http() + .Run(HttpMethod.Post, "/hosted-services/postgres-outbox-relay-03/pause") + .Response.StatusCode.Should().Be(HttpStatusCode.Accepted); +``` + +> **Troubleshooting — Service Bus emulator entity not found.** If a Relay (or any Service Bus) test fails with an error like: +> ``` +> The messaging entity 'sb://sbemulatorns.servicebus.onebox.windows-int.net//subscriptions/' could not be found. +> ``` +> (the topic/subscription path varies), the test host is reaching the emulator but the requested topic/subscription does not exist in it. **Emit to the chat output:** *"Check that the Service Bus emulator (container) is executing with the correct `/servicebus/Config.json` file."* — the emulator provisions its topics/subscriptions from that config at startup, so a missing or mismatched `Config.json` (or a container started without it) is the usual cause. This is an **environment** problem, not a test-code defect — do not "fix" it by editing the test, the subjects, or the emulator entity names. + +--- + +## NUnit Attributes + +Use `[Test]` on individual test methods. `[TestFixture]` is inherited when using `WithApiTester` or `WithGenericTester`. Do not use `[TestCase]` for integration tests — use separate named methods for clarity. + +## Naming Tests + +Name test methods as `{Entity}_{Action}_{Outcome}`: + +``` +Product_Get_Found +Product_Get_NotFound +Product_Create_Success +Product_Create_Bad_Data +Basket_Checkout_Success +Basket_Checkout_Insufficient_Quantity +``` + +## Do Not + +- Do not use `[TestCase]` for integration tests — create separate named test methods for each scenario. +- Do not use `UseExpectedSqlServerOutboxPublisher` / `ExpectSqlServerOutboxEvents` in PostgreSQL domain tests — use the Postgres equivalents. +- Do not use `UseExpectedPostgresOutboxPublisher` / `ExpectPostgresOutboxEvents` in SQL Server domain tests — use the SQL Server equivalents. +- Do not call `ClearFusionCacheAsync()` in Outbox Relay host tests — relay hosts have no cache. +- Do not test inter-domain HTTP calls against a real API — always mock with `MockHttpClientFactory`. +- Do not call `Test.ReplaceHttpClientFactory()` inside individual tests — configure it once in `[OneTimeSetUp]`. +- Do not use `FluentAssertions` — use `AwesomeAssertions` (the `AwesomeAssertions` NuGet package). +- Do not omit `.Verify()` after a `MockHttpClientRequest` action — it confirms the mock was actually invoked. +- Do not set a typed reference-data navigation property (e.g. `Gender`) when arranging a test input — set the `{Name}Code` string (e.g. `GenderCode = "M"`); the typed property depends on the `ReferenceDataOrchestrator`, which is not set during arrange. +- Do not write validator tests for reference-data `IsActive`/`IsInactive` — assert only valid vs not-valid via `.IsValid()` (a not-valid case just uses an unseeded code); active/inactive is trusted framework behaviour. Reserve `ExtendForTesting` for rules that depend on a ref-data **extended property**. +- Do not remove or replace the final `_ => throw …` catch-all arm of `ReferenceDataServiceDecorator.GetAsync` when adding a ref-data type — insert the new arm **above** it; the catch-all must remain. +- Do not write validator tests for **length** rules (`MaximumLength`/`MinimumLength`/`Length`/`String`) — assume the declared length logic works (framework-guaranteed, like reference-data active/inactive); test conditional/business logic instead. +- Do not put an entity's read and mutate API tests in one class — split into `XxxReadTests` (seeds `read-data.seed.yaml`) and `XxxMutateTests` (seeds `mutate-data.seed.yaml`), one partial sub-file per operation (`Xxx{Read|Mutate}Tests.{Operation}.cs`). +- Do not load the whole dataset in an API read/mutate class — use the named-file `MigrateXxxDataAsync(["read-data.seed.yaml"|"mutate-data.seed.yaml"], …)` overload so the class loads only its dataset. +- Do not use the wrong identifier casing in seed YAML — schema, table, **and** column names must match the database's actual casing for the provider (PostgreSQL = lowercase `snake_case`, the default; SQL Server = `PascalCase`). A wrong-cased schema/table fails with *"Table '…' does not exist"*. Mirror exactly what the migration created. +- Do not write order-dependent or interfering mutate tests — each must act on a distinct seeded id or create its own data. +- Do not include `id`/`etag`/`changelog` in a `.res.json` used with `ExpectIdentifier()`/`ExpectETag()`/`ExpectChangeLogCreated()`/`ExpectChangeLogUpdated()` (or list them as manual excludes) — those helpers assert presence and auto-exclude from the compare. +- Do not use the `Code`-suffixed name in test JSON (`.res.json`/`.req.json`/inline bodies) for a reference-data property — use the non-`Code` JSON name (`gender`, not `genderCode`). +- Do not omit `.WithMergePatchJsonContentType()` on a PATCH test — the request default is plain JSON, not merge-patch. +- Do not assert `AssertNotFound()` on a `DELETE` — delete is idempotent and always returns 204 No Content (the 404 belongs to the *GET* in a get→delete→get flow). Only the first delete emits an event; assert `ExpectNoXxxOutboxEvents()` on a repeat/non-existent delete. +- Do not add a `.vN` version suffix to a **no-value** event subject (e.g. `…deleted`) — the version applies **only** when the event carries a value (create/update). Derive the version from the contract's `[Schema("vX.Y")]` major (default `.v1`); don't guess or hard-code it. +- Do not use `AssertWithValue` for a **no-value** event (Delete, or any `204 No Content`) — there is no returned value to reconstruct from. Use `AssertMetadata(destination, subject, key)` and pass the entity id (e.g. `2.ToGuid().ToString()`) as the `key` (the `.WithKey(id)` value). Reserve `AssertWithValue` for value-carrying Create/Update events. + +## Further Reading + +- [Testing Guide](https://github.com/Avanade/CoreEx/blob/main/samples/docs/testing.md) — full test architecture, data seeding, schema isolation, and E2E runner. +- [Pattern Catalog](https://github.com/Avanade/CoreEx/blob/main/samples/docs/patterns.md) — pattern catalog linking testing patterns to layer docs. diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-tooling.instructions.md b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-tooling.instructions.md new file mode 100644 index 00000000..e157f336 --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-tooling.instructions.md @@ -0,0 +1,570 @@ +--- +applyTo: "**/*.CodeGen/Program.cs;**/*.CodeGen/ref-data.yaml;**/*.Database/Program.cs;**/*.Database/dbex.yaml;**/*.Database/Migrations/**;**/*.Database/Data/**;**/*.Database/Schema/**;!**/*.Database/Schema/**/*.g.*" +description: "Developer tooling conventions: *.CodeGen reference-data C# code generation and *.Database schema migration, DbEx commands, seed data, and outbox provisioning" +tags: ["tooling", "codegen", "database", "migrations", "dbex", "reference-data", "outbox"] +--- + +# Developer Tooling Conventions + +Each domain has two developer-time tooling projects that have **no runtime presence**. They run locally during development and in CI/CD pipelines to generate code and manage the database schema. + +| Project | Purpose | +|---|---| +| `*.CodeGen` | Generates reference-data C# artefacts across all layers from `ref-data.yaml` | +| `*.Database` | Manages the full database lifecycle — schema, seed data, outbox provisioning, and Infrastructure C# code generation | + +--- + +## Order of operations (database-first) + +A change that touches the database, code generation, and hand-written code must be done **in dependency order** — the database is the baseline everything else generates from or builds on. **Plan the full sequence first, execute it in order, and do not begin a step until the previous one has succeeded.** Doing things out of order (CodeGen first, or adding seed rows / `dbex.yaml` tables before the create migrations exist) is the main cause of confused, self-inflicted "fixes". + +1. **Establish & inspect.** Bring the DB up to date (`dotnet run -- database`), then `dotnet run -- inspect
…` to confirm current state and decide create-vs-alter — see [Creating or altering a table for an entity](#creating-or-altering-a-table-for-an-entity). **If the bring-up fails, STOP and surface the verbatim error — do not continue or start editing unrelated files.** +2. **Author migration script(s)** for the absent/changed table(s): `dotnet run -- script refdata|create
`, then fill in the columns. +3. **Add reference-data rows** to `Data/ref-data.seed.yaml`. +4. **Register the tables** in `dbex.yaml` `tables:`. +5. **Apply & generate (DB side):** `dotnet run -- All` (Create → Migrate → CodeGen → Schema → Data) — Migrate applies the new scripts (tables now exist), CodeGen generates the EF persistence models from the live schema, Data seeds. **If it fails, STOP and surface the verbatim error** — a broken baseline invalidates everything downstream. +6. **CoreEx CodeGen — contracts:** edit `*.CodeGen/ref-data.yaml` and run `dotnet run` (in `*.CodeGen`) to generate the reference-data contracts/services/repositories/mappers. +7. **Hand-written .NET code:** application services, validators, controllers, tests — last, on top of the generated baseline. + +Steps 3–4 come **after** the migration scripts (step 2) and are only applied/introspected by step 5 — where, within `All`, Migrate creates the tables **before** Data seeds and **before** CodeGen introspects `dbex.yaml`. Never add seed rows or `dbex.yaml` tables (or run CodeGen) before the create migrations exist; that is exactly what makes the bring-up fail (seeding into, or generating models from, tables that do not yet exist). + +> Two distinct `dotnet run` code-gen steps in different projects: step 5's `All` invokes the **DbEx** CodeGen in `*.Database` (EF persistence models from `dbex.yaml` + live schema); step 6 is the **CoreEx** CodeGen in `*.CodeGen` (contracts etc. from `*.CodeGen/ref-data.yaml`). Do not conflate them. + +--- + +## `*.CodeGen` — Reference-Data C# Code Generation + +### How it works + +`Program.cs` is minimal — it delegates entirely to `CodeGenConsole`: + +```csharp +await CoreEx.CodeGen.CodeGenConsole.Create().RunAsync(args); +``` + +Running `dotnet run` reads `ref-data.yaml`, validates it against the CoreEx JSON Schema, evaluates the embedded Handlebars templates via [OnRamp](https://github.com/Avanade/OnRamp), and writes `.g.cs` files into the correct target project directories (resolved automatically by convention from the CodeGen project location). + +### What is generated + +| Artefact | Target layer | Description | +|---|---|---| +| `*.g.cs` contract class | Contracts | Typed reference-data entity contract extending `ReferenceData`, decorated with `[ReferenceData]` which triggers the Roslyn source generator to emit additional members at compile time | +| `*.g.cs` controller route | API host | HTTP GET endpoint exposing the entity collection | +| `*.g.cs` service method | Application | Service method delegating to the repository | +| `*.g.cs` repository interface | Application | `IXxxRepository` interface declaration | +| `*.g.cs` repository | Infrastructure | EF Core repository implementation | +| `*.g.cs` mapper | Infrastructure | `BiDirectionMapper` for the entity | + +All outputs carry the `.g.cs` suffix and must never be edited directly — regenerate by re-running `dotnet run`. + +> **Add the global usings the generated code depends on — the clean scaffold does not pre-import own-project namespaces.** The `coreex` scaffold ships **clean**: it does **not** carry `global using {Solution}.Contracts;` / `{Solution}.Application;` / `{Solution}.Application.Repositories;` in the `GlobalUsing.cs` files, because those namespaces are empty until code/CodeGen populates them (a `global using` of an empty namespace is **CS0234**). The generated artefacts reference these types **unqualified** (e.g. `ReferenceDataService.g.cs` and `IXxxRepository.g.cs` use `IReferenceDataRepository`, `GenderCollection`, `Gender`), so **as you create the code, add the matching `global using` to each consuming project's `GlobalUsing.cs`**: +> - First **contract** created → add `global using {Solution}.Contracts;` to **Application**, **Infrastructure** (mappers), and **Api** (controllers). +> - After **CodeGen** emits repository interfaces → add `global using {Solution}.Application.Repositories;` to **Application** (services) and **Infrastructure** (repositories). +> - First **controller** referencing a service → add `global using {Solution}.Application;` to **Api**. +> +> Do **not** fully-qualify inside the `.g.cs` and do **not** reintroduce a placeholder/marker type just to keep a namespace non-empty — add the `global using` alongside the real code that needs it. (The `AssemblyMarker` types in **Application** and **Infrastructure** are **not** for this — they exist solely as stable `AddDynamicServicesUsing(...)` assembly anchors; see `coreex-host-setup.instructions.md`.) + +### `ref-data.yaml` structure + +> ⚠️ **Two related but distinct reference-data files — do not confuse them:** +> - **`*.CodeGen/ref-data.yaml`** (this section) — *defines* the reference-data **entities/contracts** (`entities:` with `name`/`idType`/`properties`); consumed by CodeGen to generate C#. +> - **`*.Database/Data/ref-data.seed.yaml`** (see [`Data` — seeding](#data--seeding)) — *seeds* reference-data **rows** into the database (`Schema:` → `- $^Table:` → rows); a completely different format. The `.seed.` in the filename marks it as the seed file. +> +> When you mean to add seed rows, edit the **Database/Data** file in the seed format — never put `entities:`-style definitions there, and never put `Schema:`/row data here. + +```yaml +# *.CodeGen/ref-data.yaml — entity/contract DEFINITIONS (not seed data) +# yaml-language-server: $schema=https://raw.githubusercontent.com/Avanade/CoreEx/refs/heads/main/schema/coreex-refdata.json +collectionSortOrder: Code # sort order applied to all collection types +repository: EntityFramework # repository implementation strategy +entities: +- name: Brand # simplest form — all defaults apply +- name: Category +- name: SubCategory + properties: + - name: CategoryCode + type: ^Category # ^ prefix = typed reference-data property (generates navigation accessor) +- name: UnitOfMeasure + plural: UnitsOfMeasure # override pluralization where irregular + idType: Guid # override identifier type; defaults to string + properties: + - name: Scale + type: int # additional stored column beyond the standard ReferenceData fields +- name: DiscountCoupon + properties: + - name: DiscountPercentage + type: decimal + excludeContract: true # exclude from generated contract (present in persistence model only) +``` + +Add the `$schema` annotation to the file for IDE YAML validation and auto-complete. + +The standard `IReferenceData` properties (`Id`, `Code`, `Text`, `Description`, `SortOrder`, `IsActive`, etc.) are automatically included in every generated type — do not declare them under `properties:`. Only additional domain-specific columns need to be listed; most reference data entities require no `properties:` entry at all. + +Key `entities:` options: + +| Key | Required | Default | Purpose | +|---|---|---|---| +| `name` | Yes | -- | Entity name (PascalCase) | +| `plural` | No | Auto-pluralized | Override when pluralization is irregular | +| `idType` | No | `string` | Identifier type override (e.g. `Guid`, `int`) | +| `properties[].name` | Yes (if any) | -- | Additional stored property name | +| `properties[].type` | Yes (if any) | -- | CLR type; prefix `^` for a ref-data navigation accessor | +| `properties[].excludeContract` | No | `false` | Exclude from the generated contract (persistence only) | + +> **Agent instruction:** When asked to create or modify a reference data type: +> 1. Add or update the entry under `entities:` in `ref-data.yaml`. +> 2. Offer to run `dotnet run` from the `*.CodeGen` directory on the user's behalf. +> 3. If confirmed, execute it and summarise the generated artefacts on success; on failure relay the **complete output verbatim** — it provides the diagnostic needed to fix the entry. +> 4. On failure, fix the issue in `ref-data.yaml` and offer to re-run -- do not create or edit `.g.cs` files to work around a generation error. +> 5. If the user declines, remind them to run `dotnet run` from the `*.CodeGen` directory before the new types are available. +> +> **Do not pre-create output directories.** A configured target path (e.g. `apiProjectPath`) that points to a not-yet-existing project directory does **not** cause CodeGen to fail — it simply emits a *warning* and skips that output. Never create an empty directory, stub project, or placeholder file to "unblock" code generation. If a target project is genuinely missing and its artefacts are needed, raise it with the user rather than fabricating the folder. + +--- + +## `*.Database` — Database Lifecycle Management + +### NuGet / Project References + +| Package | Use case | +|---|---| +| `DbEx.SqlServer` + `DbEx.SqlServer.Console` | SQL Server domains | +| `DbEx.Postgres` + `DbEx.Postgres.Console` | PostgreSQL domains | +| `CoreEx.Database` | `SqlStatement` type — add its assembly to the migration runner for extended schema scripts | + +> **Polyglot**: Use `PostgresMigrationConsole` with `.pgsql` scripts and PostgreSQL functions for PostgreSQL domains. Use `SqlServerMigrationConsole` with `.sql` scripts and stored procedures for SQL Server domains. Choose the correct package per domain. + +### `Program.cs` pattern + +This is the generated baseline (the `Main` is provider-specific; `ConfigureMigrationArgs` is a plain block-bodied method — note **no** `=>` before the `{`): + +```csharp +// PostgreSQL domain example +public static Task Main(string[] args) => PostgresMigrationConsole + .Create("Server=127.0.0.1;Database=mydb;Username=postgres;Password=...") + .Configure(c => ConfigureMigrationArgs(c.Args)) + .RunAsync(args); + +// SQL Server domain example +public static Task Main(string[] args) => SqlServerMigrationConsole + .Create("Data Source=127.0.0.1,1433;Initial Catalog=MyDb;User id=sa;Password=...") + .Configure(c => ConfigureMigrationArgs(c.Args)) + .RunAsync(args); + +public static MigrationArgs ConfigureMigrationArgs(MigrationArgs args) +{ + args.AddAssembly().AddAssembly(); // SqlStatement = CoreEx EF code-gen templates; Program = this project's embedded Migrations/Schema/Data. Both REQUIRED — do not remove (see below). + args.DataResetFilterPredicate = ts => ts.Schema == "{domain-schema}"; // Only reset data for this domain's schema. + return args; +} +``` + +**Both `AddAssembly` calls are required — never drop `AddAssembly()`.** `AddAssembly()` brings in the CoreEx EF code-generation templates used by the `CodeGen` command; `AddAssembly()` registers **this** Database project's assembly, which carries the embedded `Migrations/`, `Schema/`, and `Data/` resources. It is tempting to omit `AddAssembly()` because `Create(...)` registers that assembly automatically — but that only applies when migration runs via **`Main`** (the console path). The **API integration tests call `ConfigureMigrationArgs` directly** (e.g. `MigrateXxxDataAsync(…, DbMigration.ConfigureMigrationArgs)`) **without** the `Create` console setup, so the embedded migrations/data are found **only** because `AddAssembly()` is inside `ConfigureMigrationArgs`. Removing it leaves the tests unable to locate any migration scripts or seed data. All sample domains (SQL Server and PostgreSQL) include both calls. + +**Optional additions** (only when the domain needs them — not in the baseline): +- `.DataParserArgs.RefDataColumnDefault("", _ => )` — default a **domain-specific** ref-data seed column when the YAML omits it (e.g. `Scale` for a `UnitOfMeasure`). Add one per such column; omit entirely if not needed. (Do **not** do this for `SortOrder` — it is auto-assigned, see below.) +- `.IncludeExtendedSchemaScripts()` — enable the extended `SqlStatement`-based schema scripting when the domain uses it. + +### DbEx commands + +Run with `dotnet run -- `. Default (no arguments) runs `All`. + +| Command | Description | +|---|---| +| `Create` | Creates the database if it does not exist | +| `Migrate` | Applies outstanding ordered migration scripts; tracks applied scripts — only new ones run | +| `CodeGen` | Generates Infrastructure `.g.cs` persistence models and `DbContext` partial from `dbex.yaml` | +| `Schema` | Drops and re-creates idempotent schema objects from `Schema/` on every run (stored procs, functions) | +| `Data` | Applies YAML/JSON seed data with INSERT or MERGE semantics | +| `Reset` | Deletes all data from the database (scoped by `DataResetFilterPredicate`) | +| `Script` | Scaffolds a new migration script, correctly named `yyyyMMdd-HHmmss-` (current UTC date+time, lower-cased). Subcommands: `schema`, `create`, `alter`, `refdata`, `outbox`. Prefer this over hand-creating script files | +| `Drop` | Drops the database | +| `Inspect` | Read-only; reports existence and current column schema (type, nullability, default, PK, identity, computed, unique, and whether it is reference data) for one or more tables in a schema, as markdown. Safe to run freely | + +Composite commands for common scenarios: + +| Composite | Runs | +|---|---| +| `All` | `Create` → `Migrate` → `CodeGen` → `Schema` → `Data` | +| `Database` | `Create` → `Migrate` → `Schema` → `Data` (database-only; no `CodeGen`) — the standard non-destructive bring-up to make the live database reflect all authored scripts | +| `Deploy` | `Migrate` → `Schema` | +| `DeployWithData` | `Migrate` → `Schema` → `Data` | +| `CreateMigrateAndCodeGen` | `Create` → `Migrate` → `CodeGen` | +| `ResetAndAll` | `Reset` → `All` | + +### Provisioning the Transactional Outbox + +The outbox table(s) are created via a DbEx-generated migration script — never hand-authored. This applies only to domains that have a `*.Database` project (i.e. a `data-provider` other than `None`). + +When a solution is scaffolded with outbox enabled, the `coreex` template **already ships the outbox create migration script** (`*-create--outbox-tables.{sql,pgsql}`) under `Migrations/`. In that case nothing needs scaffolding — the script just needs to be applied via `Migrate`. Only scaffold a new create script when one does **not** already exist (e.g. outbox was enabled after the solution was generated). + +> **Migration scripts are immutable once applied.** A create script runs exactly once and is never re-run, regenerated, or edited. If the outbox schema later needs to change (e.g. a DbEx version bump alters its shape), author a **new** timestamped `ALTER` migration script for the delta — do not modify or re-scaffold the original create script. + +> **Agent instruction:** When asked to create the database outbox table(s): +> 1. **Validate `dbex.yaml` first.** All three conditions must hold: +> - Root-level `outbox: true` is set. +> - Root-level `schema:` has a value (call it `xxx`). +> - Root-level `outboxName:` has a value (call it `yyy`). +> If any condition fails, **stop and error** — state which is missing. Do not attempt to scaffold the script. +> 2. **Check for an existing create script.** Look under `Migrations/` for a `*-create-*-outbox-tables.{sql,pgsql}` script (the template ships one when outbox is enabled). If present, **do not scaffold another** — skip to step 4 to apply it. Only when none exists, proceed to step 3. +> 3. **Scaffold the migration script** using the extracted `schema` (`xxx`) and `outboxName` (`yyy`) values: +> ``` +> dotnet run -- script outbox xxx yyy +> ``` +> 4. **Ask** whether the (new or existing) script should be deployed/migrated to the database. +> 5. **If confirmed**, run: +> ``` +> dotnet run -- CreateMigrateAndCodeGen +> ``` +> Summarise the output on success; on failure relay the **complete output verbatim** — it provides the diagnostic needed to resolve the issue. +> 6. **If declined**, remind the user to run `dotnet run -- CreateMigrateAndCodeGen` before the outbox table(s) exist in the database. + +### `dbex.yaml` structure + +Schema, table, and column names follow the casing convention of the target database: +- **PostgreSQL** — `snake_case` throughout +- **SQL Server** — `PascalCase` throughout + +```yaml +# PostgreSQL example +# yaml-language-server: $schema=https://raw.githubusercontent.com/Avanade/DbEx/refs/heads/main/schema/dbex.json +schema: products # snake_case schema name +outbox: true # generate full transactional outbox infrastructure +outboxName: outbox # prefix for outbox tables and functions +tables: +# Reference-data tables +- name: brand +- name: category +- name: unit_of_measure +# Transactional tables +- name: product +- name: inventory +``` + +```yaml +# SQL Server example +# yaml-language-server: $schema=https://raw.githubusercontent.com/Avanade/DbEx/refs/heads/main/schema/dbex.json +schema: Products # PascalCase schema name +outbox: true +outboxName: Outbox # prefix for outbox tables and stored procedures +tables: +# Reference-data tables +- name: Brand +- name: Category +- name: UnitOfMeasure +# Transactional tables +- name: Product +- name: Inventory +``` + +Add the `$schema` annotation to each file for IDE YAML validation and auto-complete. + +**Keep table entries minimal — write `- name: Xxx` and nothing else.** A table entry needs only its `name`; everything else is by-convention. Use the simple `- name: Xxx` form (not the inline-object `- { name: Xxx, ... }` form) and do **not** add properties speculatively: +- **`efModel`** is a **choice string** — `Yes` (default), `No`, `ModelOnly`, or `ModelBuilderOnly` — **not** a boolean. **Omit it entirely** — the default is already `Yes` (generate the EF model), so `efModel: Yes` is redundant noise. Never write `efModel: true`, and don't add `efModel: Yes`. +- **The `IsDeleted` logical-delete column is recognised by convention** (the table-level `columnNameIsDeleted`, default `IsDeleted`). Do **not** declare it under `columns:` — and there is no per-column `isDeleted` flag (a column entry only supports `name`, `property`, `type`, `valueConverter`, `default`). DbEx detects the `IsDeleted` column from the live schema automatically. Only set the table-level `columnNameIsDeleted: "X"` if the column is non-standardly named. +- **`schema:`** on a table is a valid **override**, but only use it when the table genuinely lives in a **different, existing** schema. Do not invent a separate schema (e.g. `Ref`) for reference data — by default every table (reference and transactional) lives in the domain's root `schema:`, consistent with the migration scripts and the seed `Data/ref-data.seed.yaml`. + +So a typical Gender + Employee domain is simply: + +```yaml +tables: +# Reference-data +- name: Gender +# Transactional-data +- name: Employee +``` + +(Add `columns:`, `efModel`, `efModelName`, `includeColumns`/`excludeColumns`, or `columnName*` overrides only when a specific need arises.) + +### `CodeGen` phase — generated Infrastructure C# + +The `CodeGen` command generates `.g.cs` files into the Infrastructure project: + +| Generated artefact | Location | Description | +|---|---|---| +| `.g.cs` | `Infrastructure/Persistence/` | Schema-aligned persistence model extending `ModelBase` (or `ReferenceDataModelBase` for reference data), both from `CoreEx.Data.Models`; the base supplies `Id`, `CreatedBy`/`CreatedOn`/`UpdatedBy`/`UpdatedOn`, and `ETag` (the DB `RowVersion` column is mapped onto `ETag`), with optional marker interfaces (`ILogicallyDeleted`) | +| `*DbContext.g.cs` | `Infrastructure/Repositories/` | Partial `DbContext` class exposing `AddGeneratedModels(ModelBuilder)` to register all persistence models with EF Core | + +These files are the only `.g.cs` outputs of `*.Database`; all other generated C# comes from `*.CodeGen`. Never edit them directly. + +> **⚠️ `AddGeneratedModels` is a partial method — the hand-written `*DbContext.cs` *declares* it, the generated `*DbContext.g.cs` *implements* it.** The generated `*DbContext.g.cs` supplies the **`partial void AddGeneratedModels(ModelBuilder)`** *implementation*. The hand-written `*DbContext.cs` (a `partial class`) must **both**: (a) declare the matching `partial void AddGeneratedModels(ModelBuilder modelBuilder);`, and (b) call `AddGeneratedModels(modelBuilder)` from `OnModelCreating`. Because it is a **partial method**, before CodeGen has emitted the `.g.cs` the declaration has no implementation and the compiler **elides the call** — so the scaffold **compiles as-is with no CodeGen required** (the models simply aren't registered until CodeGen runs, which is correct). Do **not** make it a non-`partial`/`public void` method and do **not** drop the declaration — both would break the elide-until-generated behaviour (a non-partial pair would be **CS0111**; a missing declaration is **CS0103** before CodeGen). + +### Inspecting current database state + +The `Inspect` command queries the **live database** and reports, per table, whether it exists and — when it does — its current column schema as markdown. It is read-only and has no side effects, so run it freely without confirmation. + +> **Bring the database up to date first.** `Inspect` only reflects what is actually in the database, so before inspecting (especially before any create/alter decision) run `dotnet run -- database` to ensure the database exists and all authored migrations/schema/data are applied. `Database` is non-destructive (`Create` → `Migrate` → `Schema` → `Data`; no `Drop`/`Reset`), so run it as the standard precursor, then inspect: +> +> ``` +> dotnet run -- database # create-if-missing + apply migrations/schema/data +> dotnet run -- inspect
[
...] +> ``` + +``` +dotnet run -- inspect
[
...] +``` + +The schema is the **first** argument; every argument after it is a table name within that schema. Example: + +``` +dotnet run -- inspect public contact gender +``` + +The output is the authoritative source of truth for what the database currently contains. **Do not infer table existence or configuration from the file system** — the presence of a script under `Migrations/` or an entry under `tables:` in `dbex.yaml` only means it was *authored*, not that it has been *applied* to the target database. Migration scripts may or may not have been run. Only `Inspect` reflects reality. + +Reading the output: +- Branch on the `## SCHEMA.TABLE - Exists: Yes|No` header first. `No` means the table is absent and must be created. +- Use the **Qualified Name** bullet (e.g. `"public"."contact"` or `[Test].[Contact]`) for DDL casing and quoting — not the uppercased header text. +- Honour the **Reference Data: Yes|No** flag for routing decisions (a reference data table is maintained via `ref-data.yaml` + CodeGen, not by hand). +- PostgreSQL reports canonical type names (`CHARACTER VARYING(50)`, `TIMESTAMP WITH TIME ZONE`); treat these as equivalent to the `VARCHAR(50)` / `TIMESTAMPTZ` forms you would author in a script. +- Per the disclaimer in the output, the live database remains the ultimate truth; the report is derived from system catalogs and may not capture every nuance. + +### `Migrate` — schema evolution + +Migration scripts are embedded resources under `Migrations/`. + +**Scaffold new scripts with the DbEx `Script` command — do not hand-create the file.** It generates a correctly named, correctly cased, correctly templated script every time: + +``` +dotnet run -- script schema # new schema (rarely needed — see below) +dotnet run -- script create
# new table +dotnet run -- script alter
# alter an existing table +dotnet run -- script refdata
# new reference-data table +dotnet run -- script outbox # transactional outbox table(s) +``` + +**Naming convention** (what `Script` produces, and what any hand-named file must match): `yyyyMMdd-HHmmss-.{sql|pgsql}`. +- The leading segment is the **current UTC date *and* time** (`yyyyMMdd-HHmmss`) at the moment of creation — **not** a placeholder date (e.g. `20250101`) and **not** a per-day incrementing index (e.g. `000001`). The time component provides natural ordering and uniqueness without tracking indices. +- The entire filename is **kebab-lower-case** — all lowercase, words separated by hyphens (e.g. `20260603-142530-create-bar-employee.sql`, never `...-create-Bar-Employee.sql`). + +> **Do not author a schema-create script.** The `coreex` template already ships the default schema-create migration, so the schema exists from the first `Migrate`. Never emit a `create--schema` script unless the user **explicitly** asks for an additional schema. + +Scripts are **immutable once applied**. Subsequent changes require new scripts (e.g. `ALTER TABLE`). Use moustache-style `{{Parameter}}` for environment-specific values resolved from `MigrationArgs.Parameters`. + +> **Never modify the database — or DbEx's journal — directly to unblock a migration.** DbEx tracks applied scripts in an internal **journal** table that it owns exclusively; do **not** insert/pre-seed/back-fill journal rows, and do **not** hand-run `CREATE`/`ALTER`/`DROP`/`INSERT` to reconcile state. If `migrate`/`database` fails because the live database is out of step with the scripts (e.g. "the journal is empty but the objects already exist", or scripts re-running over existing objects), **stop and ask the user** — reconciling environment state is their decision. The usual clean fix for a disposable local/dev database is to drop and rebuild via `dotnet run -- dropanddatabase` (destructive — confirm first); production/shared environments are reconciled by the user. Never edit the database or journal yourself. + +SQL conventions: +- Wrap each script in `BEGIN TRANSACTION ... COMMIT TRANSACTION` (SQL Server) or equivalent. +- Use explicit schema-qualified names. +- Include the `IChangeLog` audit columns on aggregate tables, named **exactly** `CreatedBy`, `CreatedOn`, `UpdatedBy`, `UpdatedOn` (SQL Server) / `created_by`, `created_on`, `updated_by`, `updated_on` (PostgreSQL). The date/time columns use the `On` suffix — **never** `CreatedDate`/`UpdatedDate` or `created_date`/`updated_date`. Type them `DATETIMEOFFSET` (SQL Server) / `TIMESTAMPTZ` (PostgreSQL) and the `*By` columns as the contract's user type (typically `NVARCHAR(n)` / `VARCHAR(n)`). +- **SQL Server**: add a `ROWVERSION` / `TIMESTAMP` column for optimistic-concurrency mapped to `ETag`. +- **PostgreSQL**: use the built-in hidden `xmin` system column for optimistic-concurrency — no explicit column is required in the schema. +- Logical (soft) delete on root/aggregate tables is an infrastructure-only column — `[IsDeleted] BIT NOT NULL DEFAULT (0)` (SQL Server) / `is_deleted BOOLEAN NOT NULL DEFAULT FALSE` (PostgreSQL) — with **no** corresponding contract/entity property; default to including it (confirm) when creating such a table. It must be **NOT NULL** and **default to the DB's `false`** (`0` / `FALSE`), never nullable. + +### Standard table templates + +**Mirror these canonical shapes** when authoring create scripts — copy and adapt rather than inventing column names. They encode the standard names, types, and lengths. Note the audit columns use the `On` suffix (`CreatedOn`/`UpdatedOn`) — **never** `CreatedDate`/`UpdatedDate`. + +**Reference-data table** — the standard `IReferenceData` columns. The primary key is the contract's identifier type (`NVARCHAR(50)` / `VARCHAR(50)` for the default `string`): + +```sql +-- SQL Server +CREATE TABLE [Schema].[Xxx] ( + [XxxId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [Code] NVARCHAR(50) NOT NULL UNIQUE, + [Text] NVARCHAR(250) NULL, + [IsActive] BIT NULL, + [SortOrder] INT NULL, + [RowVersion] TIMESTAMP NOT NULL, -- ETag (optimistic concurrency) + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL +); +``` + +```sql +-- PostgreSQL (no RowVersion column — the hidden xmin provides concurrency) +CREATE TABLE "schema"."xxx" ( + "xxx_id" VARCHAR(50) NOT NULL PRIMARY KEY, + "code" VARCHAR(50) NOT NULL UNIQUE, + "text" VARCHAR(250) NULL, + "is_active" BOOLEAN NULL, + "sort_order" INTEGER NULL, + "created_by" VARCHAR(250) NULL, + "created_on" TIMESTAMPTZ NULL, + "updated_by" VARCHAR(250) NULL, + "updated_on" TIMESTAMPTZ NULL +); +``` + +**Aggregate / transactional table** — domain columns first, then the identical audit + concurrency columns. Reference-data relationships are stored by `Code` by default (e.g. `[StatusCode] NVARCHAR(50) NOT NULL`) with no foreign key — see [Creating or altering a table for an entity](#creating-or-altering-a-table-for-an-entity): + +```sql +-- SQL Server +CREATE TABLE [Schema].[Order] ( + [OrderId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [CustomerId] NVARCHAR(100) NOT NULL, + [StatusCode] NVARCHAR(50) NOT NULL, -- references [OrderStatus].[Code]; no FK by convention + [IsDeleted] BIT NOT NULL DEFAULT (0), -- logical delete (default yes — confirm); NOT NULL, defaults false; no contract property + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL, + [RowVersion] TIMESTAMP NOT NULL +); +``` + +### Mapping contract types to columns + +When authoring a migration script for an entity that has a corresponding .NET contract, the column types must mirror the contract's property types. Do not substitute a different type (most importantly for the primary key) unless the user explicitly asks. Before authoring, establish whether the table already exists and its current shape — see [Inspecting current database state](#inspecting-current-database-state) and the [Creating or altering a table for an entity](#creating-or-altering-a-table-for-an-entity) workflow. + +| Contract (.NET) type | SQL Server | PostgreSQL | +|---|---|---| +| `string` / `string?` | `NVARCHAR(n)` | `VARCHAR(n)` / `TEXT` | +| `Guid` | `UNIQUEIDENTIFIER` | `UUID` | +| `int` | `INT` | `INTEGER` | +| `long` | `BIGINT` | `BIGINT` | +| `short` | `SMALLINT` | `SMALLINT` | +| `decimal` | `DECIMAL(p,s)` | `DECIMAL(p,s)` | +| `double` | `FLOAT` | `DOUBLE PRECISION` | +| `bool` | `BIT` | `BOOLEAN` | +| `DateTime` | `DATETIME2` | `TIMESTAMP` | +| `DateTimeOffset` | `DATETIMEOFFSET` | `TIMESTAMPTZ` | + +> **Agent instruction:** When generating a migration script for an entity that has a .NET contract: +> 1. **The `script` scaffold's PK is a placeholder — replace it.** `dotnet run -- script create|refdata` seeds the primary key with a generated-key placeholder: `[{Name}Id] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY` (SQL Server) or `"{name}_id" SERIAL PRIMARY KEY` (PostgreSQL). **Neither is the default to keep** — overwrite it to match the contract's identifier type. +> 2. **Map the primary key column to the contract's identifier type.** The identifier is `string` **by default** → `[{Name}Id] NVARCHAR(50) NOT NULL PRIMARY KEY` (SQL Server) / `"{name}_id" VARCHAR(50) NOT NULL PRIMARY KEY` (PostgreSQL). Use `UNIQUEIDENTIFIER`/`UUID` **only** when the contract identifier is explicitly a `Guid`; `INT`/`INTEGER` for `int`; etc. Never leave the scaffold's `UNIQUEIDENTIFIER` (SQL Server) or `SERIAL` (PostgreSQL) for a `string` identifier. +> 3. **Drop the scaffold's value-generation default.** Remove `DEFAULT (NEWSEQUENTIALID())` (SQL Server) and **replace `SERIAL` with the plain column type** (PostgreSQL — `SERIAL` is an auto-increment sequence, i.e. a DB-assigned value); never add `IDENTITY` / `gen_random_uuid()` either — unless explicitly requested. The application services layer assigns identifier and other values; the database should not default them. +> 4. **Map every other column to its contract property type** per the table above, preserving nullability (`?` → nullable column). For a `[ReferenceData]` property, the contract property is `{Name}Code` (a string) — so the column is `{Name}Code` (e.g. `[GenderCode] NVARCHAR(50) NULL`), **not** `{Name}Id`, and **no foreign key** (see step 4 of the create/alter workflow). Mirror the contract property name exactly. +> 5. **Lock the agreed identifier type — never deviate, especially in fixing loops.** Once the type is agreed (the `string` default, or whatever the user explicitly specified), it is fixed for the whole task. Do **not** silently change it, and do **not** revert to the scaffold's `UNIQUEIDENTIFIER` (or flip it again) while troubleshooting a build/migration failure — a failure is never resolved by changing the PK type. If you believe the agreed type is wrong, **stop and ask** rather than changing it. +> 6. **If in doubt about a type, nullability, or precision/length, ask** rather than guessing. + +### Creating or altering a table for an entity + +When asked to create or change a database table for a .NET entity (e.g. *"create a table for the Employee entity"*), do **not** blindly scaffold a `CREATE TABLE` script. The table — or a related reference data table — may already exist with a different shape. Use `Inspect` to establish the current state first. + +> **Inspect first — this is a hard gate.** Inspection is the **first action**, ahead of authoring anything. Do **not** write (or plan to write) a `CREATE`/`ALTER` script before the `Inspect` result is in hand — the result determines *whether* a script is even needed and *which kind*. A plan that scaffolds scripts before inspecting is wrong; fix the plan, don't proceed. + +> **Agent instruction:** +> 1. **Identify every table involved.** This includes the entity's own table plus any reference data tables implied by its `[ReferenceData]` properties (each typed reference-data property maps to a lookup table that may need to exist). +> 2. **Bring the database up to date, then inspect — before authoring any script.** First run `dotnet run -- database` (non-destructive: `Create` → `Migrate` → `Schema` → `Data`) so the live database reflects all authored scripts, then run `dotnet run -- inspect
[
...]` (read-only). Only proceed to authoring once you know each table's actual state. +> 3. **Branch per table on the `Inspect` result:** +> - **Not found** → scaffold the script with `dotnet run -- script create
` (or `script refdata
` for a reference-data table) so it is correctly named/timestamped/cased, fill in the columns, and register the table under `tables:` in `dbex.yaml`. Map column types per [Mapping contract types to columns](#mapping-contract-types-to-columns). **Do not create a schema-create script** — the schema already exists (template-provided). +> - **Found, Reference Data: Yes** → do **not** recreate or alter it directly. Reference it from the entity table per step 4 below. Any change to the reference data table's own shape flows through `ref-data.yaml` + CodeGen, not a hand-authored script. +> - **Found, schema differs from the contract** → scaffold with `dotnet run -- script alter
` and include the **delta only** (applied scripts are immutable — never edit the original create script). +> - **Found, schema already matches** → no script is needed; say so. +> 4. **Reference-data relationships are stored by `Code`, with no foreign key — this is the default; do not deviate silently.** For each `[ReferenceData]` property on the entity (the contract has a `{Name}Code` string property, e.g. `GenderCode`): +> - **Default — by Code:** create a column that **mirrors the contract property** — same name `{Name}Code` (e.g. `[GenderCode] NVARCHAR(50) NULL` / `gender_code`), typed to match the reference data's `Code` column. **Do not create a foreign key.** Do **not** invent a `{Name}Id` column. +> - **Only if the user explicitly asks for an Id reference** → name it `{Name}Id`, typed to match the reference data identifier; and even then a foreign key is **not** automatic — ask whether one is required and add it only if confirmed. +> +> **Never** default to `{Name}Id` + a `REFERENCES` foreign key — that contradicts both the contract (whose property is `{Name}Code`) and the CoreEx convention (reference data is resolved by code, not FK-joined). +> 5. **Confirm logical-delete support** (for root/aggregate tables). Ask whether the table should support logical (soft) deletes — **default yes**. If yes, add an infrastructure-only column: `[IsDeleted] BIT NOT NULL DEFAULT (0)` (SQL Server) / `is_deleted BOOLEAN NOT NULL DEFAULT FALSE` (PostgreSQL) — it must be **NOT NULL** and default to the DB's `false` (`0` / `FALSE`), **never nullable**. This is a persistence concern only — the .NET contract/entity must **not** declare an equivalent property. +> 6. **Offer to apply.** Offer to run `dotnet run -- CreateMigrateAndCodeGen`. Summarise the output on success; on failure relay the **complete output verbatim**. +> +> **On failure, do not add defensive existence-guards.** Migration scripts are plain DDL — DbEx tracks which have been applied and runs each exactly once, so a `CREATE TABLE` does **not** need `IF NOT EXISTS` / `IF OBJECT_ID(...) IS NULL` wrappers. If a script fails because an object already exists (or differs), that means the `Inspect` step was skipped or stale: **re-inspect** to learn the real state, then either remove the redundant create script (object already correct), or author a separate `ALTER` migration for the delta. Wrapping the DDL in conditional guards to make it "pass" masks the underlying state mismatch and is not the convention — never do it. + +### `Schema` — idempotent objects + +Objects under `Schema/` are dropped and re-created on every `Schema` run, making them safely idempotent. When `outbox: true` is set in `dbex.yaml`, DbEx generates the full outbox schema objects here: + +| SQL Server | PostgreSQL | +|---|---| +| `Schema/Stored Procedures/spOutboxEnqueue.g.sql` | `Schema/Functions/fn_outbox_enqueue.g.pgsql` | +| `spOutboxLeaseAcquire.g.sql` | `fn_outbox_lease_acquire.g.pgsql` | +| `spOutboxLeaseRelease.g.sql` | `fn_outbox_lease_release.g.pgsql` | +| `spOutboxBatchClaim.g.sql` | `fn_outbox_batch_claim.g.pgsql` | +| `spOutboxBatchComplete.g.sql` | `fn_outbox_batch_complete.g.pgsql` | +| `spOutboxBatchCancel.g.sql` | `fn_outbox_batch_cancel.g.pgsql` | + +These `.g.sql` / `.g.pgsql` files are generated by DbEx — never edit them directly. + +### `Data` — seeding + +> 🚫 **NEVER include the identifier (`{Name}Id`, e.g. `GenderId`) in a seed row.** The `$^` prefix **auto-generates** the id — providing it is a bug (it conflicts with the generated key). A row carries **only** the business columns (`Code`, `Text`, and any extra/`scale` columns). This is the single most common seeding mistake — it applies in **both** the shorthand and the inline-object form: +> ```yaml +> - M: Male # ✅ shorthand (preferred) +> - { code: HR, text: Hour, scale: 2 } # ✅ inline object (extra columns) — still no id +> - { GenderId: M, Code: M, Text: Male, ... } # 🚫 NEVER — has the GenderId identifier +> ``` + +> ⚠️ **This is `*.Database/Data/ref-data.seed.yaml` — the seed-data file** (the `.seed.` distinguishes it), *not* the `*.CodeGen/ref-data.yaml` entity-definition file (see [`ref-data.yaml` structure](#ref-datayaml-structure)). It uses the seed format below (`Schema:` → `- $^Table:` → rows) — **never** the `entities:` definition format. + +Seed data in `Data/ref-data.seed.yaml` is **cross-environment** — it is applied in every environment including production. It should therefore contain only shared **reference data** (lookup tables, code lists) that must exist everywhere. Do not seed master or transactional data here unless it is genuinely required in all environments; test-specific data belongs in the test project's own `data.yaml`, applied only during test setup. + +**Structure** — there is exactly one valid shape, three levels deep: + +``` +: # root mapping key = schema name (no prefix, no dots) + - $^
: # YAML LIST ITEM (note the leading "- ") = table, with a $ / $^ prefix + - # rows +``` + +- The **schema** is the root mapping key — **never** a dotted `Schema.Table:` key (e.g. `Bar.Gender:` is **wrong**), and **never** prefixed. +- Each **table** is a YAML **list item** — it **must** begin with `- ` (e.g. `- $^Gender:`). A bare mapping key without the dash (`$^Gender:`) is **wrong** — that makes it an object property, not a list entry, and DbEx will not process it. Also never mash schema and table (`- $Bar.$^Gender:` is wrong). +- The **prefix is required** on reference-data table entries. **Reference data always uses `$^`** (merge + auto-generate the identifier) — this is the default **regardless of the identifier's type**. `^` auto-generates the id for *any* id type, not just `Guid` (DbEx handles, and can be extended per type) — so a `string`/`NVARCHAR(50)` PK still uses `$^`. Use a different prefix **only when explicitly asked**: plain `$` (merge, no auto-id) when ids are supplied/assigned externally. An **unprefixed** entry is a plain INSERT — not re-runnable; never use it for reference data. + +DbEx infers column types from the live schema. + +**Names follow the provider's casing** (same as the schema and migration scripts): +- **SQL Server** — PascalCase: schema `Bar`, table `Gender`, columns `Code`, `Text`, `IsActive`, `SortOrder`. +- **PostgreSQL** — snake_case: schema `bar`, table `gender`, columns `code`, `text`, `is_active`, `sort_order`. + +Prefixes control merge behaviour and identifier generation (on the table entry): + +| Prefix | Meaning | +|---|---| +| `$` | MERGE (upsert) — safe to re-run | +| `^` | Auto-generate the primary-key identifier (**any** id type — not GUID-only) | +| `$^` | Both — merge + auto-generated id; **the default for reference data**, whatever the id type | + +Prefer the `Code: Text` **shorthand** (it sets `Code` and `Text`); use an inline object only for extra columns. **Never set the identifier column** (`{Name}Id`, e.g. `GenderId`) — `$^` auto-generates it, so supplying it is wrong unless you were **explicitly** asked to provide ids. Likewise omit `IsActive` (defaults active) and `SortOrder` (auto-assigned by row order — see below). The ideal row is just `M: Male`. + +```yaml +# SQL Server (PascalCase) — schema "Bar", reference table "Gender" +Bar: + - $^Gender: # merge + auto-generated id (the ref-data default, any id type) + - F: Female + - M: Male + - X: Other +``` + +```yaml +# PostgreSQL (snake_case) — schema "products" +products: + - $^brand: # merge + auto-generated id (the ref-data default, any id type) + - CANYON: Canyon Bicycles + - YETI: Yeti Cycles + - $^unit_of_measure: + - EA: Each + - { code: HR, text: Hour, scale: 2 } # inline object for additional columns + - $^sub_category: + - { code: XC, text: Cross country, category_code: B } # FK column by code; DbEx resolves id at runtime +``` + +**`SortOrder` is auto-assigned from row order.** When a ref-data row does not specify `SortOrder`, DbEx assigns it based on the row's position in the YAML. So **order the rows the way they should sort** — normally **by `Code`** — otherwise the resulting `SortOrder` (and thus default display order) will look arbitrary. Only specify an explicit `SortOrder` value when it must differ from positional order. + +## Do Not + +- Do not edit `*.g.cs`, `*.g.sql`, or `*.g.pgsql` files directly — they are owned by `*.CodeGen` or `*.Database` tooling. +- Do not use SQL Server packages (`DbEx.SqlServer`) in PostgreSQL domains or vice versa. +- Do not alter applied migration scripts — subsequent schema changes require new scripts. +- Do not hand-author the outbox stored procedures or functions — set `outbox: true` in `dbex.yaml` and let DbEx generate them. +- Do not write persistence models or `DbContext` partials by hand — run `dotnet run -- CodeGen` (or `dotnet run -- All`) to regenerate. +- Do not hand-create or hand-name migration script files — scaffold via `dotnet run -- script ...`; names must be `yyyyMMdd-HHmmss-` using the current date+time (never a placeholder date or a per-day index) and be fully kebab-lower-case. +- Do not author a schema-create script — the template already provides the default schema; only create one if the user explicitly asks for an additional schema. +- Do not modify the database directly to unblock anything — no ad-hoc `CREATE`/`ALTER`/`DROP`/`INSERT`/`UPDATE`/`DELETE`, and never touch DbEx's journal/tracking table (no pre-seeding rows). Structural change = migration script; data = `Data/*.yaml`. If state is inconsistent, stop and ask the user. +- Do not leave ref-data seed rows unordered, and do not default `SortOrder` via `RefDataColumnDefault` — order the YAML rows by `Code` so DbEx's positional `SortOrder` assignment is sensible. +- Do not use a dotted `Schema.Table:` seed key (e.g. `Bar.Gender:`) or mash schema and table into one prefixed key (`- $Schema.$^Table:`) — the schema is the **root mapping key**, and the prefixed table is a **list entry** beneath it (`Schema:` → `- $^Table:` → rows). +- Do not omit the leading `- ` on a table entry — it is a YAML **list item** (`- $^Gender:`), not a bare mapping key (`$^Gender:`); without the dash DbEx will not process it. +- **Do not put the identifier (`{Name}Id`, e.g. `GenderId`) in a seed row — ever, under `$^`.** The `$^` prefix auto-generates the id; a row has only `Code`/`Text`/extra columns. (An id is supplied only in the rare explicit case of plain `$` with externally-assigned keys — not the reference-data default.) +- Do not write an **unprefixed** ref-data table entry — it is a plain INSERT (not re-runnable). Default reference data to **`$^`** (merge + auto-generate the id, **any** id type — `^` is not GUID-only); use plain `$` only when explicitly asked (ids supplied externally). Do not downgrade `$^` to `$` just because the PK is a `string`/`NVARCHAR(50)`. +- Do not work out of order — follow the [database-first order of operations](#order-of-operations-database-first) (inspect → author migrations → seed + `dbex.yaml` → `All` → CoreEx CodeGen → .NET code). Add seed rows and `dbex.yaml` tables only **after** their create migrations exist, and apply them together via `All` (Migrate creates the tables before Data seeds / CodeGen introspects). Never run a bring-up that would seed, or CodeGen, against tables whose create migration does not yet exist. +- Do not continue past a failed `dotnet run -- database` bring-up — stop and surface the error; a broken baseline invalidates everything downstream, and pressing on causes churn-y, misdirected fixes. +- Do not keep the `script` scaffold's generated-key PK — `[{Name}Id] UNIQUEIDENTIFIER ... DEFAULT (NEWSEQUENTIALID())` (SQL Server) or `"{name}_id" SERIAL` (PostgreSQL) — replace it with the agreed identifier type's column (`string` default → `NVARCHAR(50)`/`VARCHAR(50)`), dropping the value-generation default **unless the user explicitly asks to keep/include it**. +- Do not change the agreed identifier type to make a failure go away — it is locked for the task; never revert to `UNIQUEIDENTIFIER` (or flip the type) in a fixing loop. If you think it is wrong, stop and ask. +- Do not default a reference-data relationship to a `{Name}Id` column or a `REFERENCES` foreign key — mirror the contract's `{Name}Code` string property (e.g. `[GenderCode] NVARCHAR(50) NULL`, **no FK**). Use `{Name}Id`/FK only when the user explicitly asks. +- Do not make the logical-delete column nullable or default-less — it is `[IsDeleted] BIT NOT NULL DEFAULT (0)` (SQL Server) / `is_deleted BOOLEAN NOT NULL DEFAULT FALSE` (PostgreSQL): **NOT NULL**, defaulting to the DB's `false`. +- Do not add `efModel` to a `dbex.yaml` table entry when it would be the default — write the bare `- name: Xxx` (not `- { name: Xxx, efModel: Yes }`). `efModel: Yes` is redundant (it's the default); `efModel: true` is invalid (it's a `Yes`/`No`/`ModelOnly`/`ModelBuilderOnly` choice). Only set `efModel` for a non-default (`No`/`ModelOnly`/`ModelBuilderOnly`). +- Do not declare the `IsDeleted` column under a table's `columns:` (and there is no `isDeleted` column flag) — it is recognised by convention from the live schema; keep table entries to `- name: Xxx` unless an override is genuinely needed. +- Do not add a per-table `schema:` override for reference data (e.g. a `Ref` schema) — reference and transactional tables both live in the domain's root `schema:` unless a different schema actually exists. +- Do not use the wrong casing in seed data — match the provider (SQL Server PascalCase `Code`/`Text`/`IsActive`/`SortOrder`; PostgreSQL snake_case `code`/`text`/`is_active`/`sort_order`), and do not hand-write `id`/`IsActive`/`SortOrder` rows — prefer the `Code: Text` shorthand. + +## Further Reading + +- [Tooling Guide](https://github.com/Avanade/CoreEx/blob/main/samples/docs/tooling.md) — full `*.CodeGen` and `*.Database` walkthrough with command reference. +- [CodeGen Schema Docs](https://github.com/Avanade/CoreEx/tree/main/src/CoreEx.CodeGen/docs) — `ref-data.yaml` schema: `CodeGeneration.md`, `Entity.md`, `Property.md`. +- [DbEx on GitHub](https://github.com/Avanade/DbEx) — DbEx command reference, YAML schema, and migration script conventions. +- [OnRamp on GitHub](https://github.com/Avanade/OnRamp) — Handlebars-based code generation engine used by `*.CodeGen`. diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-validators.instructions.md b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-validators.instructions.md new file mode 100644 index 00000000..d7eaea85 --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/assets/templates/.github/instructions/coreex-validators.instructions.md @@ -0,0 +1,313 @@ +--- +applyTo: "**/*Validator*.cs" +description: "Validator conventions: Validator, AbstractValidator, declarative rules, async OnValidateAsync, nested/dictionary validators, and Result-based invocation" +tags: ["validators", "validation", "fluent-api", "rules", "error-handling", "application-layer"] +--- + +# Validator Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx.Validation` | `Validator`, `Validator`, `AbstractValidator`, `AbstractValidator`, `Validator.Create()`, `.Mandatory()`, `.MaximumLength()`, `.IsValid()`, `.PrecisionScale()`, `.GreaterThanOrEqualTo()`, `.LessThanOrEqualTo()`, `.Equal()`, `.NotFound()`, `.WhenValue()`, `.Error()`, `.DependsOn()`, `.Entity()`, `.Dictionary()`, `.WithKeyValidator()`, `.WithValueValidator()`, `ValidationContext`, `.ValidateFurtherAsync()`, `.ValidateAndThrowAsync()`, `.ValidateWithResultAsync()` | +| `CoreEx` | `LText` — localised text label for use in `.WithKeyValidator(label, ...)`; `[Localization(...)]` attribute on contract properties | +| `CoreEx.UnitTesting` | `.AssertErrors()` — test-only helper for asserting expected validation errors inline | + +## Placement + +Validators live in `Application/Validators/`. They belong to the Application layer and may inject Application-layer dependencies (e.g., `IProductRepository`) — they must not reference Infrastructure directly. + +## Unit Tests (maintain alongside the validator) + +Validators are the primary unit-test target, so a validator and its test are maintained together. Validator rules are proven **exhaustively here** (every rule, error + success) — the API integration tests do **not** re-enumerate them; they assert only one representative bad-request to confirm the validator is wired into the pipeline. See the test-responsibility split in `coreex-tests.instructions.md`. + +Author the test per `coreex-tests.instructions.md` → "Validator unit tests" and "Expected message text". Three things that cause repeated discover-by-running loops if missed: +- **Invoke via `Test.Scoped(test => { XxxValidator.Default.AssertErrors(...); })`** — the **non-generic** `Test.Scoped` (no type parameter) + the validator's `Default` (or `new XxxValidator(deps)`). **Never** `Test.Scoped(...)` — validators are not in DI, so the generic (DI-resolving) overload fails. +- **`AssertErrors` takes `(jsonName, message)` tuples** — camelCase JSON property path + the full message; ref-data rules key on the **navigation** name (`"gender"`, not `"genderCode"`). +- **Expected messages are exact** — use the message table in `coreex-tests.instructions.md` (`Mandatory()` → `"{Label} is required."`, `MaximumLength` → `"… character(s) in length."`, `PrecisionScale` → `"… exceeds the maximum decimal places (n)."`, etc.) with sentence-cased labels (`FirstName` → "First name"). +- **Ignore `ExecutionContext` in tests** — `Test.Scoped(...)` sets it up for you; do not construct, inject, or mock it. Ambient `Runtime` and any `ExecutionContext`-dependent rule work automatically inside the scope. + +> **Agent instruction:** When you create or modify a validator, **offer to also create or update the matching `{Validator}Tests`** in the `*.Test.Unit/Validators/` project (covering the new/changed rules — both error and success cases). If the user accepts, author it per `coreex-tests.instructions.md`; if the validator uses a reference-data type the test host does not yet handle, also add the corresponding case to `EntryPoint.ReferenceDataServiceDecorator.GetAsync`. If the user declines or defers, proceed with the validator change but note that its unit-test coverage is now missing/stale. + +## Base Class + +Choose the base class based on whether a `Default` singleton and constructor injection are needed: + +**`Validator`** — use when no constructor injection is required. The two-type-argument form exposes a static `Default` singleton automatically: + +```csharp +public class ProductValidator : Validator +{ + public ProductValidator() + { + Property(p => p.Sku).Mandatory().MaximumLength(50); + Property(p => p.Text).Mandatory().MaximumLength(250); + Property(p => p.SubCategory).Mandatory().IsValid(); + Property(p => p.UnitOfMeasure).Mandatory().IsValid(); + Property(p => p.Price).PrecisionScale(null, 2).GreaterThanOrEqualTo(0, _ => "zero"); + } +} +``` + +**`Validator`** — use when constructor injection is required (e.g., a repository dependency). There is no `Default` singleton; register the validator in DI and inject it: + +```csharp +public class MovementRequestValidator : Validator +{ + private readonly IProductRepository _repository; + + public MovementRequestValidator(IProductRepository repository) + { + _repository = repository.ThrowIfNull(); + Property(x => x.Id).Mandatory().MaximumLength(50); + // ... + } +} +``` + +**`AbstractValidator`** — a FluentValidation-style compatibility alias for `Validator`. Use when your team prefers the `RuleFor(x => ...)` / `NotEmpty()` / `GreaterThanOrEqualTo()` syntax. Validation and error handling are still performed by CoreEx: + +```csharp +public class ProductValidator : AbstractValidator +{ + public ProductValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Sku).NotEmpty(); + RuleFor(x => x.UnitOfMeasure).NotEmpty().IsValid(); + RuleFor(x => x.Price).GreaterThanOrEqualTo(0); + } +} +``` + +Do **not** use the `FluentValidation` NuGet package — `AbstractValidator` here is `CoreEx.Validation.AbstractValidator`, not FluentValidation. + +## Invoking Validators + +For `Validator` (no injection), call via the static `Default` singleton: + +```csharp +// Exception style — throws ValidationException on failure +await ProductValidator.Default.ValidateAndThrowAsync(product, cancellationToken); + +// Result style — returns Result for pipeline composition +var result = await ProductValidator.Default.ValidateWithResultAsync(product, cancellationToken); +``` + +For `Validator` (with injection), the instance is resolved from DI and invoked the same way: + +```csharp +await _movementRequestValidator.ValidateAndThrowAsync(request, cancellationToken); +``` + +## Common Rules + +| Rule | Method | +|---|---| +| Required | `.Mandatory()` | +| Max string length | `.MaximumLength(n)` | +| Reference data validity | `.IsValid()` | +| Decimal precision | `.PrecisionScale(precision, scale)` | +| Greater than or equal to | `.GreaterThanOrEqualTo(value)` | +| Less than or equal to | `.LessThanOrEqualTo(value)` | +| Equals | `.Equal(value)` | +| Not found (for key lookup) | `.NotFound()` | +| Conditional rule | `.WhenValue(predicate)` | +| Custom error text | `.Error("message")` | + +### Full rule and clause reference + +The table above lists the most common rules; the full set of fluent extension methods is below. All are part of `CoreEx.Validation` — consult the [CoreEx.Validation README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Validation/README.md) for complete detail and overloads before hand-rolling logic in `OnValidateAsync` (most needs are already covered by a rule). + +| Category | Rules (extension methods) | +|---|---| +| Presence (required) | `Mandatory()`, `NotNull()`, `NotEmpty()` | +| Absence (must be unset) | `Null()`, `None()`, `Empty()` | +| Strings | `MaximumLength(n)`, `MinimumLength(n)`, `Length(exact)`, `String(maxLength)`, `String(min, max, regex)`, `Matches(regex)`, `Wildcard()`, `Email()` / `Email(maxLength)` | +| Numbers / decimals | `Numeric(allowNegatives)`, `Positive()`, `Decimal(precision, scale)`, `PrecisionScale(precision, scale)` | +| Comparisons (value) | `Equal(v)`, `NotEqual(v)`, `LessThan(v)`, `LessThanOrEqualTo(v)`, `GreaterThan(v)`, `GreaterThanOrEqualTo(v)`, `Compare(op, v)` — each with a delegate overload for runtime values (see below) | +| Comparisons (other) | `CompareProperty(op, x => x.Other)`, `CompareValues(values)`, `Between(min, max)`, `InclusiveBetween(min, max)`, `ExclusiveBetween(min, max)` | +| Enums | `Enum()`, `Enum(allowed[])`, string `Enum(c => c....)` | +| Reference data | `IsValid(allowInactive)` / `ReferenceData(allowInactive)` (typed property), `ReferenceDataCodes()` / `AreValid()` (code collection), string `ReferenceData(c => c....)` | +| Collections | `Collection(...)`, with `WithDuplicateIdCheck()` / `WithDuplicateKeyCheck()` / `WithDuplicatePropertyCheck()` / `WithDuplicateCheck()` | +| Dictionaries | `Dictionary(...)` (with `WithKeyValidator(...)` / `WithValueValidator(...)`) | +| Child / shared / external | `Entity(validator)` (child entity), `Common(commonValidator)` (shared value rules), `Interop(validator)` (external/FluentValidation) | +| Always-error (guard with a clause) | `Error(text)`, `Duplicate()`, `NotFound()`, `Invalid()`, `Immutable()` | +| Clauses (conditional execution) | `When(...)`, `WhenValue(pred)`, `WhenHasValue()`, `WhenEntity(pred)`, `DependsOn(x => x.Other)` | + +> **`Mandatory()` on a non-nullable value type checks `default`, not just null.** `Mandatory()` defaults to `mustNotBeDefault: true`, so on a non-nullable value type it **errors when the value equals `default(T)`** — `0` for `decimal`/`int`, `DateOnly.MinValue` for `DateOnly`, etc. (On a **nullable** type — `decimal?`, `DateOnly?` — it errors only on `null`, since a supplied `0` is non-default.) Implications: +> - **Do not use `Mandatory()` on a non-nullable value type where the default is a legitimate value** (e.g. a `decimal Salary` where `0` is allowed) — it will wrongly reject `0`. Use a range rule instead, e.g. `GreaterThanOrEqualTo(0)` (allows `0`) or `GreaterThan(0)` (requires positive). To require presence *without* the default-check, pass `Mandatory(mustNotBeDefault: false)`. +> - **The expected unit-test error follows the same rule:** a `Mandatory()` on a non-nullable `decimal`/`DateOnly` produces the *required* error for `0`/`MinValue`, so a test arranging that value must expect the mandatory error (not a pass). + +### Comparisons + +Prefer the dedicated comparison rules — `.GreaterThanOrEqualTo(value)`, `.LessThanOrEqualTo(value)`, `.GreaterThan(value)`, `.LessThan(value)`, `.Equal(value)`. For the general form use the **`.Compare(...)`** extension with a `CompareOperator` value: + +```csharp +// ✅ Idiomatic — dedicated rule (preferred) +Property(x => x.Salary).GreaterThanOrEqualTo(0m, _ => "zero").PrecisionScale(18, 2); + +// ✅ Equivalent — general Compare extension +Property(x => x.Salary).Compare(CompareOperator.GreaterThanOrEqualTo, 0m, "zero").PrecisionScale(18, 2); + +// ❌ Wrong — no such method `CompareValue`, and `GreaterThanEqual` is not a valid CompareOperator +Property(x => x.Salary).CompareValue(CompareOperator.GreaterThanEqual, 0m, "zero").PrecisionScale(18, 2); +``` + +The extension is **`Compare`** (not `CompareValue`), and the `CompareOperator` members are `Equal`, `NotEqual`, `LessThan`, `LessThanOrEqualTo`, `GreaterThan`, `GreaterThanOrEqualTo` (there is no `GreaterThanEqual`). + +#### Runtime-computed values (delegate overloads) + +Most comparison rules (and many others) provide a **delegate overload** — `Func, TProperty>` — so the value can be computed at runtime. Prefer a declarative rule with a delegate over an imperative check in `OnValidateAsync` whenever the rule can express it. For example, "at least 16 years old" is a comparison rule, not hand-written logic. + +Mind the direction: the threshold is `today − 16 years` — the **latest** acceptable birth date — so being at least 16 means `DateOfBirth <= threshold`. A rule passes when its condition holds, so the correct rule is **`LessThanOrEqualTo`** (it errors when `DateOfBirth` is *after* the threshold, i.e. younger than 16): + +```csharp +// ✅ Preferred — declarative rule with a computed threshold (no OnValidateAsync needed) +Property(x => x.DateOfBirth) + .Mandatory() + .LessThanOrEqualTo(_ => DateOnly.FromDateTime(Runtime.UtcNow.UtcDateTime.AddYears(-16)), _ => "the minimum age of 16"); +``` + +The delegate parameter is the `PropertyContext` (use `ctx => ctx.Entity...` to compute relative to other properties; ignore it with `_ =>` for an absolute value such as one derived from `Runtime.UtcNow`). Always sanity-check the comparison direction so the rule *fails* on the invalid case, matching the equivalent imperative check (`if (DateOfBirth > threshold) AddError(...)`). + +#### Message text is a suffix, not a full sentence + +The optional text argument on these rules (e.g. `"zero"`, `_ => "the minimum age of 16"`) supplies **only the value substitution** (`{2}`) in the standard message template — it is **not** a complete error message. For instance `CompareLessThanEqualFormat` is `"{0} must be less than or equal to {2}."`, so `.LessThanOrEqualTo(..., _ => "the minimum age of 16")` renders *"Date Of Birth must be less than or equal to the minimum age of 16."* Keep the text short (the value rendering only); the standard wrapper supplies the rest. The default templates live in `ValidatorStrings.cs` (each is an overridable, localizable `LText`) — consult it rather than re-inventing full messages. To override the *entire* message, use `.Error("...")` instead. + +## Reference Data Fields + +Use `.IsValid()` on the **typed reference-data navigation property** to validate that the value is a known active item — **not** the serialized `*Code` string property. For a contract with `GenderCode`, validate the generated `Gender` property; for `SubCategoryCode`, validate `SubCategory`; and so on. + +**Required vs optional:** chain **`.Mandatory().IsValid()`** when the ref-data field is **required**; use **`.IsValid()` alone** when it is **optional** (a null/unset value then passes, but a *supplied* value must still be a known active item). `IsValid()` on its own does not enforce presence. + +```csharp +// ✅ Correct — validate the typed navigation property +Property(p => p.SubCategory).Mandatory().IsValid(); // required ref-data +Property(p => p.UnitOfMeasure).Mandatory().IsValid(); // required ref-data +Property(x => x.Gender).IsValid(); // optional ref-data — valid-if-supplied, null allowed + +// ❌ Wrong — do not apply .IsValid() to the *Code string property +Property(x => x.GenderCode).IsValid(); +``` + +## Nested / Collection Validators + +For entities with nested objects, create a separate `Validator.Create()` for the nested type and reference it via `.Entity(validator)` or `.Dictionary(c => c.WithKeyValidator(...).WithValueValidator(...))`. + +Use `LText` to provide a localised label for dictionary keys in error messages: + +```csharp +private static readonly LText _productText = "Product"; + +private static readonly Validator _productValidator = Validator.Create() + .HasProperty(x => x.UnitOfMeasure, c => c.Mandatory().IsValid()) + .HasProperty(x => x.Quantity, c => c.GreaterThanOrEqualTo(0).DependsOn(x => x.UnitOfMeasure)); + +// In parent validator constructor: +Property(x => x.Products).Mandatory().Dictionary(c => c + .WithKeyValidator(_productText, k => k.Mandatory().MaximumLength(50)) + .WithValueValidator(v => v.Mandatory().Entity(_productValidator))); +``` + +When the value validator needs to access the dictionary key (e.g., to look up data keyed by that value), use `ctx.GetDictionaryKey()` inside the rule lambda: + +```csharp +var dv = Validator.Create() + .HasProperty(x => x.UnitOfMeasure, c => c.Equal( + ctx => products[ctx.GetDictionaryKey()].UnitOfMeasureCode)); +``` + +## Async Validation (Database Checks) + +Override `OnValidateAsync` for validators that need to query the database. Check `context.HasErrors` first to skip expensive async work if earlier rules already failed — this global guard is appropriate here because the I/O assumes a valid entity. (When a check merely augments a single property, gate on that property with `context.HasError(x => x.Prop)` instead — see [Adding Errors Manually](#adding-errors-manually).) + +```csharp +protected async override Task OnValidateAsync(ValidationContext context, CancellationToken cancellationToken) +{ + if (context.HasErrors) + return; + + var ids = context.Value.Products!.Keys.ToArray(); + var products = await _repository.GetForReservationAsync(ids, cancellationToken).ConfigureAwait(false); + + await context.ValidateFurtherAsync(c => c + .HasProperty(x => x.Products, c => c.Dictionary(c => c + .WithKeyValidator("Product", k => k + .NotFound().WhenValue(v => !products.ContainsKey(v)) + .Error("{0} is non-stocked.").WhenValue(v => products[v].IsNonStocked)) + )), cancellationToken).ConfigureAwait(false); +} +``` + +## Adding Errors Manually + +**Prefer a declarative rule first.** Most checks — including those needing runtime-computed values — are expressible as rules via the delegate overloads (see [Runtime-computed values](#runtime-computed-values-delegate-overloads)); the "at least 16 years old" check below is better written as `Property(x => x.DateOfBirth).LessThanOrEqualTo(_ => DateOnly.FromDateTime(Runtime.UtcNow.UtcDateTime.AddYears(-16)), _ => "the minimum age of 16")`. Reserve manual `OnValidateAsync` + `AddError` for logic that genuinely cannot be a rule (e.g. multi-field conditions or checks requiring async I/O). + +When you do add an error directly, identify the property using the **member-access expression** overload of `context.AddError` — `context.AddError(x => x.Property, ...)`. Never pass a property-name string such as `nameof(...)`; the expression resolves the label, JSON name, and metadata automatically. + +**Choose the right guard — `HasErrors` vs `HasError(x => x.Prop)`:** +- `context.HasErrors` (global) — bail early only when the *following logic needs the whole entity in a valid state*, e.g. before async I/O / database lookups (see [Async Validation](#async-validation-database-checks)). +- `context.HasError(x => x.Prop)` (per-property) — when you are simply layering an additional rule onto a single property, gate on **that property only**. This still runs your check when unrelated properties have failed, and skips it only when the property itself is already in error (avoiding a misleading second message). + +```csharp +protected override Task OnValidateAsync(ValidationContext context, CancellationToken cancellationToken) +{ + // Only gate on DateOfBirth — an unrelated failure elsewhere should not skip this check. + if (!context.HasError(x => x.DateOfBirth)) + { + // Use Runtime.UtcNow (the ambient, ExecutionContext-aware clock) — never DateTime.UtcNow / DateTimeOffset.UtcNow. + var minDob = DateOnly.FromDateTime(Runtime.UtcNow.UtcDateTime.AddYears(-16)); + if (context.Value.DateOfBirth > minDob) + context.AddError(x => x.DateOfBirth, "Employee must be at least 16 years old."); // expression — not nameof(...) + } + + return Task.CompletedTask; +} +``` + +## Localization Labels + +Property names in error messages use an auto-derived label by default (the PascalCase property name split into words, e.g. `DateOfBirth` → "Date Of Birth"). Override with `[Localization("...")]` on the contract property (or a custom label in the rule) **only when the default is undesired** — do not add `[Localization("Salary")]` to a `Salary` property, as it merely repeats the default: + +```csharp +// Contract +[Localization("Sub-category")] +public partial string? SubCategoryCode { get; set; } + +// Produces: "Sub-category is required." (not "SubCategoryCode is required.") +``` + +## DependsOn for Conditional Precision + +Use `.DependsOn(x => x.OtherProp)` to skip a rule when a dependent property is already invalid. This prevents misleading cascading errors: + +```csharp +Property(x => x.Quantity, c => c + .GreaterThanOrEqualTo(0) + .PrecisionScale( + ctx => ctx.Entity.UnitOfMeasure!.Precision, + ctx => ctx.Entity.UnitOfMeasure!.Scale) + .DependsOn(x => x.UnitOfMeasure)); +``` + +## Do Not + +- Do not use the `FluentValidation` NuGet package — `AbstractValidator` here is `CoreEx.Validation.AbstractValidator`, not FluentValidation. +- Do not perform I/O in `OnValidateAsync` without first checking `context.HasErrors` — always fail fast. +- Do not reference Infrastructure assemblies from validators — inject Application-layer repository interfaces only. +- Do not instantiate validators with `new` at the call site when a `Default` singleton is available. +- Do not add logic that requires async I/O to the constructor — use `OnValidateAsync` for that. +- Do not pass a property-name string (e.g. `nameof(...)`) to `context.AddError` — use the member-access expression overload, `context.AddError(x => x.Property, ...)`. +- Do not apply `.IsValid()` to a `*Code` string property — validate the typed reference-data navigation property instead (e.g. `Gender`, not `GenderCode`). +- Do not use `CompareValue(...)` or a `CompareOperator.GreaterThanEqual` value — the extension is `.Compare(...)` and the operator is `CompareOperator.GreaterThanOrEqualTo` (or use the dedicated `.GreaterThanOrEqualTo(...)` rule). +- Do not hand-write logic in `OnValidateAsync` for something expressible as a rule — use the delegate overloads for runtime-computed values (e.g. `.LessThanOrEqualTo(_ => DateOnly.FromDateTime(Runtime.UtcNow.UtcDateTime.AddYears(-16)), _ => "the minimum age of 16")`). Sanity-check the comparison direction so the rule fails on the *invalid* case. +- Do not put a full sentence in a rule's text argument — it is only the `{2}` value substitution in the standard message template; override the whole message with `.Error("...")`, and consult `ValidatorStrings.cs` for the defaults. +- Do not add a redundant `[Localization]` whose value equals the auto-derived label (e.g. `[Localization("Salary")]` on `Salary`) — only annotate to change the label. + +## Further Reading + +- [Application Layer Guide — Validators](https://github.com/Avanade/CoreEx/blob/main/samples/docs/application-layer.md) — full validator walkthrough including declarative and programmatic phases. +- [Pattern Catalog](https://github.com/Avanade/CoreEx/blob/main/samples/docs/patterns.md) — Validator pattern entry with cross-links. +- [CoreEx.Validation README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Validation/README.md) — `Validator`, rule set, `OnValidateAsync`, `ValidateFurtherAsync`, and `AbstractValidator`. diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/references/checklists.md b/plugins/coreex-agent-pack/skills/coreex-onboard/references/checklists.md new file mode 100644 index 00000000..d256eb46 --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/references/checklists.md @@ -0,0 +1,21 @@ +# CoreEx Onboard Checklists + +## Readiness checklist + +- [ ] Template files are present under `assets/templates/.github/`. +- [ ] Repository root is writable. +- [ ] User selected mode: `safe` (default) or `force`. + +## Completion checklist + +- [ ] `.github/copilot-instructions.md` exists. +- [ ] `.github/instructions/` contains all CoreEx `*.instructions.md` files from templates. +- [ ] `.github/agents/coreex-expert.agent.md` exists. +- [ ] `.github/coreex-bootstrap.json` exists and is valid JSON. +- [ ] Result summary includes created/overwritten/skipped file counts. + +## Safety checklist + +- [ ] No overwrite in safe mode. +- [ ] Overwrites in force mode are explicitly requested. +- [ ] Missing source templates fail fast with a clear message. diff --git a/plugins/coreex-agent-pack/skills/coreex-onboard/references/workflow.md b/plugins/coreex-agent-pack/skills/coreex-onboard/references/workflow.md new file mode 100644 index 00000000..40f65f54 --- /dev/null +++ b/plugins/coreex-agent-pack/skills/coreex-onboard/references/workflow.md @@ -0,0 +1,52 @@ +# CoreEx Onboard Workflow + +## Phase 1: Preflight + +1. Confirm the working directory is a repository root. +2. Ensure these paths exist or can be created: + - `.github/` + - `.github/instructions/` + - `.github/agents/` +3. Discover template files from: + - `assets/templates/.github/copilot-instructions.md` + - `assets/templates/.github/instructions/*.instructions.md` + - `assets/templates/.github/agents/coreex-expert.agent.md` + +## Phase 2: Plan file operations + +1. Build a source-to-target map. +2. For each target: + - If missing: mark `create`. + - If present and byte-equal: mark `skip-unchanged`. + - If present and different: + - `safe` mode: mark `skip-existing`. + - `force` mode: mark `overwrite`. + +## Phase 3: Apply + +1. Create any missing directories. +2. Execute operations in this order: + - `.github/copilot-instructions.md` + - `.github/instructions/*.instructions.md` (sorted name order) + - `.github/agents/coreex-expert.agent.md` +3. Create/update `.github/coreex-bootstrap.json`: + +```json +{ + "bootstrapVersion": "1.1.0", + "plugin": "coreex-agent-pack", + "skill": "coreex-onboard", + "mode": "safe|force", + "updatedAtUtc": "" +} +``` + +## Phase 4: Validate + +1. Verify all expected files exist after copy. +2. Verify marker file exists and is valid JSON. +3. Report: + - created files + - overwritten files + - skipped files (unchanged or protected by safe mode) +4. If any copy fails, surface the specific path and error. diff --git a/plugins/coreex-agent-pack/skills/solution-scaffolder/SKILL.md b/plugins/coreex-agent-pack/skills/solution-scaffolder/SKILL.md new file mode 100644 index 00000000..6aaf1c45 --- /dev/null +++ b/plugins/coreex-agent-pack/skills/solution-scaffolder/SKILL.md @@ -0,0 +1,185 @@ +--- +name: solution-scaffolder +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 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, 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." +argument-hint: "Optional: base solution name, whether this is new or retrofit, required hosts, database choice, and messaging needs." +tags: ["coreex", "scaffolding", "retrofit", "template", "hosts"] +--- + +# CoreEx Scaffold + +Guides a repository through the right CoreEx setup path by interviewing the user in simple English and translating the answers into safe CoreEx template commands. + +## When to Use + +- Starting a new CoreEx domain or service from a repository that already contains only the bootstrap AI assets. +- Deciding whether the first cut should create just the API host or also include the outbox relay and/or subscriber hosts. +- Adding missing `Api`, `Relay`, or `Subscribe` hosts to an existing scaffolded CoreEx solution. +- Converting plain-English project needs into either the matching `dotnet new coreex*` commands or the safest retrofit path. + +## When Not to Use + +- You are debugging local runtime, container, Aspire, or package restore issues unrelated to project shaping. +- You still need to create the initial bootstrap repository; run `dotnet new coreex-bootstrap` before this skill is used. +- You want architectural guidance for an existing implementation beyond project setup; use `CoreEx Expert` instead. + +## Workflow Overview + +1. **Inspect the workspace shape first.** +2. **Interview the user in simple English.** Ask one short question at a time and turn the answers into template inputs. +3. **Validate the naming shape before any template command.** +4. **Choose the safest implementation path.** Prefer dry-runs and stop on layout mismatches. +5. **Apply the right shape.** Generate only what is missing or explicitly requested. +6. **Validate the scaffold.** Wire projects into the solution, then choose between shape validation and fully runnable local validation. +7. **Summarize the result.** Show the derived inputs, commands, validations, and any deferred steps. + +For step-by-step guidance, see [the workflow guide](https://github.com/Avanade/CoreEx/blob/main/docs/application-scaffolding-guide.md). + +## Interactive Interview Rules + +- When `mcp_microsoft_git_confirm_options` is available, use it for every interview step. +- Each interview turn must contain exactly one editable field plus optional readonly context fields. +- Ask short, plain-English questions. Do not ask the user to supply template flag names. +- The canonical interview order is: base solution name, new vs retrofit, HTTP API, reliable event publishing, event consumption, data storage, messaging provider when needed, reference data, domain layer, and ROP. +- Use a single `text` field only for the base solution name. +- Use a single `select` field for every other interview step so the workflow stays multiple-choice and deterministic. +- For yes or no questions, use a `select` with `Yes` and `No` so the default remains visibly preselected. +- Prefer either/or or small-choice questions before any extra freeform follow-up. +- Do not batch multiple scaffold questions into a single assistant message. +- If the user gives a partial or non-canonical name, stop and help them reach `[Company].[Product].[Domain]` before any template command. +- If the workspace already proves a choice, confirm it instead of asking again. +- Before any real `dotnet new` command, restate the derived inputs in one compact summary and ask for confirmation when there is any ambiguity. + +## Default Selection Policy + +When the workspace does not already prove a value, preselect the safest default that still keeps the workflow moving: + +| Question | Default | +|---|---| +| Base solution name | The workspace root folder name if it is already in `[Company].[Product].[Domain]` form; otherwise the best canonical guess from workspace hints. If only two parts exist, use `Product` as a temporary middle segment. The user can override during the interview. | +| New domain or retrofit | `New domain` in a bootstrap-only repo | +| HTTP API | `Yes` | +| Reliable event publishing | `No` | +| Event consumption | `No` | +| Data storage | `No local database` | +| Messaging provider | `Yes` for Azure Service Bus when messaging is needed | +| Reference data | `No` | +| Domain layer | `No` | +| Result/ROP style | `No` | + +Set outbox to `true` only when the user chose owned persistence and reliable publishing. Otherwise keep it `false`. + +## Quick Reference + +| User answer | Template impact | +|---|---| +| "I need HTTP endpoints." | Add `coreex-api`. | +| "I need to publish events reliably." | Add `coreex-relay`; use a messaging provider and keep outbox enabled where relevant. | +| "I need to consume events." | Add `coreex-subscribe`; use a messaging provider. | +| "This service stores its own data in SQL Server." | `--data-provider SqlServer` | +| "This service stores its own data in Postgres." | `--data-provider Postgres` | +| "This is a facade. No local database." | `--data-provider None` | +| "I need reference data." | `--refdata-enabled true` | +| "I want a Domain layer." | `--domain-driven-enabled true` | +| "I want Result/ROP style pipelines." | `--rop-enabled true` | + +## Prerequisite + +Assume the repository is already in one of these states before this skill runs: +- a bootstrap-only shell created earlier by the `coreex-bootstrap` template (`dotnet new coreex-bootstrap`); or +- an existing CoreEx solution that is missing some runtime hosts. + +## Naming Rules + +- The base solution name must be in `[Company].[Product].[Domain]` form, e.g. `Avanade.Bookstore.Books`. +- The `coreex` (solution) template takes the **3-part base name**: `-n Company.Product.Domain`. If the current directory is already named `Company.Product.Domain`, `-n` can be omitted — the template uses the folder name automatically. +- Each **host template** takes a **4-part name** with the host suffix appended: + - `coreex-api -n Company.Product.Domain.Api` + - `coreex-relay -n Company.Product.Domain.Relay` + - `coreex-subscribe -n Company.Product.Domain.Subscribe` +- Host templates **always require `-n`** — the folder name is the 3-part base and cannot supply the host suffix automatically. +- Do not pass the 3-part base name to host templates; doing so causes all three hosts to emit into the same `src/Company.Product.Domain/` directory, overwriting each other. +- If the user gives only a two-part name, ask for the missing segment before running any template. +- If the repository already exists with a non-canonical root name (e.g., `Avanade.Books`), prefer a manual retrofit unless the user explicitly wants to rename the solution first. + +## Command Patterns + +### Bootstrap-Only Repository + +```text +# Step 1: Run the solution template. If the folder is named Avanade.Product.Books, -n can be omitted. +dotnet new coreex -n Avanade.Product.Books --data-provider SqlServer --messaging-provider ServiceBus --refdata-enabled true --outbox-enabled true --domain-driven-enabled false --rop-enabled false + +# Step 2: Add each host template using the 4-part name (base + host suffix). +dotnet new coreex-api -n Avanade.Product.Books.Api --data-provider SqlServer --refdata-enabled true --outbox-enabled true +dotnet new coreex-relay -n Avanade.Product.Books.Relay --data-provider SqlServer --messaging-provider ServiceBus +dotnet new coreex-subscribe -n Avanade.Product.Books.Subscribe --data-provider SqlServer --messaging-provider ServiceBus --refdata-enabled true + +# Step 3: Add host and test projects to the solution file. +dotnet sln Avanade.Product.Books.slnx add src/Avanade.Product.Books.Api +dotnet sln Avanade.Product.Books.slnx add tests/Avanade.Product.Books.Test.Api +dotnet sln Avanade.Product.Books.slnx add src/Avanade.Product.Books.Relay +dotnet sln Avanade.Product.Books.slnx add tests/Avanade.Product.Books.Test.Relay +dotnet sln Avanade.Product.Books.slnx add src/Avanade.Product.Books.Subscribe +dotnet sln Avanade.Product.Books.slnx add tests/Avanade.Product.Books.Test.Subscribe + +# Step 4: Validate the generated solution. +dotnet build Avanade.Product.Books.slnx +dotnet test tests/Avanade.Product.Books.Test.Unit +# Run broader test projects only when their local infrastructure dependencies are available. +``` + +### Existing Repository Retrofit (Skip Bootstrap) + +```text +# If the repo already has a bootstrap or partial scaffold, run only the missing domain/host templates. +# Confirm the canonical three-part base name, then run coreex and/or host templates with the correct suffixes. +dotnet new coreex -n Company.Product.Domain ... +dotnet new coreex-api -n Company.Product.Domain.Api ... +dotnet new coreex-relay -n Company.Product.Domain.Relay ... +dotnet new coreex-subscribe -n Company.Product.Domain.Subscribe ... +# Then add new projects to the solution file as in Step 3 above. +``` + +## Existing Repository Guardrails + +- **Bootstrap is a prereq:** This skill assumes any bootstrap creation has already happened before the workflow starts. +- **For retrofit work:** If the repo already contains a solution, `src/`, tooling, or tests, do not re-run the root scaffold unless the current shape is still only the bootstrap shell. +- **Template identity conflicts:** If `dotnet new list` or template execution reports duplicate CoreEx template identities, warn the user which template source is being selected before continuing. +- **Pre-flight validation:** Always run a dry-run with the intended base solution name before any real domain/host template invocation. +- **Watch for nested roots:** If dry-run output shows paths like `src\\Company.Product.Domain.Api\\src\\Company.Product.Domain.Api\\...`, the output root is wrong; stop and change strategy. +- **Naming mismatch:** If dry-run output shows incorrect project names because the repo uses a non-canonical root name, stop and recommend either renaming the solution first or manually creating the missing hosts. +- **Force only for bootstrap replacement:** In a confirmed bootstrap-only repo, `--force` is acceptable only after dry-run validation shows the expected canonical layout. +- **No force for mismatches:** Do not use `--force` to push through a naming or layout mismatch. The mismatch itself indicates an unsafe operation. + +## Validation Rules + +- After generation, verify that every expected host and test project exists on disk and has been added to the `.slnx`. +- Always run `dotnet build` on the solution as the minimum end-to-end validation step. +- Always run the unit test project when present. +- Run API, relay, or subscriber test projects only when their required local dependencies are available; otherwise skip them and report the reason explicitly. +- Treat missing local infrastructure such as SQL Server, Postgres, Redis, or Service Bus as a validation skip, not as a scaffolding failure, unless the user explicitly asked for full local environment setup. +- When the user explicitly wants a first local runnable state, create any missing local dependency assets before broader validation, using bundled templates where available. +- When `data-provider != None` and the user wants runnable local state, run the `*.Database` tool from its own project directory so `dbex.yaml` resolves correctly; prefer `dotnet run -- All` for first-run local setup. +- When `refdata-enabled` is `true` and the user wants runnable local state, run the `*.CodeGen` tool from its own project directory so its config file resolves correctly. +- If generated code fails to compile before environment or tool execution becomes relevant, treat that as a template or package-version defect first; prefer fixing the template pack rather than teaching the workflow to hand-patch generated output every time. +- If `refdata-enabled` is `true`, call out the `*.CodeGen` tool as the next step or run it only when the user wants a fully generated local state. +- If `data-provider != None`, call out the `*.Database` tool as the next step or run it only when the user wants local schema setup as part of scaffolding. + +## Shape Selection Guide + +| Need | Recommendation | +|---|---| +| Empty repo, own data, expose HTTP only | `coreex` + `coreex-api` | +| Empty repo, own data, expose HTTP, publish reliable events | `coreex` + `coreex-api` + `coreex-relay` | +| Empty repo, expose HTTP, publish events, consume events | `coreex` + `coreex-api` + `coreex-relay` + `coreex-subscribe` | +| Existing repo missing only runtime hosts | Infer current shape, then add only the missing hosts | +| Facade over another system with no local database | `coreex --data-provider None --messaging-provider None` and add `coreex-api --data-provider None` only if an API is needed | + +## Key References + +- [Application scaffolding guide](https://github.com/Avanade/CoreEx/blob/main/docs/application-scaffolding-guide.md). +- [Layer dependencies](https://github.com/Avanade/CoreEx/blob/main/samples/docs/layers.md). +- [Pattern catalog](https://github.com/Avanade/CoreEx/blob/main/samples/docs/patterns.md). +- [CoreEx.Template README](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Template/README.md). + +