From aec07ead35f218df981b7fa35ebdf3a4a054decd Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 27 May 2026 10:03:45 -0700 Subject: [PATCH 01/17] Review co-pilot instructions. Review coreex.expect agent. Add AGENTS.md to all packages. --- .github/INSTRUCTION_AUTHORING.md | 261 +++++++++++++----- .github/agents/coreex-expert.agent.md | 99 +++++-- .github/copilot-instructions.md | 69 +++-- .../api-controllers.instructions.md | 67 ++++- .../application-services.instructions.md | 130 +++++++-- .../instructions/contracts.instructions.md | 88 +++++- .../database-project.instructions.md | 90 ------ .github/instructions/domain.instructions.md | 207 ++++++++++++++ .../event-subscribers.instructions.md | 186 +++++++++---- .../instructions/host-setup.instructions.md | 212 +++++++++++--- .../instructions/repositories.instructions.md | 187 +++++++++---- .github/instructions/tests.instructions.md | 204 +++++++++++--- .github/instructions/tooling.instructions.md | 244 ++++++++++++++++ .../instructions/validators.instructions.md | 98 ++++++- .../namespace_readme_template.md | 0 .github/skills/add-capability/SKILL.md | 2 +- .../add-capability/references/workflow.md | 2 +- samples/docs/application-layer.md | 2 +- .../Contoso.Products.Contracts/ProductLite.cs | 3 +- .../ProductReserve.cs | 3 +- src/CoreEx.AspNetCore.NSwag/AGENTS.md | 41 +++ src/CoreEx.AspNetCore.NSwag/README.md | 8 +- src/CoreEx.AspNetCore/AGENTS.md | 96 +++++++ src/CoreEx.AspNetCore/Mvc/README.md | 2 +- src/CoreEx.AspNetCore/README.md | 8 +- .../AGENTS.md | 95 +++++++ .../README.md | 6 +- src/CoreEx.Caching.FusionCache/AGENTS.md | 53 ++++ src/CoreEx.Caching.FusionCache/README.md | 6 +- src/CoreEx.CodeGen/AGENTS.md | 52 ++++ src/CoreEx.CodeGen/README.md | 6 +- src/CoreEx.Data/AGENTS.md | 76 +++++ src/CoreEx.Data/README.md | 6 +- src/CoreEx.Database.Postgres/AGENTS.md | 63 +++++ src/CoreEx.Database.Postgres/README.md | 6 +- src/CoreEx.Database.SqlServer/AGENTS.md | 72 +++++ src/CoreEx.Database.SqlServer/README.md | 6 +- src/CoreEx.Database/AGENTS.md | 73 +++++ src/CoreEx.Database/README.md | 6 +- src/CoreEx.DomainDriven/AGENTS.md | 79 ++++++ src/CoreEx.DomainDriven/README.md | 6 +- src/CoreEx.EntityFrameworkCore/AGENTS.md | 81 ++++++ src/CoreEx.EntityFrameworkCore/README.md | 6 +- src/CoreEx.Events/AGENTS.md | 89 ++++++ src/CoreEx.Events/README.md | 6 +- src/CoreEx.RefData/AGENTS.md | 118 ++++++++ src/CoreEx.RefData/README.md | 6 +- src/CoreEx.UnitTesting/AGENTS.md | 111 ++++++++ src/CoreEx.UnitTesting/README.md | 6 +- src/CoreEx.Validation/AGENTS.md | 98 +++++++ src/CoreEx.Validation/README.md | 6 +- src/CoreEx/AGENTS.md | 125 +++++++++ src/CoreEx/README.md | 67 ++++- src/Directory.Build.props | 14 +- 54 files changed, 3167 insertions(+), 486 deletions(-) delete mode 100644 .github/instructions/database-project.instructions.md create mode 100644 .github/instructions/domain.instructions.md create mode 100644 .github/instructions/tooling.instructions.md rename .github/{instructions => }/namespace_readme_template.md (100%) create mode 100644 src/CoreEx.AspNetCore.NSwag/AGENTS.md create mode 100644 src/CoreEx.AspNetCore/AGENTS.md create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/AGENTS.md create mode 100644 src/CoreEx.Caching.FusionCache/AGENTS.md create mode 100644 src/CoreEx.CodeGen/AGENTS.md create mode 100644 src/CoreEx.Data/AGENTS.md create mode 100644 src/CoreEx.Database.Postgres/AGENTS.md create mode 100644 src/CoreEx.Database.SqlServer/AGENTS.md create mode 100644 src/CoreEx.Database/AGENTS.md create mode 100644 src/CoreEx.DomainDriven/AGENTS.md create mode 100644 src/CoreEx.EntityFrameworkCore/AGENTS.md create mode 100644 src/CoreEx.Events/AGENTS.md create mode 100644 src/CoreEx.RefData/AGENTS.md create mode 100644 src/CoreEx.UnitTesting/AGENTS.md create mode 100644 src/CoreEx.Validation/AGENTS.md create mode 100644 src/CoreEx/AGENTS.md diff --git a/.github/INSTRUCTION_AUTHORING.md b/.github/INSTRUCTION_AUTHORING.md index bfb1a8c9..187eafe4 100644 --- a/.github/INSTRUCTION_AUTHORING.md +++ b/.github/INSTRUCTION_AUTHORING.md @@ -1,133 +1,246 @@ --- -description: "Standards for creating and maintaining .instructions.md files" applyTo: "**/*.instructions.md" +description: "Standards for authoring and maintaining Copilot instruction files in this repository" tags: ["authoring", "standards", "instructions", "documentation"] --- # Instruction File Authoring Standards -When creating or updating any Copilot instruction Markdown file (`.instructions.md`), follow these rules to keep guidance durable, easy to review, and maintainable. +Instruction files (`.instructions.md`) are **context-window injections for a code model**. When Copilot generates or edits a file that matches the `applyTo` glob, the instruction file is automatically prepended to the context. This has two practical consequences that drive every rule below: -## Purpose +- **Every token costs.** Instruction file content displaces actual code context. Brevity and precision matter. +- **Code examples outperform prose rules.** Copilot is a code model. Showing it the exact pattern to follow produces better results than listing `MUST`/`MUST NOT` directives. -Instruction files define scoped AI guidance for specific file types or code areas. They must be predictable and machine-readable. +Write instruction files as you would a concise internal coding guide for a capable new team member, not as a policy document. -## General Authoring Rules +--- -- Prefer precise, testable directives over vague guidance. -- Avoid overlapping or conflicting instructions across files. -- Keep content reusable and not tied to one temporary task. -- Use imperative language ("Use", "Prefer", "Do not", "Validate"). -- If a rule is scoped to a subset of files, use a path-specific `.instructions.md` file rather than adding it to the global file. -- Do not restate general rules in multiple files unless required for clarity. -- When unsure, produce fewer, clearer rules. +## Principles -## Required Format +- **Show, don't tell.** A real, working code example is worth more than five imperative rules. +- **Be specific.** Use actual type names, package names, and method names from this codebase. +- **Be brief.** If content cannot fit on one screen, split into a separate file or move detail to `Further Reading`. +- **Do not restate global rules.** If `.github/copilot-instructions.md` already covers it, do not repeat it here. +- **Explicit negation matters.** Copilot's training data contains many common patterns that are wrong for this repo. State anti-patterns explicitly with a `## Do Not` section. +- **Scope tightly.** An instruction that is always injected regardless of context wastes tokens. Use the narrowest `applyTo` glob that is still correct. +- **Never direct Copilot toward generated files.** Instruction files must never include guidance, examples, or `applyTo` globs that would cause Copilot to create or modify `*.g.cs`, `*.g.sql`, `*.g.pgsql`, or any other generated-output file. All generated files are owned exclusively by their corresponding tooling (Roslyn source generator, `*.Database` project, `*.CodeGen` project). Changes must be made to the source templates or generation configuration, not to the output. See [Generated Code](#generated-code) below. + +--- -All `.instructions.md` files must begin with YAML frontmatter and follow this section order: +## Required Format -```yaml +```markdown --- -description: "Short, concrete summary of what the file governs" -applyTo: "src/**/*.cs" +applyTo: "" +description: "" tags: ["tag1", "tag2"] --- -# Purpose +# Conventions -[One paragraph explaining why this guidance exists] +## NuGet / Project References -## Scope +| Package | Key types provided | +|---|---| +| `CoreEx.X` | `TypeA`, `TypeB`, `TypeC` | -[What files and scenarios this applies to] +## -## Required Rules + -[MUST / MUST NOT directives, phrased imperatively] +```csharp +// real example matching what the codebase actually looks like +``` -## Preferred Patterns +## -[SHOULD directives, repeated patterns, best practices] +... -## Validation +## Do Not -[Concrete checks: build, test, lint, docs validation] +- Do not use `SomeWrongType` — use `CorrectType` instead. +- Do not call `SomeAntiPattern()` directly; delegate to the application service. -## Examples +## Further Reading -[Optional: "Preferred" and "Avoid" patterns] +- [`samples/docs/.md`](../../samples/docs/.md) — layer-level walkthrough with sample code references. +- [`src/CoreEx.X/README.md`](../../src/CoreEx.X/README.md) — full API reference for the primary package. ``` +--- + ## Frontmatter Rules -- `description` must be one sentence and concrete. -- `applyTo` must use explicit glob patterns with the narrowest safe scope. Examples: - - `src/**/*.cs` — all C# files in source - - `tests/**/*.cs` — all test files - - `**/Program.cs` — program entry points -- `applyTo` must **not** be `**` unless the file is intentionally repository-wide. -- If multiple globs are needed, keep them explicit and readable, separated by semicolons or as separate lines. -- `tags` should reflect the instruction's domain (e.g., `["validation", "dependency-injection", "testing"]`). +- Field order: `applyTo` → `description` → `tags`. +- `applyTo` must be the narrowest glob that correctly captures all relevant files. Examples: + - `**/Contracts/**/*.cs` — contract DTOs + - `**/Application/**/*.cs` — application services + - `**/Infrastructure/**/*.cs` — repositories and adapters + - `**/Controllers/**/*.cs` — API controllers + - `**/Subscribe/**/*.cs` — event subscriber classes + - `**/Program.cs` — host entry points + - `**/*Validator*.cs` — validator classes + - `**/*.Test*/**/*.cs` — test projects + - `**/*.Database/**/*.sql` — database project SQL scripts +- `description` must be one sentence, concrete, and specific to the area governed. +- `tags` must reflect the instruction's domain (e.g., `["validation", "application-layer", "unit-of-work"]`). +- Do not use `applyTo: "**"` unless the file is genuinely repository-wide. + +--- + +## Section Guidance + +### `## NuGet / Project References` — required, always first + +List every package the generated code will need, with the key types Copilot should use from each. This is the highest-value, lowest-token section: it anchors imports and prevents Copilot from inventing types. + +```markdown +| Package | Key types provided | +|---|---| +| `CoreEx` | `[ScopedService]`, `IUnitOfWork`, `NotFoundException`, `.ThrowIfNull()` | +| `CoreEx.Validation` | `Validator`, `.ValidateAndThrowAsync()` | +``` + +### Pattern sections — one section per distinct concept + +Name sections after the actual coding concept, not a rule number. Each section should contain one or two orienting sentences followed by a real code example. Avoid long prose; if a concept needs a paragraph of explanation, it belongs in `Further Reading`. + +### `## Do Not` — required when anti-patterns exist + +List things Copilot must not generate for this area. Be specific: name the wrong type, wrong library, or wrong pattern, and say what to use instead. This section counteracts Copilot's training data for common-but-wrong patterns. + +```markdown +## Do Not + +- Do not use `AutoMapper` — use explicit mapping helpers or classes. +- Do not inject `IUnitOfWork` into controllers — delegate to the application service. +- Do not inherit from `Controller` — inherit from `ControllerBase`. +``` + +### `## Further Reading` — required + +Link to the layer-level `samples/docs/` walkthrough and any directly relevant `src/*/README.md` files. Copilot will fetch these when it needs deeper context, keeping the instruction file itself lean. -## Content Rules +```markdown +## Further Reading -- **Required rules** must be phrased as MUST / MUST NOT / SHOULD where possible. -- **Validation section** must include concrete checks (build, test, lint, docs validation) when applicable. -- **Examples** must show "preferred" and "avoid" patterns when useful. -- Do not include secrets, tokens, or environment-specific sensitive values. -- Keep sections short and scannable; each section should fit on one screen without scrolling. +- [`samples/docs/application-layer.md`](../../samples/docs/application-layer.md) — application service patterns with sample code. +- [`src/CoreEx/Results/README.md`](../../src/CoreEx/Results/README.md) — `Result` pipeline API reference. +``` + +--- ## Conflict Resolution -When multiple instruction files might apply to the same file: +- `.github/copilot-instructions.md` defines repository-wide defaults. Never restate them in a scoped file. +- When two instruction files could apply to the same file, the narrower `applyTo` glob wins. +- If a new file would duplicate policy from an existing one, extend the existing file instead. + +## Non-Instruction Files in `.github/instructions/` + +Not every file in this folder is an instruction file. `namespace_readme_template.md` is a copy/paste template used by skills and prompts — it has no `applyTo` frontmatter and is never auto-injected by Copilot. Do not add `applyTo` to it or restructure it to follow the instruction file format. + +--- + +## Generated Code + +Generated files are owned exclusively by their tooling and must never be touched by Copilot or by hand. There are three distinct generators in this repo, each with its own input and output: + +### Roslyn source generator (`CoreEx.Generator`) + +Triggered at compile time. Reads classes decorated with `[Contract]` or `[ReferenceData]` and emits companion `.g.cs` files in the same project. + +| Output pattern | What to change instead | +|---|---| +| `*.g.cs` alongside a `[Contract]` class | The decorated partial class itself (add/remove properties, change attributes) | +| `*.g.cs` alongside a `[ReferenceData]` class | The decorated partial class itself | -- Repository-wide instructions define defaults. -- Path-specific instruction files define narrower, stronger rules for matching files. -- If a new file would conflict with an existing instruction file, revise the narrow file instead of creating duplicate policy. -- Always document the relationship between overlapping instruction files in cross-references. +### CoreEx.CodeGen (`*.CodeGen` project) -## Cross-Referencing +A development-time console tool. Reads a single `ref-data.yaml` file (validated against `schema/coreex-refdata.json`) and generates a complete reference-data implementation — contract, controller, service, repository interface, repository, and mapper — across four project directories in one run. -Link between instruction files using relative paths or workspace absolute paths: +| Output pattern | Target layer | What to change instead | +|---|---|---| +| `Contracts/**/*.g.cs` | Contracts | `ref-data.yaml` entity/property config | +| `**/Controllers/**/*.g.cs` | Api | `ref-data.yaml` route/entity config | +| `**/Services/**/*.g.cs` | Application | `ref-data.yaml` entity config | +| `**/Repositories/**/*.g.cs` | Infrastructure | `ref-data.yaml` repository/mapper config | +| `**/Mappers/**/*.g.cs` | Infrastructure | `ref-data.yaml` property config or `excludeMapper: true` | -- Relative: `../other-instruction.md` -- Absolute: `/.github/instructions/host-setup.instructions.md` -- Always verify links work before committing. +The entry point is a one-line `Program.cs` in the `*.CodeGen` project. Templates live in `CoreEx.CodeGen/RefData/Templates/` (embedded in the NuGet package) and are the only place structural changes to the generated shape belong. -## Example Structure +### DbEx (`*.Database` project) -A well-formed instruction file: +A development-time migration and generation tool. Reads YAML configuration and SQL migration scripts to produce database schema, outbox infrastructure, and EF Core scaffolding. -```yaml +| Output pattern | What to change instead | +|---|---| +| `*.g.sql`, `*.g.pgsql` | The YAML configuration or SQL migration scripts in the `*.Database` project | +| `*DbContext.g.cs`, `Persistence/*.g.cs` | The DbEx YAML config in the `*.Database` project | + +### Rules for instruction file authors + +- Do not write guidance that instructs Copilot to create or modify any `*.g.*` file. +- Do not include `*.g.*` files in `applyTo` globs — they must never match a generated output. +- When showing examples that reference generated types (e.g. a persistence model or a ref-data controller), show the YAML or source class that drives generation, not the generated output. +- If a user asks Copilot to edit a generated file, identify which generator owns it from the table above and redirect to the correct input source. + +--- + +## Worked Example + +A well-formed instruction file for API controllers: + +```markdown --- -description: "Controller conventions for CoreEx API hosts" applyTo: "**/Controllers/**/*.cs" -tags: ["controllers", "api", "dependency-injection"] +description: "API controller conventions for CoreEx: inheritance, routing, WebApi helper usage, and CQRS separation" +tags: ["controllers", "api", "routing", "dependency-injection"] --- -# Purpose +# API Controller Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx.AspNetCore` | `WebApi`, `[IdempotencyKey]`, `[ProducesNotFoundProblem]`, `[Query]`, `[Paging]` | +| `CoreEx.AspNetCore.NSwag` | `[OpenApiTag]` | -Controllers define HTTP endpoints for CoreEx API hosts. This guidance ensures consistent routing, dependency injection, and use of CoreEx WebApi helpers. +## Structure -## Scope +Inherit from `ControllerBase`. Decorate with `[ApiController]`, `[Route]`, and `[OpenApiTag]`. Inject `WebApi` +and the relevant service interface via primary constructor, guarded with `.ThrowIfNull()`. Split read and +write operations into separate controller classes following CQRS conventions. -Applies to all `Controllers/` directories in API projects (`.Api` projects). +```csharp +[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(); +} +``` + +## Action Methods -## Required Rules +Return `Task` via the `WebApi` helper. Do not return typed `ActionResult` directly. -- MUST inherit from `WebApiControllerBase` or `WebApiControllerBase`. -- MUST use `[Route("api/v1/...")]` and follow REST conventions. -- MUST NOT inject `IUnitOfWork` directly; receive it only through application service dependency. +```csharp +[HttpPost, IdempotencyKey] +public Task CreateAsync([FromBody] Product product) => + _webApi.PostAsync(Request, () => _service.CreateAsync(product), statusCode: HttpStatusCode.Created); +``` -## Preferred Patterns +## Do Not -- Prefer `PostAsync()`, `PutAsync()`, `PatchAsync()` from `WebApi` helpers over manual response building. -- Prefer explicit dependency injection over service locator patterns. -- Prefer PATCH with `application/merge-patch+json` for partial updates. +- 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. +- Do not inject `IUnitOfWork` into controllers — it belongs in the application service. +- Do not put business logic in controllers — delegate immediately to the application service. -## Validation +## Further Reading -- Build the project: `dotnet build` -- Run tests: `dotnet test` -- Check inheritance with: `grep "class.*Controller" Controllers/*.cs` +- [`samples/docs/hosts-layer.md`](../../samples/docs/hosts-layer.md) — API host composition and controller patterns. +- [`src/CoreEx.AspNetCore/README.md`](../../src/CoreEx.AspNetCore/README.md) — `WebApi` helper API reference. ``` diff --git a/.github/agents/coreex-expert.agent.md b/.github/agents/coreex-expert.agent.md index ab7c7d82..7e629ed3 100644 --- a/.github/agents/coreex-expert.agent.md +++ b/.github/agents/coreex-expert.agent.md @@ -12,36 +12,77 @@ Your mission: - Prefer CoreEx-native primitives and conventions over generic .NET advice. - Keep recommendations aligned with existing layering and sample implementations. -Primary sources of truth: -- .github/copilot-instructions.md -- docs/agent-interaction-guide.md -- docs/agent-prompt-recipes.md -- .github/instructions/api-controllers.instructions.md -- .github/instructions/application-services.instructions.md -- .github/instructions/contracts.instructions.md -- .github/instructions/repositories.instructions.md -- .github/instructions/event-subscribers.instructions.md -- .github/instructions/host-setup.instructions.md -- .github/instructions/tests.instructions.md -- .github/instructions/validators.instructions.md - -Operating rules: +## Primary sources of truth + +### Repo-wide conventions +- `.github/copilot-instructions.md` — project-wide guidelines, repository shape, key conventions, and house rules. +- `.github/INSTRUCTION_AUTHORING.md` — standards for authoring scoped instruction files and skills. +- `.github/SKILL_AUTHORING.md` — standards for authoring skills (`SKILL.md` files). + +### Scoped instruction files (auto-applied by file glob, read these for area-specific rules) +- `.github/instructions/contracts.instructions.md` — entity contracts, `[Contract]`, `[ReferenceData]`, source generation. +- `.github/instructions/domain.instructions.md` — DDD aggregates, `Entity`, mutation guards, `Result` pipelines. +- `.github/instructions/application-services.instructions.md` — service shape, `TransactionAsync`, validation-before-transaction, event enqueuing. +- `.github/instructions/validators.instructions.md` — `AbstractValidator`, rule chains, `CommonValidator`, `ValidateAndThrowAsync`. +- `.github/instructions/repositories.instructions.md` — `EfDbModel`, `IBiDirectionMapper`, `QueryArgsConfig`, paging. +- `.github/instructions/api-controllers.instructions.md` — controller shape, `WebApi` helpers, `[IdempotencyKey]`, PATCH. +- `.github/instructions/event-subscribers.instructions.md` — subscriber classes, `[Subscribe]`, `SubscribedManager`, error handling. +- `.github/instructions/host-setup.instructions.md` — `Program.cs` shape, middleware order, service registration, outbox relay hosts. +- `.github/instructions/tooling.instructions.md` — `*.CodeGen` and `*.Database` projects, `ref-data.yaml`, DbEx, generated-file ownership. +- `.github/instructions/tests.instructions.md` — `UnitTestEx`, `NUnit`, `AwesomeAssertions`, outbox/event expectations, seed data. + +### Sample architecture docs (real-world usage patterns) +- `samples/docs/layers.md` — full layer dependency diagram, design-time tooling overview, dependency rules. +- `samples/docs/patterns.md` — canonical pattern catalogue: error handling, railway-oriented flows, outbox, adapters, policies, testing. +- `samples/docs/contracts-layer.md` — contracts in practice: generated contracts, interfaces, reference data code properties. +- `samples/docs/domain-layer.md` — aggregates, mutation guards, integration-event accumulation, `Result` pipelines. +- `samples/docs/application-layer.md` — service orchestration, `TransactionAsync`, `IUnitOfWork.Events`, validators, policies, adapters. +- `samples/docs/infrastructure-layer.md` — EF Core repositories, `IBiDirectionMapper`, outbox table wiring, relay publisher. +- `samples/docs/hosts-layer.md` — API, Subscribe, and Outbox.Relay `Program.cs` shapes, middleware ordering, Service Bus wiring. +- `samples/docs/testing.md` — unit, integration, API, Subscribe, and Relay test patterns with concrete examples. +- `samples/docs/tooling.md` — `*.CodeGen` and `*.Database` project run order, generated-file ownership, schema generation. +- `samples/docs/aspire.md` — Aspire orchestration for local distributed development and E2E testing. + +### Per-package AI usage guides (consumer-facing, packed with each NuGet) +- `src/CoreEx/AGENTS.md` — exceptions, `ExecutionContext`, `Result`, entity contracts, `Runtime.UtcNow`, DI attributes. +- `src/CoreEx.AspNetCore/AGENTS.md` — `WebApi`, middleware, health checks, idempotency. +- `src/CoreEx.AspNetCore.NSwag/AGENTS.md` — NSwag/OpenAPI integration. +- `src/CoreEx.Azure.Messaging.ServiceBus/AGENTS.md` — Service Bus publisher, subscribers, error handling. +- `src/CoreEx.Caching.FusionCache/AGENTS.md` — `IHybridCache`, Redis backplane, idempotency provider. +- `src/CoreEx.CodeGen/AGENTS.md` — `CodeGenConsole`, `ref-data.yaml`, generated-file ownership. +- `src/CoreEx.Data/AGENTS.md` — `IUnitOfWork`, `TransactionAsync`, `QueryArgsConfig`, `DataResult`. +- `src/CoreEx.Database/AGENTS.md` — `IDatabase`, `DatabaseCommand`, outbox relay base types. +- `src/CoreEx.Database.Postgres/AGENTS.md` — PostgreSQL `IDatabase`, outbox, error-code conventions. +- `src/CoreEx.Database.SqlServer/AGENTS.md` — SQL Server `IDatabase`, session context, outbox, error-code conventions. +- `src/CoreEx.DomainDriven/AGENTS.md` — `Entity`, `Aggregate`, `PersistenceState`. +- `src/CoreEx.EntityFrameworkCore/AGENTS.md` — `EfDb`, `EfDbModel`, dynamic query, `ValueConverterBridge`. +- `src/CoreEx.Events/AGENTS.md` — `EventData`, `IEventFormatter`, `IEventPublisher`, `SubscribedManager`. +- `src/CoreEx.RefData/AGENTS.md` — `ReferenceData`, `ReferenceDataHybridCache`, `ReferenceDataOrchestrator`. +- `src/CoreEx.UnitTesting/AGENTS.md` — outbox/event expectations, `JsonDataReader`, `AwesomeAssertions`. +- `src/CoreEx.Validation/AGENTS.md` — `AbstractValidator`, rule catalogue, `ValidateAndThrowAsync`. + +## Operating rules + - Always inspect current code before recommending changes. -- Give sample-backed guidance where possible. +- 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 relay, API plus subscribe, and orchestration shapes. - -Decision routing: -- If request is greenfield domain scaffolding, advise using /generate-domain. -- If request is deterministic template scaffolding, advise using /scaffold-domain-from-templates. -- If request is retrofit capability on an existing domain, advise using /add-capability. -- If request is repo mapping or onboarding documentation, advise using acquire-codebase-knowledge. - -Response format: -1. Recommendation. -2. Why this fits CoreEx. -3. Evidence from repo files. -4. Risks and tradeoffs. -5. Minimal next steps. +- 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. + +## Decision routing + +- Greenfield domain scaffolding → advise using `/generate-domain`. +- Deterministic template scaffolding → advise using `/scaffold-domain-from-templates`. +- Retrofit capability on an existing domain → advise using `/add-capability`. +- 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/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0cd0f65d..6672c282 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,5 @@ --- +# 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"] --- @@ -27,10 +28,13 @@ CoreEx is a modular .NET framework for enterprise APIs and distributed services. ## Architecture - **Two roles**: framework packages (`src\`) + sample reference implementations (`samples\`). -- **Domain layers**: `*.Contracts` → `*.Application` → `*.Infrastructure` → `*.Api`, plus `*.Database`, `*.Outbox.Relay`, `*.Subscribe` (messaging). -- **Sample flow**: Controllers → `WebApi` helpers → Application services (validate + `IUnitOfWork`) → Infrastructure repositories (EF + explicit mappers) → outbox events → relay publishes to Service Bus → subscribers consume. +- **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`, `*.Outbox.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 sample hosts in `samples\aspire\Contoso.Aspire\AppHost.cs`. +- **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 @@ -50,19 +54,34 @@ CoreEx is a modular .NET framework for enterprise APIs and distributed services. - 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\` or `Application\Repositories\`; + - 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. run mutations inside `_unitOfWork.ExecuteAsync(...)`; - 5. add `EventData` within the same unit-of-work scope. + 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. -- When working in application or infrastructure code, follow `.github\instructions\application-services.instructions.md`, `.github\instructions\repositories.instructions.md`, and related scoped instruction files. + +### 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: @@ -83,13 +102,17 @@ CoreEx is a modular .NET framework for enterprise APIs and distributed services. - OpenAPI/health endpoints standard in hosts. ### Data and Messaging -- SQL Server + outbox + Azure Service Bus are first-class patterns. -- Shopping: synchronous HTTP reservation + transactional outbox + async event publishing. Preserve this split. +- 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: NUnit + FluentAssertions. +- Framework: UnitTestEx + NUnit + AwesomeAssertions (the `AwesomeAssertions` NuGet package — not FluentAssertions). - Sample: `WithGenericTester` (unit) or `WithApiTester` (API/Subscribe/Relay). -- Integration tests: `Data\data.yaml` (Test.Common) + `Resources\` JSON expectations + `ExpectSqlServerOutboxEvents(...)`. +- Integration tests: `Data\data.yaml` (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 @@ -98,16 +121,24 @@ CoreEx is a modular .NET framework for enterprise APIs and distributed services. - Always use `.ConfigureAwait(false)` in service/repository code. ### Generated Code -- Do not edit generated code directly. If changes are needed, update the source templates or generation logic. -- Generated code files are typically marked with a comment at the top indicating they are auto-generated and should not be edited manually. -- Generated code also has the file name pattern `*.g.cs`, `*.g.sql`, `*.g.pgsql` or similar to indicate its nature. -- Copilot should not suggest edits to generated code files. If it does, the suggestion should be rejected or redirected to the source templates. +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 [INSTRUCTION_AUTHORING.md](.github/INSTRUCTION_AUTHORING.md#generated-code) for full generator ownership detail. ## Key Docs to Read Before Large Changes -- `README.md` for repo-level positioning and top-level commands. -- `samples\README.md` for the runnable Contoso architecture and local setup. -- `docs\capabilities.md` for the deeper CoreEx capability/pattern explanations. -- `.github\instructions\*.instructions.md` for area-specific rules when editing `Program.cs`, contracts, application services, repositories, validators, subscribers, or tests. +- `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 and Skills) diff --git a/.github/instructions/api-controllers.instructions.md b/.github/instructions/api-controllers.instructions.md index 57d09026..1b40b895 100644 --- a/.github/instructions/api-controllers.instructions.md +++ b/.github/instructions/api-controllers.instructions.md @@ -10,15 +10,14 @@ tags: ["controllers", "api", "routing", "cqrs", "dependency-injection"] | Package | Key types provided | |---|---| -| `CoreEx.AspNetCore` | `WebApi`, `[IdempotencyKey]`, `[Accepts]`, `[ProducesNotFoundProblem]`, `[Query]`, `[Paging]`, `HttpNames`, `app.UseCoreExExceptionHandler()`, `app.UseExecutionContext()`, `app.UseIdempotencyKey()`, `app.MapHealthChecks()` | -| `CoreEx.AspNetCore.NSwag` | `[OpenApiTag]`, `app.UseOpenApi()`, `app.UseSwaggerUi()`, `s.AddCoreExConfiguration()` | -| `CoreEx` | `WebApplicationBuilderExtensions.AddHostSettings()`, `AddExecutionContext()` | +| `CoreEx.AspNetCore` | `WebApi`, `[IdempotencyKey]`, `[Accepts]`, `[ProducesNotFoundProblem]`, `[Query]`, `[Paging]`, `HttpNames`, `.Required()`, `.Adjust(...)` | +| `CoreEx.AspNetCore.NSwag` | `[OpenApiTag]` | ## Structure - Inherit from `ControllerBase`. Never inherit from `Controller` (that brings View support). - Decorate with `[ApiController]` and `[Route("...")]` on the class. -- Add `[OpenApiTag("TagName")]` to group endpoints in the generated OpenAPI document. +- Add `[OpenApiTag("TagName")]` to group endpoints in the generated OpenAPI document. Can also be placed on an individual action method to cross-tag it into a different OpenAPI group. - Inject `WebApi` and the relevant service interface via primary constructor. Guard with `.ThrowIfNull()`. - Split read operations and write operations into separate controller classes (`ProductController` for mutations, `ProductReadController` for queries) following CQRS conventions. @@ -35,14 +34,29 @@ public class ProductController(WebApi webApi, IProductService service) : Control All action methods return `Task` using the `WebApi` helper. Do not return typed `ActionResult` directly. +### Standard (exception-based services — Products style) + | HTTP Verb | WebApi helper | Notes | |---|---|---| | `GET` / `HEAD` | `_webApi.GetAsync(...)` | Use both attributes together | -| `POST` | `_webApi.PostAsync(...)` or `PostWithResultAsync` | Add `[IdempotencyKey]` for safe POST | +| `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 — Shopping style) + +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 Validate route parameters inline using `.Required()`: @@ -106,17 +120,52 @@ public Task GetCategoriesAsync([FromQuery] IEnumerable? c Decorate actions with standard response metadata attributes: -- `[ProducesResponseType(StatusCodes.Status201Created)]` -- `[ProducesNotFoundProblem()]` on GET/PUT/PATCH/DELETE where not-found is expected. -- `[Accepts]` to document the consumed media type. +- `[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() => + _webApi.GetAsync(Request, (ro, _) => _service.QuerySchemaAsync()); +``` ## Result-Based Services -When the service returns `Result` (Shopping-style domain services), use the `PostWithResultAsync` / `GetWithResultAsync` variants: +When the service returns `Result` (Shopping-style domain services), use the `WithResult` variants. See the Method Signatures table above for the full variant list. ```csharp [HttpPost("{basketId}/checkout")] +[ProducesResponseType(typeof(Basket), StatusCodes.Status200OK)] +[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public Task CheckoutAsync(string basketId) => _webApi.PostWithResultAsync(Request, (_, _) => _service.CheckoutAsync(basketId.Required()), HttpStatusCode.OK); + +[HttpPost("{basketId}/items")] +[IdempotencyKey] +[Accepts] +[ProducesResponseType(typeof(Basket), StatusCodes.Status200OK)] +public Task ItemAddAsync(string basketId) => + _webApi.PostWithResultAsync(Request, (ro, _) => + _service.ItemAddAsync(basketId.Required(), ro.Value), HttpStatusCode.OK); ``` + +## 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 — it belongs in the application service. +- Do not put business logic in controllers — delegate immediately to the application service. +- Do not call `HttpClient` or adapters directly from controllers — go through the application service. + +## Further Reading + +- [`samples/docs/hosts-layer.md`](../../samples/docs/hosts-layer.md) — API host composition, controller patterns, and `Program.cs` shape. +- [`src/CoreEx.AspNetCore/README.md`](../../src/CoreEx.AspNetCore/README.md) — `WebApi` helper API reference. diff --git a/.github/instructions/application-services.instructions.md b/.github/instructions/application-services.instructions.md index 85e064b2..95af14e0 100644 --- a/.github/instructions/application-services.instructions.md +++ b/.github/instructions/application-services.instructions.md @@ -1,7 +1,7 @@ --- applyTo: "**/Application/**/*.cs" -description: "Application service conventions: ScopedService registration, dependency injection, validation, unit of work patterns, and business logic structure" -tags: ["services", "application-layer", "dependency-injection", "validation", "unit-of-work"] +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 @@ -47,13 +47,50 @@ public async Task UpdateAsync(Product product) ## Validation -Call the validator before any persistence operations. Throw on first error set: +Validators live in `Application/Validators/` and extend `Validator`. They combine two phases. + +**Declarative phase** — property rules composed fluently in the constructor using the built-in rule set (`Mandatory()`, `MaximumLength()`, `IsValid()`, `PrecisionScale()`, `GreaterThanOrEqualTo()`, `Dictionary()`, `Entity()`, etc.). Run synchronously before any I/O. + +**Programmatic phase** — `OnValidateAsync` override for rules that require I/O (repository lookups, cross-field checks, dynamically-constructed validators). Always guard with `if (context.HasErrors) return;` to fail fast when declarative rules have already failed. + +```csharp +// Declarative-only validator. +public class ProductValidator : Validator +{ + public ProductValidator() + { + Property(p => p.Sku).Mandatory().MaximumLength(50); + Property(p => p.SubCategoryCode).Mandatory().IsValid(); + Property(p => p.Price).PrecisionScale(null, 2).GreaterThanOrEqualTo(0, _ => "zero"); + } +} +``` + +```csharp +// Declarative + programmatic validator with I/O. +protected async override Task OnValidateAsync( + ValidationContext context, CancellationToken cancellationToken) +{ + if (context.HasErrors) return; // fail fast — skip I/O if phase 1 already found errors + + var ids = context.Value.Products!.Select(kvp => kvp.Key).ToArray(); + var products = await _repository.GetForReservationAsync(ids).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); +} +``` + +Call the validator in the service before any persistence operations. Throw on the first error set (exception-based services): ```csharp await ProductValidator.Default.ValidateAndThrowAsync(product); ``` -For `Result` style, use `ValidateWithResultAsync` and propagate with `ThenAs`: +For `Result` pipelines, use `ValidateWithResultAsync`: ```csharp var result = await Result.GoAsync(() => MyValidator.Default.ValidateWithResultAsync(value)); @@ -80,10 +117,10 @@ if (!product.IsInactive) ## Unit of Work and Events -Wrap all side-effectful database operations in `_unitOfWork.ExecuteAsync(...)`. Add integration events inside that scope so event and data writes are atomic: +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.ExecuteAsync(async () => +return await _unitOfWork.TransactionAsync(async () => { var dr = await _repository.CreateAsync(product).ConfigureAwait(false); return dr.WhereMutated(v => @@ -91,11 +128,11 @@ return await _unitOfWork.ExecuteAsync(async () => }).ConfigureAwait(false); ``` -- `WhereMutated(action)` — executes `action` only when the data result has a mutation; add the event inside this callback. +- `WhereMutated(action)` — executes `action` only when the data result records a mutation; add the event inside this callback. - `EventData.CreateEventWith(value, action)` — creates a typed event from the entity. - `EventAction.Created`, `EventAction.Updated`, `EventAction.Deleted` — use the standard constants. -For delete where the entity value is gone, carry the ID via `.WithKey(id)`: +For delete where the entity value is no longer available, carry the ID via `.WithKey(id)`: ```csharp _unitOfWork.Events.Add( @@ -104,14 +141,14 @@ _unitOfWork.Events.Add( ## Result Style (Domain-Aggregate Services) -For services operating on DDD aggregates (e.g., Shopping Basket), use `Result` chains instead of exceptions for expected failures. Compose with `Result.GoAsync`, `.ThenAs`, `.ThenAsAsync`: +For services operating on DDD aggregates (e.g., Shopping Basket), use `Result` chains instead of exceptions for expected failures. Compose with `Result.GoAsync`, `.ThenAs`, `.ThenAsAsync`. The unit of work is still `TransactionAsync`: ```csharp public Task> CreateAsync(string customerId) { var aggregate = Domain.Basket.CreateNew(customerId.ThrowIfNullOrEmpty()); - return _unitOfWork.ExecuteAsync(async () => + return _unitOfWork.TransactionAsync(async () => { var br = await _repository.CreateAsync(aggregate).ConfigureAwait(false); return br.ThenAs(b => @@ -124,7 +161,7 @@ public Task> CreateAsync(string customerId) } ``` -For multi-step orchestration with early exit: +For multi-step orchestration with early exit on the first failure: ```csharp var pr = await Result.GoAsync(() => SomeValidator.Default.ValidateWithResultAsync(input)) @@ -134,9 +171,9 @@ if (pr.IsFailure) return pr.AsResult(); ``` -## Read Services +## CQRS — Read Services -Split read operations into a separate service with an `IXxxReadService` interface when the project follows CQRS. Read services do not use UnitOfWork and do not publish events: +Split read operations into a separate service with an `IXxxReadService` interface. 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. ```csharp [ScopedService] @@ -152,14 +189,15 @@ public class ProductReadService(IProductRepository repository) : IProductReadSer ## 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 `ProductsHttpClient`: +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: ```csharp -// Application layer — interface only +// Application layer — interface only (domain-idiomatic, not a mirror of the remote API) public interface IProductAdapter { - Task GetAsync(string id); - Task ReserveInventoryAsync(MovementRequest request); + Task> GetAsync(string id); + Task ReserveInventoryAsync(Domain.Basket basket); + Task CancelReservationAsync(Domain.Basket basket); } // Infrastructure layer — implementation @@ -167,6 +205,64 @@ public interface IProductAdapter public class ProductAdapter(ProductsHttpClient httpClient) : IProductAdapter { ... } ``` +A second adapter interface (`IProductSyncAdapter`) 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 — for example, confirming a referenced entity exists before allowing a mutation. + +Policies return `Result` or `Result` and compose naturally into `Result` pipelines via `.GoAsync()` / `.ThenAsAsync()`: + +```csharp +// Application/Policies/ProductPolicy.cs +public class ProductPolicy(IProductAdapter productAdapter) +{ + public Task> EnsureExistsAsync(string productId) => Result + .GoAsync(() => _productAdapter.GetAsync(productId)) + .OnFailure(r => r.IsNotFoundError + ? Result.ValidationError(MessageItem.CreateErrorMessage(nameof(productId), "Product was not found.")) + : r); +} +``` + +## Application-Level Mapping + +When a domain has a Domain layer (e.g., Shopping), 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): + +```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))] + }; +} +``` + +Infrastructure-level mapping (Contract ↔ Persistence model) uses `BiDirectionMapper` and lives in `Infrastructure/Mapping/`. Do not conflate the two layers. + ## ConfigureAwait Always call `.ConfigureAwait(false)` on every `await` inside service and repository methods. + +## Do Not + +- Do not call `_unitOfWork.ExecuteAsync(...)` — the correct method is `_unitOfWork.TransactionAsync(...)`. +- Do not publish events outside of `_unitOfWork.TransactionAsync(...)` — events must be committed atomically with the database write. +- 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. + +## Further Reading + +- [`samples/docs/application-layer.md`](../../../samples/docs/application-layer.md) — full walkthrough of services, validators, adapters, policies, mapping, and the unit-of-work pattern. +- [`samples/docs/patterns.md`](../../../samples/docs/patterns.md) — pattern catalog: CQRS, Service, Unit of Work, Validator, Policy, Adapter, and Event patterns with cross-links. +- [`samples/docs/layers.md`](../../../samples/docs/layers.md) — layer dependency rules: Application depends inward only on Contracts and its own interfaces. +- [`src/CoreEx.Validation/README.md`](../../../src/CoreEx.Validation/README.md) — `Validator`, rule set, `OnValidateAsync`, and `ValidateFurtherAsync`. +- [`src/CoreEx/README.md`](../../../src/CoreEx/README.md) — `IUnitOfWork`, `Result`, `[ScopedService]`, and CoreEx exception types. diff --git a/.github/instructions/contracts.instructions.md b/.github/instructions/contracts.instructions.md index fd1d256c..1d048fdb 100644 --- a/.github/instructions/contracts.instructions.md +++ b/.github/instructions/contracts.instructions.md @@ -22,15 +22,32 @@ tags: ["contracts", "dto", "source-generation", "reference-data", "etag"] ``` +## 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 contract classes with the `[Contract]` attribute and declare them `partial`. Roslyn source generation fills in serialization, equality, and change-tracking code. Never manually implement the generated members. +Mark entity contract classes with the `[Contract]` attribute and declare them `partial`. The Roslyn source generator ([`CoreEx.Generator`](../../../gen/CoreEx.Generator)) emits serialization, equality, and change-tracking code into a paired `*.g.cs` file. Never manually implement those generated members. ```csharp [Contract] public partial class Product : ProductBase, IETag, IChangeLog { } ``` +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: @@ -46,7 +63,7 @@ All three are typically combined on mutable entities: ```csharp [Contract] -public partial class Product : ProductBase, IIdentifier, IETag, IChangeLog +public partial class Product : ProductBase, IETag, IChangeLog { [ReadOnly(true)] public string? Id { get; set; } @@ -61,7 +78,7 @@ public partial class Product : ProductBase, IIdentifier, IETag, IChange ## 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). +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 @@ -91,7 +108,9 @@ public partial string? SubCategoryCode { get; set; } ## 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: +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] @@ -106,8 +125,19 @@ public abstract partial class ProductBase : IIdentifier [Contract] public partial class Product : ProductBase, IETag, IChangeLog { /* additions only */ } -[Contract] -public partial class ProductLite : ProductBase { /* subset for list queries */ } +// Projection — plain class, no generated members needed. +public class ProductLite : ProductBase +{ + public decimal QtyOnHand { get; set; } +} +``` + +## Typed Collection Classes + +Pair entity contracts with a typed collection class when the contract represents a resource commonly returned as a list: + +```csharp +public partial class MovementCollection : List { } ``` ## Reference Data Contracts @@ -121,16 +151,26 @@ public partial class Category : ReferenceData { } public class CategoryCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code) { } ``` -For reference data that carries additional fields (e.g., `UnitOfMeasure.Scale`), add those as plain properties and mark computed ones with `[JsonIgnore]`: +Reference data contract `*.g.cs` files are generated by the domain's `*.CodeGen` project from `ref-data.yaml`. A hand-authored partial class in the same namespace can extend the generated type with additional computed members or constants: ```csharp -[ReferenceData] -public partial class UnitOfMeasure : ReferenceData +// MovementKind.g.cs — generated, do not edit. +// MovementKind.cs — hand-authored extension. +public partial class MovementKind { - public int Scale { get; init; } + public const string Adjust = "A"; + public const string Issue = "I"; + public const string Receive = "R"; +} +``` + +For reference data that carries additional stored fields (e.g., `UnitOfMeasure.Scale`), add those as plain properties on the hand-authored partial and mark computed ones with `[JsonIgnore]`: +```csharp +public partial class UnitOfMeasure +{ [JsonIgnore] - public int Precision => 16 - Scale; + public int Precision => 16 - Scale; // Scale is a generated stored field } ``` @@ -153,4 +193,28 @@ 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. Computed helpers (like the `IsQuantityValidForKind` example above) are acceptable read-only shorthands but must not mutate state. +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 + +Reference-data contract types (`*.g.cs`) are produced by the domain's `*.CodeGen` project. Never create or edit these files directly. + +| File pattern | Generator | Change instead | +|---|---|---| +| `*.g.cs` (ref-data types) | `*.CodeGen` project (`ref-data.yaml` + Handlebars templates) | `ref-data.yaml` or the templates in `CoreEx.CodeGen/RefData/Templates/` | +| `*.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 edit `*.g.cs` files directly — regenerate via the appropriate tooling. + +## Further Reading + +- [`samples/docs/contracts-layer.md`](../../../samples/docs/contracts-layer.md) — unified API/event surface, source generation, reference data, and internal adapter models. +- [`gen/CoreEx.Generator`](../../../gen/CoreEx.Generator) — Roslyn source generator that processes `[Contract]` and `[ReferenceData]` annotations. +- [`src/CoreEx.RefData/README.md`](../../../src/CoreEx.RefData/README.md) — reference data types, collections, and sort order. +- [`samples/docs/tooling.md`](../../../samples/docs/tooling.md) — `*.CodeGen` project usage and `ref-data.yaml` configuration. diff --git a/.github/instructions/database-project.instructions.md b/.github/instructions/database-project.instructions.md deleted file mode 100644 index 8c932226..00000000 --- a/.github/instructions/database-project.instructions.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -applyTo: "**/*.Database/**" -description: "Database project structure: migrations, DbEx YAML, reference data seeding, stored procedures, and outbox support" -tags: ["database", "migrations", "dbex", "reference-data", "outbox"] ---- - -# Database Project Conventions - -## NuGet / Project References - -| Package | Key types provided | -|---|---| -| `DbEx.SqlServer` | `SqlServerMigrationConsole`, migration host runner, YAML data parsing | -| `CoreEx.Database` | `SqlStatement` helpers, outbox integration support | - -## Project Shape - -Each domain database project must contain: - -- `Program.cs` with `ConfigureMigrationArgs`. -- `dbex.yaml` listing reference and transactional tables. -- `Migrations/` ordered SQL scripts. -- `Schema/Stored Procedures/` outbox relay procedures. -- `Data/ref-data.yaml` seed reference data. - -## Program.cs Pattern - -- Use `SqlServerMigrationConsole.Create(defaultConnectionString)`. -- Configure `.IncludeExtendedSchemaScripts()`. -- Add default ref-data columns: - - `SortOrder = 0`. - - `Scale = 0`. -- Set `DataResetFilterPredicate` to the domain schema only. - -```csharp -args.DataResetFilterPredicate = ts => ts.Schema == "{Domain}"; -``` - -## Migration Naming - -Use timestamp-prefixed, ordered scripts: - -- `20260101-000001-create-{domain}-schema.sql`. -- `20260101-000101-create-{domain}-.sql`. -- `20260101-000201-create-{domain}-.sql`. -- `20260101-000202-create-{domain}-.sql`. -- `20260101-000301-create-{domain}-outbox-tables.sql`. - -## SQL Conventions - -- Wrap each migration in `BEGIN TRANSACTION ... COMMIT TRANSACTION`. -- Use explicit schema-qualified names (`[{Domain}].[Table]`). -- Include `CreatedBy`, `CreatedOn`, `UpdatedBy`, `UpdatedOn` columns on aggregate and reference-data tables. -- Use `TIMESTAMP`/`ROWVERSION` for concurrency columns mapped to `ETag`. -- Add FK constraints for child tables. - -## Outbox Requirements - -Create both tables: - -- `[{Domain}].[Outbox]`. -- `[{Domain}].[OutboxLease]`. - -Create all required procedures: - -- `spOutboxEnqueue.g.sql`. -- `spOutboxLeaseAcquire.g.sql`. -- `spOutboxLeaseRelease.g.sql`. -- `spOutboxBatchClaim.g.sql`. -- `spOutboxBatchComplete.g.sql`. -- `spOutboxBatchCancel.g.sql`. - -Procedure naming and schema must match the domain schema and outbox publisher configuration in Infrastructure. - -## Data Seed Conventions - -- Keep reference data in `Data/ref-data.yaml`. -- Root node should be the schema/domain name. -- Use concise status/code values with clear text. -- Include required reference data used by validators. - -Example: - -```yaml -Orders: - - $^OrderStatus: - - P: Pending - - C: Confirmed - - X: Cancelled -``` diff --git a/.github/instructions/domain.instructions.md b/.github/instructions/domain.instructions.md new file mode 100644 index 00000000..4b723c6d --- /dev/null +++ b/.github/instructions/domain.instructions.md @@ -0,0 +1,207 @@ +--- +applyTo: "**/Domain/**/*.cs" +description: "Domain layer conventions: aggregates, entities, value objects, PersistenceState tracking, and Result-based 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. Shopping includes this layer; Products, being a largely CRUD-oriented domain, does not. + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx.DomainDriven` | `Aggregate`, `Entity`, `PersistenceState`, `.AsNew()`, `.AsNotModified()`, `.SetPersistenceState()` | +| `CoreEx` | `Result`, `Result`, `Runtime.NewId()`, `.ThrowIfNull()`, `.ThrowIfNullOrEmpty()`, `.ThrowIfInactive()`, `.ThrowIfLessThanZero()`, `ValidationException` | +| `CoreEx.Results` | `Result.GoAsync()`, `.ThenAs()`, `.ThenAsAsync()`, `Result.BusinessError()`, `Result.NotFoundError()`, `Result.ValidationError()` | + +## 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 + +All public mutation methods must return `Result` or `Result` so the Application layer can compose them in pipelines. 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. + +## 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; + + // Additional validation that cannot be expressed in a single property rule. + 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 (like Products), 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. +- Do not throw exceptions for expected business failures in domain methods — return `Result.BusinessError(...)` or `Result.NotFoundError()` and let the Application layer propagate. +- 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 + +- [`samples/docs/domain-layer.md`](../../../samples/docs/domain-layer.md) — aggregates, entities, value objects, and `PersistenceState` walkthrough. +- [`samples/docs/patterns.md`](../../../samples/docs/patterns.md) — Aggregate, Entity, and Value Object pattern entries with cross-links. +- [`samples/docs/layers.md`](../../../samples/docs/layers.md) — when to introduce the Domain layer and its position in the dependency graph. +- [`src/CoreEx.DomainDriven/README.md`](../../../src/CoreEx.DomainDriven/README.md) — `Aggregate`, `Entity`, and `PersistenceState`. diff --git a/.github/instructions/event-subscribers.instructions.md b/.github/instructions/event-subscribers.instructions.md index 83199423..d8933be4 100644 --- a/.github/instructions/event-subscribers.instructions.md +++ b/.github/instructions/event-subscribers.instructions.md @@ -1,7 +1,7 @@ --- applyTo: "**/Subscribe/**/*.cs" -description: "Event subscriber conventions: SubscribedBase inheritance, Service Bus integration, error handling, and scoped service registration" -tags: ["subscribers", "messaging", "service-bus", "event-handling", "integration"] +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 @@ -10,33 +10,42 @@ tags: ["subscribers", "messaging", "service-bus", "event-handling", "integration | Package | Key types provided | |---|---| -| `CoreEx.Azure.Messaging.ServiceBus` | `SubscribedBase`, `[Subscribe(...)]`, `EventSubscriberArgs`, `ErrorHandler`, `ErrorHandling`, `ServiceBusSessionReceiverOptions`, `AzureServiceBusReceiving()`, `.WithSessionReceiver()`, `.WithSubscribedSubscriber()`, `.WithHostedService()` | -| `CoreEx.Events` | `EventData`, `EventData.Key`, `.ToData()` | +| `CoreEx.Azure.Messaging.ServiceBus` | `SubscribedBase`, `SubscribedBase`, `[Subscribe(...)]`, `EventSubscriberArgs`, `ErrorHandler`, `ErrorHandling`, `ServiceBusSessionReceiverOptions`, `.AzureServiceBusReceiving()`, `.WithSessionReceiver()`, `.WithSubscribedSubscriber()`, `.WithHostedService()` | +| `CoreEx.Events` | `EventData`, `.Key`, `.Required()`, `.ToData()`, `IValidator` | | `CoreEx.Results` | `Result`, `Result.Success` | -| `CoreEx` | `[ScopedService]`, `.ThrowIfNull()`, `.Required()` | +| `CoreEx` | `[ScopedService]`, `.ThrowIfNull()` | -## Structure +## Subscriber Structure -- Subscriber classes inherit from `SubscribedBase`. -- Decorate with `[ScopedService]` and `[Subscribe("subject.pattern")]`. -- Inject service dependencies via constructor and guard with `.ThrowIfNull()`. -- Override `OnReceiveAsync` — return `Result.Success` on completion. +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, not the payload: ```csharp [ScopedService, Subscribe("contoso.products.reservation.confirm")] -public class ReservationConfirmSubscriber : SubscribedBase +public class ReservationConfirmSubscriber(IMovementService service) : SubscribedBase { - private readonly IMovementService _service; + private readonly IMovementService _service = service.ThrowIfNull(); - public ReservationConfirmSubscriber(IMovementService service) - { - _service = service.ThrowIfNull(); - } + internal static readonly ErrorHandler DefaultErrorHandler = new ErrorHandler() + .Add(ex => ex.ErrorCode == "pending-reservation-not-found" + ? ErrorHandling.CompleteAsInformation + : null); + + public ReservationConfirmSubscriber(IMovementService service) : this(service) + => ErrorHandler = DefaultErrorHandler; protected async override Task OnReceiveAsync( - EventData @event, - EventSubscriberArgs args, - CancellationToken cancellationToken = default) + EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default) { var referenceId = @event.Key.Required(); await _service.ConfirmReservationAsync(referenceId).ConfigureAwait(false); @@ -45,64 +54,116 @@ public class ReservationConfirmSubscriber : SubscribedBase } ``` +### 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); +} +``` + +Multiple `[Subscribe]` attributes on a single class handle multiple subjects with the same logic — no duplication required. + +### Key-only untyped subscriber + +When only the key is needed (no payload), use `SubscribedBase` and extract the key directly: + +```csharp +[ScopedService, Subscribe("contoso.products.product.deleted.v1")] +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()); +} +``` + ## Subject Naming -Use dot-separated lowercase subject strings in the format: +Use dot-separated lowercase subject strings: ``` -{solution}.{domain}.{entity}.{action} +{solution}.{domain}.{entity}.{action}[.v{n}] ``` +- **Domain events** (published from the outbox) include a version suffix: `contoso.products.product.created.v1` +- **Command messages** (point-to-point, no versioning semantics): `contoso.products.reservation.confirm` + Examples: - `contoso.products.product.created.v1` - `contoso.products.product.updated.v1` +- `contoso.products.product.deleted.v1` - `contoso.products.reservation.confirm` - `contoso.products.reservation.cancel` -- `contoso.shopping.basket.checkedout.v1` - -Versioned event subjects (published from the domain outbox) include `.v1`. Command subjects (point-to-point) do not include a version suffix. ## Error Handling -Define a static `ErrorHandler` when certain known exceptions should be swallowed or handled differently. Assign it to `this.ErrorHandler` in the constructor: +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 - : null); - -public ReservationConfirmSubscriber(IMovementService service) -{ - _service = service.ThrowIfNull(); - ErrorHandler = DefaultErrorHandler; -} + .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) ``` -- `ErrorHandling.CompleteAsInformation` — consume the message without error; log as informational. -- `null` return — fall through to default error 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 use the same handler). +Share the same `ErrorHandler` instance across related subscribers (e.g., both Confirm and Cancel subscribers can reference the same static instance). ## Accessing Event Data -Extract the key and optional data from `EventData`: - ```csharp -var referenceId = @event.Key.Required(); // Message key (partition/session key) -var data = @event.ToData(); // Deserialize typed payload +var key = @event.Key.Required(); // Key from the message — throws if missing +var value = @event.ToData(); // Deserialize typed payload from untyped subscriber ``` -Use `.Required()` on the key to throw a descriptive error if it is missing rather than a null reference exception. +In typed subscribers (`SubscribedBase`), the deserialized value is passed directly as the first parameter to `OnReceiveAsync` — no manual deserialization needed. -## Service Bus Registration +## Program.cs Composition -In `Program.cs`, register subscribers using `AddSubscribersUsing()` to discover all subscriber classes in the same assembly: +The Subscribe host `Program.cs` follows a predictable CoreEx shape. Key sections in order: ```csharp -builder.Services.AddSubscribedManager((_, c) => c.AddSubscribersUsing()); +// 1. Execution context and dynamic service discovery +builder.Services + .AddExecutionContext() + .AddDynamicServicesUsing(); // discovers all [ScopedService] types in the assembly + +// 2. Infrastructure — database, EF, outbox publisher (for transactional writes inside subscribers) +builder.Services + .AddSqlServerDatabase() + .AddSqlServerUnitOfWork() + .AddSqlServerOutboxPublisher() + .AddDbContext() + .AddEfDb(); + +// 3. Azure Service Bus — add the primary publisher and/or a direct publisher if needed +builder.Services.AddAzureServiceBusPublisher((_, c) => +{ + c.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId; +}, addAsDefaultIEventPublisher: false); // false when outbox publisher is the default IEventPublisher +// 4. Event formatter + subscriber manager +builder.Services + .AddEventFormatter() // required for message parsing + .AddSubscribedManager((_, c) => c.AddSubscribersUsing()); + +// 5. Azure Service Bus receiver wiring builder.Services.AzureServiceBusReceiving() .WithSessionReceiver(_ => { @@ -110,13 +171,34 @@ builder.Services.AzureServiceBusReceiving() o.SessionProcessorOptions.MaxConcurrentSessions = 4; return o; }) - .WithSubscribedSubscriber() - .WithHostedService() + .WithSubscribedSubscriber() // routes received messages through the SubscribedManager + .WithHostedService() // runs the receiver as a BackgroundService .Build(); + +// 6. Health checks, OpenTelemetry, middleware +builder.Services.PostConfigureAllHealthChecks(); +// ...OpenTelemetry... + +app.UseCoreExExceptionHandler(); +app.UseExecutionContext(); +app.MapHealthChecks(); +app.MapHostedServices(); // exposes pause/resume management endpoints per partition ``` -## Integration-Events Only +`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 -- Subscribers react to integration events published over the broker. -- Do not use MediatR or in-process domain event dispatchers. -- Keep subscriber logic thin — delegate to the Application service layer; do not embed business logic directly in the subscriber. +- [`samples/docs/hosts-layer.md`](../../../samples/docs/hosts-layer.md#subscribe-host) — Subscribe host architecture, Program.cs shape, and subscriber patterns. +- [`samples/docs/patterns.md`](../../../samples/docs/patterns.md) — Subscribe, Publish, Transactional Outbox, and Event-Driven Replication pattern entries. +- [`src/CoreEx.Azure.Messaging.ServiceBus/README.md`](../../../src/CoreEx.Azure.Messaging.ServiceBus/README.md) — `SubscribedBase`, `ErrorHandler`, and Service Bus receiver configuration. diff --git a/.github/instructions/host-setup.instructions.md b/.github/instructions/host-setup.instructions.md index 0e138e2f..135a82dc 100644 --- a/.github/instructions/host-setup.instructions.md +++ b/.github/instructions/host-setup.instructions.md @@ -6,7 +6,13 @@ tags: ["program-cs", "host-setup", "middleware", "dependency-registration", "cac # Host Setup Conventions (Program.cs) -## NuGet / Project References by Host Type +The host is a **composition root only** — no business logic. There are three host types in a CoreEx solution. Each follows the same opening skeleton, then diverges based on its responsibilities. + +> **Further Reading**: [`samples/docs/hosts-layer.md`](../../samples/docs/hosts-layer.md) · [`samples/docs/layers.md`](../../samples/docs/layers.md) · [`samples/docs/patterns.md`](../../samples/docs/patterns.md) + +--- + +## Key Registrations by Host Type ### API Host @@ -15,62 +21,90 @@ tags: ["program-cs", "host-setup", "middleware", "dependency-registration", "cac | `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.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()`, `UseOtlpExporter()` | +| `OpenTelemetry.*` | `WithCoreExTelemetry()`, `WithCoreExSqlServerTelemetry()` / `WithCoreExPostgresTelemetry()`, `UseOtlpExporter()` | ### Subscribe Host -All of the above **plus**: - | Package | Key registrations | |---|---| -| `CoreEx.Azure.Messaging.ServiceBus` | `AddAzureServiceBusClient("ServiceBus")`, `AddSubscribedManager()`, `AzureServiceBusReceiving()`, `AddHostedServiceManager()`, `MapHostedServices()`, `WithCoreExServiceBusTelemetry()` | +| `CoreEx.AspNetCore` | `AddMvcWebApi()`, `AddHttpWebApi()`, `AddExecutionContext()`, `AddHostedServiceManager()`, `UseCoreExExceptionHandler()`, `UseExecutionContext()`, `MapHealthChecks()`, `MapHostedServices()` | +| `CoreEx.Events` | `AddEventFormatter()` | +| `CoreEx.Database.SqlServer` | `AddSqlServerDatabase()`, `AddSqlServerUnitOfWork()`, `AddSqlServerOutboxPublisher()`, `AddSqlServerClient("SqlServer")` | +| `CoreEx.EntityFrameworkCore` | `AddDbContext()`, `AddEfDb()` | +| `CoreEx.Azure.Messaging.ServiceBus` | `AddAzureServiceBusClient("ServiceBus")`, `AddAzureServiceBusPublisher(..., addAsDefaultIEventPublisher: false)`, `AddSubscribedManager()`, `AzureServiceBusReceiving()`, `WithCoreExServiceBusTelemetry()` | +| `OpenTelemetry.*` | `WithCoreExTelemetry()`, `WithCoreExServiceBusTelemetry()`, `WithCoreExSqlServerTelemetry()`, `UseOtlpExporter()` | ### Outbox Relay Host | Package | Key registrations | |---|---| -| `CoreEx.AspNetCore` | `AddMvcWebApi()`, `AddHttpWebApi()`, `AddExecutionContext()`, `UseCoreExExceptionHandler()` | +| `CoreEx.AspNetCore` | `AddMvcWebApi()`, `AddHttpWebApi()`, `AddExecutionContext()`, `AddHostedServiceManager()`, `UseCoreExExceptionHandler()`, `UseExecutionContext()`, `MapHealthChecks()`, `MapHostedServices()` | | `CoreEx.Database.SqlServer` | `AddSqlServerDatabase()`, `AddSqlServerUnitOfWork()`, `AddSqlServerOutboxRelay()`, `AddSqlServerOutboxRelayHostedService()` | -| `CoreEx.Azure.Messaging.ServiceBus` | `AddAzureServiceBusClient()`, `AddAzureServiceBusPublisher()`, `ServiceBusSessionStrategy` | -| `OpenTelemetry.*` | `WithCoreExTelemetry()`, `WithCoreExSqlServerTelemetry()`, `WithCoreExServiceBusTelemetry()`, `UseOtlpExporter()` | +| `CoreEx.Database.Postgres` | `AddPostgresDatabase()`, `AddPostgresUnitOfWork()`, `AddPostgresOutboxRelay()`, `AddPostgresOutboxRelayHostedService()` | +| `CoreEx.Azure.Messaging.ServiceBus` | `AddAzureServiceBusClient("ServiceBus")`, `AddAzureServiceBusPublisher(...)`, `ServiceBusSessionStrategy` | +| `OpenTelemetry.*` | `WithCoreExTelemetry()`, `WithCoreExSqlServerTelemetry()` / `WithCoreExPostgresTelemetry()`, `WithCoreExServiceBusTelemetry()`, `UseOtlpExporter()` | --- -There are three host types in a CoreEx solution. Each follows the same skeleton but adds type-specific registrations. - ---- +## API Host -## Shared Skeleton (All Host Types) +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() .AddMvcWebApi() .AddHttpWebApi(); -// ... type-specific registrations follow ... +builder.Services.AddDynamicServicesUsing(); + +// 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 example; use Postgres equivalents for Products). +builder.AddSqlServerClient("SqlServer"); +builder.Services + .AddSqlServerDatabase() + .AddSqlServerUnitOfWork() + .AddEventFormatter() + .AddSqlServerOutboxPublisher() + .AddDbContext() + .AddEfDb(); builder.Services.PostConfigureAllHealthChecks(); builder.Services.AddControllers(); -builder.Services.AddOpenApiDocument(s => { - s.Title = builder.Environment.ApplicationName; - s.AddCoreExConfiguration(); -}); +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(); @@ -78,46 +112,134 @@ app.MapHealthChecks(); app.Run(); ``` +Key points: +- `AddReferenceDataOrchestrator()` and `AddDynamicServicesUsing<...>()` are exclusive to the API host. +- FusionCache (L1/L2) and `AddHybridCacheIdempotencyProvider()` are exclusive to the API host. +- `AddEventFormatter()` is required wherever events are published or parsed. +- `AddSqlServerOutboxPublisher()` / `AddPostgresOutboxPublisher()` (no generic type parameter). +- Products uses `AddPostgresDatabase()` / `AddPostgresUnitOfWork()` / `AddPostgresOutboxPublisher()` / `WithCoreExPostgresTelemetry()` instead of the SQL Server variants. +- `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`. + --- -## API Host +## Subscribe Host -Add: reference data, SQL Server, FusionCache, outbox publisher, idempotency. +The Subscribe host receives broker messages and delegates to Application-layer services. It does **not** have reference data, FusionCache, or idempotency — but it does have a database/outbox for its own domain writes, plus Service Bus wiring. -Key registrations: -- `.AddReferenceDataOrchestrator()` -- `.AddDynamicServicesUsing<...>()` -- `.AddFusionCache()` + `.WithRegisteredDistributedCache()` + `.WithBackplane(...)` -- `.AddSqlServerDatabase()` + `.AddSqlServerUnitOfWork()` + `.AddSqlServerOutboxPublisher()` -- `.AddEventFormatter()` -- Middleware: `.UseIdempotencyKey()` after `.UseExecutionContext()` +```csharp +builder.Services + .AddPrecisionTimeProvider() + .AddExecutionContext() + .AddMvcWebApi() + .AddHttpWebApi() + .AddHostedServiceManager(); ---- +// Domain database + outbox publisher (for writes triggered by inbound events). +builder.AddSqlServerClient("SqlServer"); +builder.Services + .AddSqlServerDatabase() + .AddSqlServerUnitOfWork() + .AddSqlServerOutboxPublisher() + .AddDbContext() + .AddEfDb(); + +// Service Bus: outbox relay is the default publisher; Service Bus is NOT 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(); -## Subscribe Host +builder.Services.PostConfigureAllHealthChecks(); +builder.Services.AddControllers(); +builder.Services.AddOpenApiDocument(s => { s.Title = builder.Environment.ApplicationName; s.AddCoreExConfiguration(); }); -All of API host **plus**: +builder.WithCoreExTelemetry().WithCoreExServiceBusTelemetry().WithCoreExSqlServerTelemetry().UseOtlpExporter(); -Key registrations: -- `.AddHostedServiceManager()` -- `.AddSubscribedManager((_, c) => c.AddSubscribersUsing())` -- `.AzureServiceBusReceiving()` → `.WithSessionReceiver(...)` → `.WithSubscribedSubscriber()` → `.WithHostedService()` → `.Build()` +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(); +``` -Middleware addition: -- `app.MapHostedServices()` (after `.MapHealthChecks()`) +Key points: +- `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. +- `AddEventFormatter()` is required for message parsing and formatting. +- `MapHostedServices()` must come **after** `MapHealthChecks()`. +- No `AddReferenceDataOrchestrator`, no FusionCache, no `UseIdempotencyKey` in a Subscribe host. --- ## Outbox Relay Host -Minimal: SQL Server, Service Bus publisher, relay background service only. +The Outbox Relay host is minimal: it polls the outbox table and forwards committed events to Azure Service Bus. No controllers, no OpenAPI, no FusionCache. + +```csharp +builder.Services + .AddPrecisionTimeProvider() + .AddExecutionContext() + .AddMvcWebApi() + .AddHttpWebApi() + .AddHostedServiceManager(); + +// Database + outbox relay (SQL Server example; use Postgres equivalents for Products). +builder.AddSqlServerClient("SqlServer"); +builder.Services + .AddSqlServerDatabase() + .AddSqlServerUnitOfWork() + .AddSqlServerOutboxRelay(); // No configuration lambda required. + +builder.AddSqlServerOutboxRelayHostedService(); + +// 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(); -Key registrations: -- `.AddHostedServiceManager()` -- `.AddSqlServerOutboxRelay((_, c) => { ... })` -- `.AddSqlServerOutboxRelayHostedService()` -- `.AddAzureServiceBusPublisher((_, c) => { c.SessionIdStrategy = ...; })` +builder.WithCoreExTelemetry().WithCoreExSqlServerTelemetry().WithCoreExServiceBusTelemetry().UseOtlpExporter(); -No: reference data, FusionCache, idempotency, controllers, Swagger. +var app = builder.Build(); +app.UseCoreExExceptionHandler(); +app.UseHttpsRedirection(); +app.UseExecutionContext(); +app.MapHealthChecks(); +app.MapHostedServices(); +app.Run(); +``` -Middleware: minimal (no `.MapControllers()`, no `.UseOpenApi()`). +Key points: +- `AddSqlServerOutboxRelay()` / `AddPostgresOutboxRelay()` take no configuration lambda. +- `AddSqlServerOutboxRelayHostedService()` / `AddPostgresOutboxRelayHostedService()` registers the background relay pump — call these on `builder`, not `builder.Services`. +- No `AddControllers()`, no `AddOpenApiDocument()`, no `UseOpenApi()`, no `UseSwaggerUi()`, no `UseIdempotencyKey()`. +- `UseAuthorization()` is also omitted in the Relay host. +- Products uses `AddAzureNpgsqlDataSource("Postgres")` + `AddPostgresDatabase()` / `AddPostgresUnitOfWork()` / `AddPostgresOutboxRelay()` / `AddPostgresOutboxRelayHostedService()` / `WithCoreExPostgresTelemetry()` instead of the SQL Server variants. diff --git a/.github/instructions/repositories.instructions.md b/.github/instructions/repositories.instructions.md index 75e4b9d7..4480686d 100644 --- a/.github/instructions/repositories.instructions.md +++ b/.github/instructions/repositories.instructions.md @@ -1,7 +1,7 @@ --- applyTo: "**/Infrastructure/**/*.cs" -description: "Repository and infrastructure conventions: EFCore, ADO.NET patterns, ScopedService registration, and data access layers" -tags: ["repositories", "infrastructure", "data-access", "efcore", "ado-net"] +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 @@ -10,17 +10,20 @@ tags: ["repositories", "infrastructure", "data-access", "efcore", "ado-net"] | Package | Key types provided | |---|---| -| `CoreEx` | `[ScopedService]`, `.ThrowIfNull()` | -| `CoreEx.EntityFrameworkCore` | `EfDb`, `EfDbSet`, `.GetAsync()`, `.CreateAsync()`, `.UpdateAsync()`, `.DeleteAsync()`, `.GetWithResultAsync()`, `.CreateWithResultAsync()`, `.UpdateWithResultAsync()` | -| `CoreEx.Database.SqlServer` | SQL Server outbox publisher, ADO.NET command/parameter helpers | +| `CoreEx` | `[ScopedService]`, `.ThrowIfNull()`, `EventData` | +| `CoreEx.EntityFrameworkCore` | `EfDb`, `EfDbModel`, `EfDbMappedModel`, `EfDbOptions`, `.GetAsync()`, `.CreateAsync()`, `.UpdateAsync()`, `.DeleteAsync()`, `.GetWithResultAsync()`, `.CreateWithResultAsync()`, `.UpdateWithResultAsync()`, `.Query()` | +| `CoreEx.Database.SqlServer` | SQL Server outbox publisher, ADO.NET helpers — **Shopping only** | +| `CoreEx.Database.Postgres` | PostgreSQL outbox publisher, ADO.NET helpers — **Products only** | | `CoreEx.Data` | `DataResult`, `ItemsResult`, `QueryArgsConfig`, `QueryFilterOperator`, `.Where(parsed)`, `.OrderBy(parsed)`, `.ToMappedItemsResultAsync()` | | `CoreEx.Results` | `Result`, `.GoAsync()`, `.ThenAs()`, `.ThenAsAsync()` | +> **Polyglot data**: Products uses PostgreSQL (`CoreEx.Database.Postgres` + `Npgsql.EntityFrameworkCore.PostgreSQL`); Shopping uses SQL Server (`CoreEx.Database.SqlServer` + `Microsoft.EntityFrameworkCore.SqlServer`). Layers above Infrastructure are database-agnostic. + ## Structure -- Define the interface in the Application project under `Application/Repositories/`. -- Implement in the Infrastructure project. Register with `[ScopedService]` attribute. -- Inject the EF `*EfDb` (or ADO.NET database) via primary constructor and guard with `.ThrowIfNull()`. +- Define the repository interface in the Application project under `Application/Repositories/`. +- Implement in the Infrastructure project under `Repositories/`. Register with `[ScopedService]`. +- Inject the domain's `*EfDb` class via primary constructor and guard with `.ThrowIfNull()`. ```csharp [ScopedService] @@ -37,12 +40,42 @@ public class ProductRepository(ProductsEfDb ef) : IProductRepository | 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>` | Includes items + optional total count | -| Domain aggregate | `Task>` | Shopping-style — wraps `DataResult` with mapping | +| Collection query | `Task>` | Items + optional total count | +| Domain aggregate (Shopping) | `Task>` | Wraps EF result with domain-model mapping | + +## EfDb — Unit-of-Work Facade + +The `*EfDb` sub-class (`ProductsEfDb` / `ShoppingEfDb`) is the **unit-of-work facade** over the `DbContext`. It declares a typed property per entity model and configures global options (e.g., logical-delete filters). The `DbContext` delegates connection and transaction management to CoreEx's `IDatabase`, so the same connection is shared across a request. + +```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(); + // ... +} +``` + +## 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) => _ef.Products.GetAsync(id); +public Task> CreateAsync(Contracts.Product product) => _ef.Products.CreateAsync(product); +public Task> UpdateAsync(Contracts.Product product) => _ef.Products.UpdateAsync(product); +public Task DeleteAsync(string id) => _ef.Products.DeleteAsync(id); +``` ## Dynamic Query Configuration -Define a `static readonly QueryArgsConfig _queryConfig` per repository for OData-style filtering and ordering. Build it once at class (not method) level: +Define a `static readonly QueryArgsConfig _queryConfig` once at class level for OData-style filtering and ordering: ```csharp private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() @@ -63,41 +96,44 @@ private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() .AddField(nameof(ProductBase.Brand))); ``` -Apply in the query method: +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) +public async Task> QueryAsync(QueryArgs? query, PagingArgs? paging) { var parsed = _queryConfig.Parse(query).ThrowOnError(); - var products = _ef.Products.Model.Query(); + // 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 products + return await q .Where(parsed) .OrderBy(parsed) - .ToMappedItemsResultAsync(x => new ProductLite + .ToMappedItemsResultAsync(x => new Contracts.ProductLite { Id = x.Product.Id, Sku = x.Product.Sku, - Text = x.Product.Text, + CategoryCode = x.CategoryCode, + QtyOnHand = x.QtyOnHand }, paging); } ``` -## EF Delegate Shortcuts - -Use the built-in EF delegate methods for single-entity CRUD — do not write raw `DbContext` queries for simple operations: +Expose the query schema for the `$query` endpoint via `ToJsonSchema()`: ```csharp -public Task GetAsync(string id) => _ef.Products.GetAsync(id); -public Task> CreateAsync(Product product) => _ef.Products.CreateAsync(product); -public Task> UpdateAsync(Product product) => _ef.Products.UpdateAsync(product); -public Task DeleteAsync(string id) => _ef.Products.DeleteAsync(id); +public Task QuerySchemaAsync() => Task.FromResult(_queryConfig.ToJsonSchema()); ``` ## Domain-Aggregate Repositories (Result Pattern) -For Shopping-style aggregate repositories, chain `Result` operations using `.GoAsync` / `.ThenAs` / `.ThenAsAsync`. Map between persistence models and domain aggregates using explicit mappers: +For Shopping-style aggregate repositories, chain `Result` operations using `.GoAsync` / `.ThenAs` / `.ThenAsAsync`. Map between persistence models and domain aggregates using the explicit infrastructure mappers: ```csharp public Task> GetAsync(string id) => Result @@ -109,54 +145,103 @@ public Task> CreateAsync(Domain.Basket basket) => Result { var model = new Persistence.Basket(); BasketIntoMapper.MapInto(basket, model); - return SynchronizeItems(basket, model); + return model; }) .ThenAsAsync(model => _ef.Baskets.CreateWithResultAsync(model)) .ThenAs(b => BasketMapper.Map(b)); ``` -## Explicit Mapping — No AutoMapper +## Mapping -Write explicit mapper classes or static methods. Do not introduce AutoMapper: +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 -public static class BasketMapper +// Infrastructure/Mapping/ProductMapper.cs +public class ProductMapper : BiDirectionMapper { - public static Domain.Basket Map(Persistence.Basket model) + protected override Persistence.Product OnMap(Contracts.Product source) => new() { - // explicit property assignment - } -} + Id = source.Id!, + Sku = source.Sku!, + SubCategoryCode = source.SubCategory?.Code!, + Price = source.Price + }; -public static class BasketIntoMapper -{ - public static void MapInto(Domain.Basket src, Persistence.Basket dest) + protected override Contracts.Product OnMap(Persistence.Product source) => new() { - dest.Id = src.Id; - dest.CustomerId = src.CustomerId; - // ... - } + Id = source.Id, + Sku = source.Sku, + SubCategoryCode = source.SubCategoryCode, + Price = source.Price + }; } ``` -## ConfigureAwait +Infrastructure-level mapping is always Contract ↔ Persistence. Application-level mapping (Domain aggregate ↔ Contract) lives in `Application/Mapping/` and uses `Mapper`. Do not conflate the two. -Always call `.ConfigureAwait(false)` on every awaited call inside repository methods. +## External Clients and Adapter Implementations -## HTTP Client Adapters +When a domain calls another domain's API over HTTP, split the concern across two focused classes: -Infrastructure adapters that wrap downstream APIs should use a typed `HttpClient` registered under a named key. The adapter interface lives in Application; the implementation lives in Infrastructure: +- **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 (e.g., reading from the local event-replicated store) and event publication. ```csharp -[ScopedService] -public class ProductAdapter(ProductsHttpClient httpClient) : IProductAdapter +// Infrastructure/Clients/ProductsHttpClient.cs +public class ProductsHttpClient(HttpClient httpClient) { - private readonly ProductsHttpClient _httpClient = httpClient.ThrowIfNull(); + private readonly HttpClient _httpClient = httpClient.ThrowIfNull(); - public Task GetAsync(string id) - => _httpClient.GetAsync($"api/products/{id}"); + public async Task CreateReservationAsync(MovementRequest request) + { + var response = await _httpClient.PostAsJsonAsync("api/inventory/reserve", request, JsonDefaults.SerializerOptions); + return await response.ToResultAsync(); + } +} - public Task ReserveInventoryAsync(MovementRequest request) - => _httpClient.PostAsync("api/inventory/reserve", request); +// 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) => Result + .GoAsync(() => _ef.Products.GetWithResultAsync(id)) + .ThenAs(p => ProductMapper.From.Map(p)); + + // ReserveInventoryAsync — calls the Products API in real time (synchronous integration). + public async Task ReserveInventoryAsync(Domain.Basket basket) + => await _client.CreateReservationAsync(BuildRequest(basket)).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. + +| File pattern | Generator | Change instead | +|---|---|---| +| `Persistence/*.g.cs` | `*.Database` project (DbEx) | DbEx YAML config or SQL migration scripts | +| `*DbContext.g.cs` | `*.Database` project (DbEx) | DbEx YAML config | + +## 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. + +## Further Reading + +- [`samples/docs/infrastructure-layer.md`](../../../samples/docs/infrastructure-layer.md) — full walkthrough of persistence models, repositories, mapping, and external client/adapter patterns. +- [`samples/docs/patterns.md`](../../../samples/docs/patterns.md) — Adapter, Repository, Mapper, Persistence, and HTTP Client pattern entries with cross-links. +- [`samples/docs/layers.md`](../../../samples/docs/layers.md) — layer dependency rules and the role of the Infrastructure layer. +- [`samples/docs/tooling.md`](../../../samples/docs/tooling.md) — `*.Database` project: schema, persistence-model generation, and outbox provisioning. +- [`src/CoreEx.EntityFrameworkCore/README.md`](../../../src/CoreEx.EntityFrameworkCore/README.md) — `EfDb`, `EfDbModel`, `EfDbMappedModel`, and `EfDbOptions`. diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md index 871b453e..b07a3434 100644 --- a/.github/instructions/tests.instructions.md +++ b/.github/instructions/tests.instructions.md @@ -10,29 +10,38 @@ tags: ["testing", "unit-tests", "integration-tests", "test-helpers", "nunit"] | Package | Key types provided | |---|---| -| `CoreEx.UnitTesting` | `WithApiTester`, `WithGenericTester`, `Test.Http()`, `Test.Http()`, `Test.MigrateSqlServerDataAsync()`, `Test.ClearFusionCacheAsync()`, `Test.UseExpectedSqlServerOutboxPublisher()`, `Test.UseExpectedAzureServiceBusPublisher()`, `Test.ReplaceHttpClientFactory()`, `.ExpectIdentifier()`, `.ExpectETag()`, `.ExpectChangeLogCreated()`, `.ExpectJsonFromResource()`, `.ExpectSqlServerOutboxEvents()`, `.ExpectNoSqlServerOutboxEvents()`, `.AssertCreated()`, `.AssertOK()`, `.AssertBadRequest()`, `.AssertErrors()`, `.AssertJsonFromResource()`, `.AssertLocationHeader()`, `Test.Scoped()` | +| `CoreEx.UnitTesting` | `WithApiTester`, `WithGenericTester`, `Test.Http()`, `Test.Http()`, `Test.Scoped()`, `Test.ScopedType()`, `Test.ClearFusionCacheAsync()`, `Test.ReplaceHttpClientFactory()` | +| `CoreEx.UnitTesting.Database.SqlServer` | `Test.MigrateSqlServerDataAsync()`, `Test.UseExpectedSqlServerOutboxPublisher()`, `.ExpectSqlServerOutboxEvents()`, `.ExpectNoSqlServerOutboxEvents()` | +| `CoreEx.UnitTesting.Database.Postgres` | `Test.MigratePostgresDataAsync()`, `Test.UseExpectedPostgresOutboxPublisher()`, `.ExpectPostgresOutboxEvents()`, `.ExpectNoPostgresOutboxEvents()` | +| `CoreEx.UnitTesting.Azure.ServiceBus` | `Test.UseExpectedAzureServiceBusPublisher()`, `Test.GetAndClearAzureServiceBusAsync()` | +| `CoreEx.UnitTesting.AspNetCore` | `.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()` | +| `AwesomeAssertions` | `.Should()`, `.Be()`, `.HaveCount()` | ## Project Types | Project suffix | Base class | Scope | |---|---|---| -| `*.Test.Api` | `WithApiTester` | Full integration — real DB, cache, events, HTTP | +| `*.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.Outbox.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. + +--- + ## One-Time Setup -Every integration test class must have a `[OneTimeSetUp]` method that runs once before the suite. Order of operations is fixed: +Every integration test class must have a `[OneTimeSetUp]` method. Order of operations is fixed: -1. Migrate + seed the database. +1. Migrate + seed the domain database. 2. Clear the hybrid cache. -3. Set up event capture publishers. -4. Set up HTTP client mocks (where applicable). +3. Register event-capture publishers. +4. Set up inter-domain HTTP mocks (Shopping only). +**Shopping (SQL Server):** ```csharp [OneTimeSetUp] public async Task OneTimeSetUpAsync() @@ -41,7 +50,7 @@ public async Task OneTimeSetUpAsync() await Test.ClearFusionCacheAsync().ConfigureAwait(false); Test.UseExpectedSqlServerOutboxPublisher(); - Test.UseExpectedAzureServiceBusPublisher(); // Shopping only + Test.UseExpectedAzureServiceBusPublisher(); var mcf = MockHttpClientFactory.Create(); _mockHttpReserveRequest = mcf.CreateClient("ProductsApi") @@ -50,53 +59,74 @@ public async Task OneTimeSetUpAsync() } ``` -## Test Data (data.yaml) +**Products (Postgres):** +```csharp +[OneTimeSetUp] +public async Task OneTimeSetUpAsync() +{ + await Test.MigratePostgresDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); -Test data lives in `Data/data.yaml` in the `*.Test.Common` project. The `TestData` marker class in that project is the assembly locator — do not rename or move it. + Test.UseExpectedPostgresOutboxPublisher(); +} +``` + +**Outbox assertion helpers are database-specific.** Use `UseExpectedPostgresOutboxPublisher` / `ExpectPostgresOutboxEvents` for Products; use `UseExpectedSqlServerOutboxPublisher` / `ExpectSqlServerOutboxEvents` for Shopping. Never mix them. + +`DataResetFilterPredicate` in `DbMigration.ConfigureMigrationArgs` scopes the reset to the domain's own schema — Products and Shopping test runs do not corrupt each other even when run concurrently. + +--- + +## Test Data (`data.yaml`) -IDs are written as integers in the YAML file and resolved to GUIDs at load time via `n.ToGuid()`. Use the same helper in test code to reference those IDs: +Test data lives in `Data/data.yaml` in the `*.Test.Common` project. The `TestData` marker class locates the YAML file — do not rename or move it. + +IDs are written as small integers in the YAML and resolved to GUIDs at load time. Reference them consistently in tests via `n.ToGuid()`: ```csharp -var product = Test.Http() - .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}") - .AssertOK(); +Test.Http().Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}").AssertOK(); ``` -## Fluent Test Pattern +--- -Always use the `Test.Http()` / `Test.Http()` fluent chain: +## API Test Pattern -1. **Set expectations** (before calling `.Run`). -2. **Execute** with `.Run(method, path, body?)`. -3. **Assert** the response. +Use the `Test.Http()` / `Test.Http()` fluent chain: **set expectations → execute → assert**. ```csharp -// Simple GET +// GET Test.Http() .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}") .AssertOK() .AssertJsonFromResource("ReadTests.Product_Get_Found.res.json", "etag", "changelog"); -// POST with event assertion +// POST — Products (Postgres outbox) var created = Test.Http() .ExpectIdentifier() .ExpectETag() .ExpectChangeLogCreated() .ExpectJsonFromResource("ProductMutateTests.Create_Success.res.json") - .ExpectSqlServerOutboxEvents(e => e + .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 — Shopping (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."); -// Verify no events published +// Assert no events on failure path Test.Http() .ExpectNoSqlServerOutboxEvents() .Run(HttpMethod.Post, $"/api/baskets/{basketId}/checkout") @@ -105,29 +135,36 @@ Test.Http() ## Resource-Based JSON Assertions -Expected response bodies are stored as `.res.json` files in `Resources/`. Reference them by their dot-separated path within the Resources folder. Exclude volatile fields (etag, changelog timestamps, traceId) by passing them as additional params: +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 `.products.res.json` (by convention, prefixed with the remote domain name). + +--- + ## HTTP Client Mocking -Define the mock request field at class level and configure its response inside each test method. Always call `.Verify()` after the test action to confirm the mock was actually invoked: +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() @@ -135,27 +172,17 @@ _mockHttpReserveRequest.WithAnyBody() "Basket_Checkout_Insufficient_Quantity.products.res.json", HttpStatusCode.BadRequest, System.Net.Mime.MediaTypeNames.Application.ProblemJson); - -// After action _mockHttpReserveRequest.Verify(); ``` -## Event Publisher Expectations - -Use `ExpectSqlServerOutboxEvents` and `ExpectAzureServiceBusEvents` before `.Run` to assert that the operation produces the expected events: - -```csharp -.ExpectSqlServerOutboxEvents(e => e - .AssertWithValue("contoso", "contoso.products.product.created.v1")) -``` - -Use `ExpectNoSqlServerOutboxEvents()` when the operation must not produce any events (e.g., a failed checkout). +--- -## Unit Tests +## Unit and Validator Tests -Unit tests use `Test.Scoped(test => { ... })` to get an isolated execution context: +Unit tests use `Test.Scoped(test => { ... })`. For relay-style tests that need a named scoped type, use `Test.ScopedType`: ```csharp +// Validator unit test [Test] public void Empty_Required() => Test.Scoped(test => { @@ -166,11 +193,92 @@ public void Empty_Required() => Test.Scoped(test => ("subCategory", "Sub-category is required."), ("unitOfMeasure", "Unit-of-measure is required.")); }); + +// Repository-dependent validator — mock the dependency, test the logic +public class InventoryValidatorTests : WithGenericTester +{ + 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.")); + }); +} ``` +--- + +## Subscribe Host Tests + +Subscribe test classes extend `WithApiTester` over the subscriber host. The `[OneTimeSetUp]` migrates/seeds the domain DB just like an API test. There is no FusionCache to clear (Subscribe hosts have no cache). + +```csharp +public class ProductModifySubscriberTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(DbMigration.ConfigureMigrationArgs).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); +``` + +--- + ## NUnit Attributes -Use `[TestFixture]` on the class (inherited from base when using `WithApiTester`) and `[Test]` on individual test methods. Do not use `[TestCase]` for integration tests — use separate named methods for clarity. +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 @@ -184,3 +292,19 @@ 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 Products tests — use the Postgres equivalents. +- Do not use `UseExpectedPostgresOutboxPublisher` / `ExpectPostgresOutboxEvents` in Shopping tests — use the SQL Server equivalents. +- Do not call `ClearFusionCacheAsync()` in Subscribe or Outbox Relay host tests — those 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. + +## Further Reading + +- [`samples/docs/testing.md`](../../samples/docs/testing.md) — full test architecture, data seeding, schema isolation, and E2E runner. +- [`samples/docs/patterns.md`](../../samples/docs/patterns.md) — pattern catalog linking testing patterns to layer docs. diff --git a/.github/instructions/tooling.instructions.md b/.github/instructions/tooling.instructions.md new file mode 100644 index 00000000..80491fef --- /dev/null +++ b/.github/instructions/tooling.instructions.md @@ -0,0 +1,244 @@ +--- +applyTo: "**/*.Database/**,**/*.CodeGen/**" +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 | + +--- + +## `*.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` | +| `*.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`. + +### `ref-data.yaml` structure + +```yaml +# 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 + 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) +``` + +The full schema reference is in [`src/CoreEx.CodeGen/docs/`](../../../src/CoreEx.CodeGen/docs/). Add the `$schema` annotation to the file for IDE YAML validation and auto-complete. + +--- + +## `*.Database` — Database Lifecycle Management + +### NuGet / Project References + +| Package | Use case | +|---|---| +| `DbEx.SqlServer` + `DbEx.SqlServer.Console` | SQL Server domains (e.g. Shopping) | +| `DbEx.Postgres` + `DbEx.Postgres.Console` | PostgreSQL domains (e.g. Products) | +| `CoreEx.Database` | `SqlStatement` type — add its assembly to the migration runner for extended schema scripts | + +> **Polyglot**: Products uses `PostgresMigrationConsole` with `.pgsql` scripts and PostgreSQL functions; Shopping uses `SqlServerMigrationConsole` with `.sql` scripts and stored procedures. Choose the correct package per domain. + +### `Program.cs` pattern + +```csharp +// PostgreSQL domain (Products) +public static Task Main(string[] args) => PostgresMigrationConsole + .Create("Server=127.0.0.1;Database=contoso;Username=postgres;Password=...") + .Configure(c => ConfigureMigrationArgs(c.Args)) + .RunAsync(args); + +// SQL Server domain (Shopping) +public static Task Main(string[] args) => SqlServerMigrationConsole + .Create("Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=...") + .Configure(c => ConfigureMigrationArgs(c.Args)) + .RunAsync(args); + +public static MigrationArgs ConfigureMigrationArgs(MigrationArgs args) +{ + args.AddAssembly().AddAssembly() + .IncludeExtendedSchemaScripts() + .DataParserArgs + .RefDataColumnDefault("SortOrder", _ => 0) + .RefDataColumnDefault("Scale", _ => 0); + + // Scope data reset to this domain's schema only. + args.DataResetFilterPredicate = ts => ts.Schema == "{domain-schema}"; + return args; +} +``` + +### 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 timestamped migration script file | +| `Drop` | Drops the database | + +Composite commands for common scenarios: + +| Composite | Runs | +|---|---| +| `All` | `Create` → `Migrate` → `CodeGen` → `Schema` → `Data` | +| `Deploy` | `Migrate` → `Schema` | +| `DeployWithData` | `Migrate` → `Schema` → `Data` | +| `ResetAndAll` | `Reset` → `All` | + +### `dbex.yaml` structure + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/Avanade/DbEx/refs/heads/main/schema/dbex.json +schema: products # database schema name (omit for SQL Server PascalCase schemas) +outbox: true # generate full transactional outbox infrastructure +outboxName: outbox # prefix for outbox tables and procedures/functions +tables: +# Reference-data tables +- name: brand +- name: category +- name: unit_of_measure +# Transactional tables +- name: product +- name: inventory +``` + +Add the `$schema` annotation for IDE YAML validation and auto-complete. + +### `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`, 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. + +### `Migrate` — schema evolution + +Migration scripts are embedded resources under `Migrations/`. Use timestamp-ordered names: + +``` +# PostgreSQL (.pgsql) +20260101-000001-create-products-schema.pgsql +20260101-000101-create-products-category.pgsql +20260101-000201-create-products-product.pgsql +20260101-000301-create-products-outbox.pgsql # if outbox: true in dbex.yaml + +# SQL Server (.sql) +20260101-000001-create-shopping-schema.sql +20260101-000101-create-shopping-basket-status.sql +20260101-000201-create-shopping-basket.sql +``` + +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`. + +SQL conventions: +- Wrap each script in `BEGIN TRANSACTION ... COMMIT TRANSACTION` (SQL Server) or equivalent. +- Use explicit schema-qualified names. +- Include `CreatedBy`, `CreatedOn`, `UpdatedBy`, `UpdatedOn` audit columns on aggregate tables. +- Use `TIMESTAMP` / `ROWVERSION` for optimistic-concurrency columns mapped to `ETag`. + +### `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 + +Seed data lives in `Data/ref-data.yaml`. The root node is the schema/domain name. DbEx infers column types from the live schema. + +Prefixes control merge behaviour and identifier generation: + +| Prefix | Meaning | +|---|---| +| `$` | MERGE (upsert) — safe to re-run; use for reference data | +| `^` | Auto-generate GUID for the primary key | +| `$^` | Both — upsert with auto-generated GUID (typical for reference data) | + +```yaml +products: + - $^brand: # merge + auto-GUID primary key + - YETI: Yeti Cycles + - CANYON: Canyon Bicycles + - $^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 +``` + +## 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. + +## Further Reading + +- [`samples/docs/tooling.md`](../../../samples/docs/tooling.md) — full `*.CodeGen` and `*.Database` walkthrough with command reference. +- [`src/CoreEx.CodeGen/docs/`](../../../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/.github/instructions/validators.instructions.md b/.github/instructions/validators.instructions.md index fe050158..2a620e63 100644 --- a/.github/instructions/validators.instructions.md +++ b/.github/instructions/validators.instructions.md @@ -1,7 +1,7 @@ --- applyTo: "**/*Validator*.cs" -description: "Validator conventions: fluent validation API, rule definition, singleton pattern, and CoreEx validation framework usage" -tags: ["validators", "validation", "fluent-api", "rules", "error-handling"] +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 @@ -10,15 +10,22 @@ tags: ["validators", "validation", "fluent-api", "rules", "error-handling"] | Package | Key types provided | |---|---| -| `CoreEx.Validation` | `Validator`, `Validator.Create()`, `.Mandatory()`, `.MaximumLength()`, `.IsValid()`, `.PrecisionScale()`, `.GreaterThanOrEqualTo()`, `.LessThanOrEqualTo()`, `.Equal()`, `.NotFound()`, `.WhenValue()`, `.Error()`, `.DependsOn()`, `.Entity()`, `.Dictionary()`, `ValidationContext`, `.ValidateFurtherAsync()`, `.ValidateAndThrowAsync()`, `.ValidateWithResultAsync()`, `.AssertErrors()` (test helper) | +| `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()`, `.AssertErrors()` (test helper) | +| `CoreEx` | `LText` — localised text label for use in `.WithKeyValidator(label, ...)` and similar | | `CoreEx.Localization` | `[Localization(...)]` attribute on contract properties | +## 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. + ## Base Class -Use `Validator` from `CoreEx.Validation`. Expose a static `Default` singleton instance: +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 class ProductValidator : Validator { public ProductValidator() { @@ -31,20 +38,57 @@ public class ProductValidator : Validator } ``` -Do **not** use FluentValidation unless the project already depends on it. +**`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: -## Static Default Instance +```csharp +public class MovementRequestValidator : Validator +{ + private readonly IProductRepository _repository; + + public MovementRequestValidator(IProductRepository repository) + { + _repository = repository.ThrowIfNull(); + Property(x => x.Id).Mandatory().MaximumLength(50); + // ... + } +} +``` -The `Validator` base provides a `Default` singleton. Call `ValidateAndThrowAsync` or `ValidateWithResultAsync` without instantiating manually: +**`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 -// Exception style (services) +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); -// Result style (domain-aggregate services) +// Result style — returns Result for pipeline composition var result = await ProductValidator.Default.ValidateWithResultAsync(product); ``` +For `Validator` (with injection), the instance is resolved from DI and invoked the same way: + +```csharp +await _movementRequestValidator.ValidateAndThrowAsync(request); +``` + ## Common Rules | Rule | Method | @@ -71,19 +115,31 @@ Property(p => p.UnitOfMeasure).Mandatory().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(...)`: +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: +// In parent validator constructor: Property(x => x.Products).Mandatory().Dictionary(c => c - .WithKeyValidator("Product", k => k.Mandatory().MaximumLength(50)) + .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: @@ -122,7 +178,7 @@ public partial string? SubCategoryCode { get; set; } ## DependsOn for Conditional Precision -Use `.DependsOn(x => x.OtherProp)` to skip a rule when a dependent property is already invalid: +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 @@ -132,3 +188,17 @@ Property(x => x.Quantity, c => c 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. + +## Further Reading + +- [`samples/docs/application-layer.md`](../../../samples/docs/application-layer.md#validators) — full validator walkthrough including declarative and programmatic phases. +- [`samples/docs/patterns.md`](../../../samples/docs/patterns.md) — Validator pattern entry with cross-links. +- [`src/CoreEx.Validation/README.md`](../../../src/CoreEx.Validation/README.md) — `Validator`, rule set, `OnValidateAsync`, `ValidateFurtherAsync`, and `AbstractValidator`. diff --git a/.github/instructions/namespace_readme_template.md b/.github/namespace_readme_template.md similarity index 100% rename from .github/instructions/namespace_readme_template.md rename to .github/namespace_readme_template.md diff --git a/.github/skills/add-capability/SKILL.md b/.github/skills/add-capability/SKILL.md index 390a6503..49a9d50e 100644 --- a/.github/skills/add-capability/SKILL.md +++ b/.github/skills/add-capability/SKILL.md @@ -46,5 +46,5 @@ For detailed step-by-step workflow, see [`references/workflow.md`](references/wo - [Host Setup Conventions](/.github/instructions/host-setup.instructions.md) - [Event Subscriber Conventions](/.github/instructions/event-subscribers.instructions.md) - [Application Service Conventions](/.github/instructions/application-services.instructions.md) -- [Database Project Conventions](/.github/instructions/database-project.instructions.md) +- [Developer Tooling Conventions](/.github/instructions/tooling.instructions.md) - Sample hosts: `samples/src/Contoso.Products.Api/Program.cs`, `samples/src/Contoso.Products.Subscribe/Program.cs`, `samples/src/Contoso.Products.Outbox.Relay/Program.cs` diff --git a/.github/skills/add-capability/references/workflow.md b/.github/skills/add-capability/references/workflow.md index 11e2bb21..f6bc5314 100644 --- a/.github/skills/add-capability/references/workflow.md +++ b/.github/skills/add-capability/references/workflow.md @@ -8,7 +8,7 @@ Before making changes, load: - `host-setup.instructions.md` - `event-subscribers.instructions.md` - `application-services.instructions.md` - - `database-project.instructions.md` + - `tooling.instructions.md` 2. Sample host wiring from: - `samples/src/Contoso.Products.Api/Program.cs` diff --git a/samples/docs/application-layer.md b/samples/docs/application-layer.md index 401b30e6..39a551b2 100644 --- a/samples/docs/application-layer.md +++ b/samples/docs/application-layer.md @@ -200,7 +200,7 @@ public class BasketMapper : Mapper +[Contract] +public partial class ProductReserve : IIdentifier { public string Id { get; set; } = default!; diff --git a/src/CoreEx.AspNetCore.NSwag/AGENTS.md b/src/CoreEx.AspNetCore.NSwag/AGENTS.md new file mode 100644 index 00000000..50647cff --- /dev/null +++ b/src/CoreEx.AspNetCore.NSwag/AGENTS.md @@ -0,0 +1,41 @@ +# CoreEx.AspNetCore.NSwag — AI Usage Guide + +Wires CoreEx MVC attributes into the NSwag OpenAPI document pipeline. Register with a single call in `Program.cs`. + +## Registration + +```csharp +// Program.cs +builder.Services.AddOpenApiDocument(s => +{ + s.Title = builder.Environment.ApplicationName; + s.AddCoreExConfiguration(); // registers the CoreEx NSwag operation processor + STJ schema settings +}); + +// Middleware +app.UseOpenApi(); +app.UseSwaggerUi(); +``` + +`AddCoreExConfiguration()` is the only call required. It registers `NSwagOpenApiOperationProcessor` and aligns the NSwag JSON schema with `JsonDefaults.SerializerOptions` (camelCase, enum-as-string, write-when-not-default). + +## What Gets Generated Automatically + +| Attribute on action | OpenAPI artifact added | +|---|---| +| `[Paging]` | `$skip`, `$take`, optionally `$count`/`$page` query params | +| `[Query]` | `$filter`, `$orderby` query params | +| `[IdempotencyKey]` | `x-idempotency-key` header parameter | +| `[ProducesNotFoundProblem]` | `404 application/problem+json` response entry | +| `[Accepts(typeof(T))]` | Request body content type and JSON schema | + +## Do Not + +- Do not manually add paging/idempotency parameters to NSwag operations — let the operation processor generate them from attributes. +- Do not call `AddCoreExConfiguration()` more than once per document. + +## Further Reading + +- [README](./README.md) — full processor and options API reference. +- [CoreEx.AspNetCore](../CoreEx.AspNetCore/README.md) — defines the MVC attributes read by this processor. +- [Hosts layer](../../samples/docs/hosts-layer.md) — shows NSwag registration in a real API host `Program.cs`. diff --git a/src/CoreEx.AspNetCore.NSwag/README.md b/src/CoreEx.AspNetCore.NSwag/README.md index 01e995ab..280f2306 100644 --- a/src/CoreEx.AspNetCore.NSwag/README.md +++ b/src/CoreEx.AspNetCore.NSwag/README.md @@ -17,7 +17,7 @@ - 📦 **Request body content types**: Reads `[AcceptsAttribute]` and populates the operation `RequestBody` with the declared content type(s) and NSwag-inferred JSON schema for the body type. - 🔑 **Idempotency-key header**: Reads `[IdempotencyKeyAttribute]` and adds an `x-idempotency-key` header parameter to the operation. - 🚫 **Not-found response**: Reads `[ProducesNotFoundProblemAttribute]` and adds a `404 application/problem+json` response entry. -- ⚠️ **Standard ProblemDetails responses**: Optionally injects `400`, `422`, and `500` `application/problem+json` response entries for all operations via `OpenApiOptions.IncludeStandardProblemDetailsResponses`. +- ⚠️ **Standard ProblemDetails responses**: Optionally injects `400`, `4xx`, and `500` `application/problem+json` response entries for all operations via `OpenApiOptions.IncludeStandardProblemDetailsResponses`. - 📡 **Fields query string**: When `OpenApiOptions.IncludeFieldsRequestHeaders` is set, adds the `$fields` query-string parameter for response field projection. - 💬 **Message response headers**: When `OpenApiOptions.IncludeMessagesResponseHeaders` is set, documents `x-messages-warning` and `x-messages-info` response headers. - ⚙️ **STJ schema settings**: `ConfigureSchemaSettings()` aligns NSwag's JSON schema generation with `JsonDefaults.SerializerOptions` (camelCase, enum-as-string, `WhenWritingDefault`) so the generated schema matches the actual serialized output. @@ -37,4 +37,8 @@ ## Additional Resources -- [NSwag GitHub](https://github.com/RicoSuter/NSwag) - The NSwag library this package extends. \ No newline at end of file +- [NSwag GitHub](https://github.com/RicoSuter/NSwag) - The NSwag library this package extends. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/AGENTS.md b/src/CoreEx.AspNetCore/AGENTS.md new file mode 100644 index 00000000..c499dd7b --- /dev/null +++ b/src/CoreEx.AspNetCore/AGENTS.md @@ -0,0 +1,96 @@ +# CoreEx.AspNetCore — AI Usage Guide + +Provides the `WebApi` HTTP execution helper, exception-to-ProblemDetails middleware, idempotency, and health checks for ASP.NET Core hosts. + +## Controllers + +Inherit `ControllerBase`. Inject `WebApi` and the application service interface. Use `WebApi` helper methods for all action methods — never return `ActionResult` directly. + +```csharp +[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(); + + [HttpGet("{id}"), ProducesNotFoundProblem] + public Task GetAsync(Guid id) => + _webApi.GetAsync(Request, () => _service.GetAsync(id)); + + [HttpPost, IdempotencyKey] + public Task CreateAsync([FromBody] Product product) => + _webApi.PostAsync(Request, () => _service.CreateAsync(product), + statusCode: HttpStatusCode.Created, + locationUri: r => new Uri($"/api/products/{r!.Id}", UriKind.Relative)); + + [HttpPut("{id}")] + public Task UpdateAsync(Guid id, [FromBody] Product product) => + _webApi.PutAsync(Request, () => _service.UpdateAsync(id, product)); + + [HttpDelete("{id}")] + public Task DeleteAsync(Guid id) => + _webApi.DeleteAsync(Request, () => _service.DeleteAsync(id)); +} +``` + +## PATCH (JSON Merge Patch) + +Use `PatchAsync` with a function that loads the current entity for merging. + +```csharp +[HttpPatch("{id}")] +public Task PatchAsync(Guid id) => + _webApi.PatchAsync(Request, + get: _ => _service.GetAsync(id), + put: product => _service.UpdateAsync(id, product)); +``` + +## Query / Paged List Endpoints + +Use `[Query]` and `[Paging]` attributes; the `WebApi` helper reads them from the request automatically. + +```csharp +[HttpGet, Query, Paging] +public Task GetAllAsync() => + _webApi.GetAsync(Request, q => _service.GetAllAsync(q.QueryArgs, q.PagingArgs)); +``` + +## Middleware Registration Order + +Order matters — follow this sequence in `Program.cs`: + +```csharp +app.UseCoreExExceptionHandler(); // translates IExtendedException → ProblemDetails +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.UseExecutionContext(); // scopes ExecutionContext per request +app.UseIdempotencyKey(); // must come AFTER UseExecutionContext +app.MapControllers(); +app.MapHealthChecks(); +``` + +## Service Registration + +```csharp +builder.Services + .AddExecutionContext() + .AddMvcWebApi() // registers Mvc.WebApi + invoker + .AddHttpWebApi(); // registers Http.WebApi for Minimal API +``` + +## Do Not + +- Do not inherit from `Controller` — use `ControllerBase`. +- Do not return `ActionResult` directly — always delegate to the `WebApi` helper. +- Do not inject `IUnitOfWork` into controllers — it belongs in the application service. +- Do not put business logic in controllers — delegate immediately to the application service. +- Do not call `UseIdempotencyKey()` before `UseExecutionContext()`. + +## Further Reading + +- [README](./README.md) — full API surface for `WebApi`, middleware, and health checks. +- [CoreEx](../CoreEx/README.md) — semantic exceptions and `Result` translated by this package. +- [CoreEx.AspNetCore.NSwag](../CoreEx.AspNetCore.NSwag/README.md) — OpenAPI spec generation for CoreEx attributes. +- [Hosts layer](../../samples/docs/hosts-layer.md) — real-world `Program.cs` shape, middleware ordering, and host-specific wiring patterns. +- [Patterns](../../samples/docs/patterns.md) — pattern catalogue for HTTP endpoints, idempotency, paging, and PATCH. +- [Layers overview](../../samples/docs/layers.md) — full layer dependency diagram and host composition rules. diff --git a/src/CoreEx.AspNetCore/Mvc/README.md b/src/CoreEx.AspNetCore/Mvc/README.md index 2487e000..14ae8a03 100644 --- a/src/CoreEx.AspNetCore/Mvc/README.md +++ b/src/CoreEx.AspNetCore/Mvc/README.md @@ -10,7 +10,7 @@ The namespace also provides a set of small, targeted MVC attributes. These carry ## Key capabilities -- 🎯 **MVC result creation**: `Mvc.WebApi.CreateResult` translates `WebApiResult` to the full range of `IActionResult` types, including field-level `ValidationException` → `422` with `errors` extension, and `ConcurrencyException` → `409 Conflict`. +- 🎯 **MVC result creation**: `Mvc.WebApi.CreateResult` translates `WebApiResult` to the full range of `IActionResult` types, including field-level `ValidationException` → `400 Bad Request` with `errors` extension, and `ConcurrencyException` → `409 Conflict`. - 📑 **Paging attribute**: `[PagingAttribute]` marks operations that accept paging arguments via query string without declaring `PagingArgs` as an explicit method parameter; NSwag reads this to add `$skip`, `$take`, `$count`, `$page` query parameters to the spec. - 🔍 **Query attribute**: `[QueryAttribute]` marks operations that accept OData-style `$filter` / `$orderby` query arguments; NSwag adds the corresponding parameters. - ✅ **Accepts attribute**: `[AcceptsAttribute]` declares the request body `Content-Type` and schema type for NSwag, replacing the need for `[Consumes]` with schema inference. diff --git a/src/CoreEx.AspNetCore/README.md b/src/CoreEx.AspNetCore/README.md index 38f4ec7e..da997b91 100644 --- a/src/CoreEx.AspNetCore/README.md +++ b/src/CoreEx.AspNetCore/README.md @@ -51,9 +51,13 @@ Two concrete `WebApi` implementations ship: `Mvc.WebApi` returning `IActionResul - **[`CoreEx`](../CoreEx/README.md)** - Semantic exceptions, `ExecutionContext`, `Result`, and `PagingArgs` are the domain primitives translated by this package into HTTP responses. - **[`CoreEx.Http`](../CoreEx.Http/README.md)** - Client-side `TypedHttpClientBase` consumes `ProblemDetails` responses produced by this package; `HttpNames` defines the shared header/query-string constants. -- **[`CoreEx.Validation`](../CoreEx.Validation/README.md)** - `ValidationException` raised by validators is translated to `422 Unprocessable Entity` with a field-level `errors` extension in `ProblemDetails`. +- **[`CoreEx.Validation`](../CoreEx.Validation/README.md)** - `ValidationException` raised by validators is translated to `400 Bad Request` with a field-level `errors` extension in `ProblemDetails`. ## Additional Resources - [RFC 7807 — Problem Details for HTTP APIs](https://tools.ietf.org/html/rfc7807) - The specification implemented by `WebApi` exception handling. -- [ASP.NET Core Health Checks](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks) - The underlying infrastructure extended by `HealthCheckOptions`. \ No newline at end of file +- [ASP.NET Core Health Checks](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks) - The underlying infrastructure extended by `HealthCheckOptions`. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/AGENTS.md b/src/CoreEx.Azure.Messaging.ServiceBus/AGENTS.md new file mode 100644 index 00000000..46aa4872 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/AGENTS.md @@ -0,0 +1,95 @@ +# CoreEx.Azure.Messaging.ServiceBus — AI Usage Guide + +Provides Azure Service Bus publishing and subscriber hosting for the CoreEx events pipeline. + +## Publishing + +Register the publisher in `Program.cs`. When the outbox pattern is used (recommended), Service Bus is **not** the default `IEventPublisher` — the outbox publisher is. Pass `addAsDefaultIEventPublisher: false`. + +```csharp +// Outbox-backed API or Subscribe host — outbox publishes to the DB; relay forwards to Service Bus +builder.Services.AddAzureServiceBusPublisher((_, c) => +{ + c.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId; +}, addAsDefaultIEventPublisher: false); + +// Outbox Relay host — Service Bus IS the default publisher (no outbox layer here) +builder.Services.AddAzureServiceBusPublisher((_, c) => +{ + c.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId; +}); +``` + +## Subscribe Host Wiring + +```csharp +// Register the event formatter and subscriber manager first +builder.Services + .AddEventFormatter() + .AddSubscribedManager((_, c) => c.AddSubscribersUsing()); +// AddSubscribersUsing scans the assembly of T and auto-registers all [Subscribe]-decorated classes. + +// Wire the Service Bus receiver +builder.Services.AzureServiceBusReceiving() + .WithSessionReceiver(_ => + { + var o = ServiceBusSessionReceiverOptions.CreateForTopicSubscription(); + o.SessionProcessorOptions.MaxConcurrentSessions = 4; + return o; + }) + .WithSubscribedSubscriber() // routes messages through SubscribedManager + .WithHostedService() // runs as a BackgroundService + .Build(); +``` + +## Subscriber Classes + +Subscribers are decorated with `[Subscribe("subject")]` and extend `SubscribedBase` (untyped) or `SubscribedBase` (typed payload). Register with `[ScopedService]`. + +```csharp +// Untyped subscriber +[ScopedService, Subscribe("contoso.orders.order.created.v1")] +public class OrderCreatedSubscriber(IOrderService service) : SubscribedBase +{ + protected override async Task OnReceiveAsync(EventData @event, EventSubscriberArgs args, + CancellationToken cancellationToken = default) + { + var id = @event.Key.Required(); + await service.ProcessCreatedAsync(id, cancellationToken).ConfigureAwait(false); + return Result.Success; + } +} + +// Typed subscriber with validation +[ScopedService, Subscribe("contoso.orders.order.updated.v1")] +public class OrderUpdatedSubscriber(IOrderService service) : SubscribedBase +{ + public override IValidator? ValueValidator => OrderValidator.Default; + + protected override Task OnReceiveAsync(Order value, EventData @event, + EventSubscriberArgs args, CancellationToken cancellationToken = default) + => service.UpdateAsync(value, cancellationToken); +} +``` + +## Error Handling + +Use `ErrorHandler` on a subscriber to map specific exceptions to dead-letter, silent completion, or retry — no try/catch in subscriber code. + +```csharp +internal static readonly ErrorHandler DefaultErrorHandler = new ErrorHandler() + .Add(_ => ErrorHandling.CompleteAsInformation); +``` + +## Do Not + +- Do not register `AddAzureServiceBusPublisher` as the default publisher when using the transactional outbox — pass `addAsDefaultIEventPublisher: false`. +- Do not add a new subscriber to `Program.cs` — creating the class with `[Subscribe]` and `[ScopedService]` is sufficient; `AddSubscribersUsing()` discovers it automatically. + +## Further Reading + +- [README](./README.md) — full publisher, subscriber, and receiver API reference. +- [CoreEx.Events](../CoreEx.Events/README.md) — `IEventPublisher`, `EventSubscriberBase`, and `SubscribedManager` that this package binds to Service Bus. +- [Hosts layer](../../samples/docs/hosts-layer.md) — Subscribe and Outbox.Relay host `Program.cs` shapes and Azure Service Bus wiring. +- [Infrastructure layer](../../samples/docs/infrastructure-layer.md) — outbox table setup and relay publisher integration. +- [Patterns](../../samples/docs/patterns.md) — transactional outbox, event publishing, and subscriber error-handling patterns. diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/README.md b/src/CoreEx.Azure.Messaging.ServiceBus/README.md index 52f135d8..59f05d4a 100644 --- a/src/CoreEx.Azure.Messaging.ServiceBus/README.md +++ b/src/CoreEx.Azure.Messaging.ServiceBus/README.md @@ -52,4 +52,8 @@ Resiliency is provided out-of-the-box: `ServiceBusReceiverResiliency` supplies f - **[`CoreEx.Events`](../CoreEx.Events/README.md)** - Defines `IEventPublisher`, `EventPublisherBase`, `EventSubscriberBase`, `SubscribedManager`, and `IEventFormatter` that this package binds to Azure Service Bus. - **[`CoreEx.Events.Publishing`](../CoreEx.Events/Publishing/README.md)** - `IDestinationProvider` and `DestinationEvent` used by `ServiceBusPublisher` during batched dispatch. - **[`CoreEx.Events.Subscribing`](../CoreEx.Events/Subscribing/README.md)** - `ErrorHandling`, `ErrorHandler`, and subscriber exception types consumed by the receiver pipeline. -- **[`CoreEx.Database.Outbox`](../CoreEx.Database/Outbox/README.md)** - Outbox relay publisher that produces events later consumed by a `ServiceBusReceiver`-based relay host. \ No newline at end of file +- **[`CoreEx.Database.Outbox`](../CoreEx.Database/Outbox/README.md)** - Outbox relay publisher that produces events later consumed by a `ServiceBusReceiver`-based relay host. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx.Caching.FusionCache/AGENTS.md b/src/CoreEx.Caching.FusionCache/AGENTS.md new file mode 100644 index 00000000..abf984a0 --- /dev/null +++ b/src/CoreEx.Caching.FusionCache/AGENTS.md @@ -0,0 +1,53 @@ +# CoreEx.Caching.FusionCache — AI Usage Guide + +Binds the CoreEx `IHybridCache` abstraction to the FusionCache library, providing L1 (in-process) + L2 (Redis) hybrid caching with a multi-node backplane. + +## Registration + +```csharp +// Program.cs — standard L1/L2 hybrid setup with Redis backplane +builder.Services.AddMemoryCache(); +builder.AddRedisDistributedCache("redis"); // Aspire Redis resource name + +builder.Services.AddFusionCache() + .WithRegisteredMemoryCache() + .WithRegisteredDistributedCache() + .WithBackplane(sp => new RedisBackplane(new RedisBackplaneOptions + { + Configuration = sp.GetRequiredService().Configuration + })) + .WithSystemTextJsonSerializer(JsonDefaults.SerializerOptions); + +builder.Services + .AddFusionHybridCache() // registers FusionHybridCache as IHybridCache + .AddDefaultCacheKeyProvider() // registers ICacheKeyProvider + .AddHybridCacheIdempotencyProvider(); // registers IIdempotencyProvider backed by IHybridCache +``` + +## Usage in Application Code + +Depend only on `IHybridCache` — never on `IFusionCache` directly in application/domain code. + +```csharp +public class ReferenceDataService(IHybridCache cache) +{ + public async Task> GetStatusesAsync(CancellationToken ct = default) + => await cache.GetOrCreateByKeyAsync("ref.statuses", + _ => LoadStatusesAsync(), + new HybridCacheEntryOptions { LocalExpiration = TimeSpan.FromMinutes(5) }, + cancellationToken: ct).ConfigureAwait(false); +} +``` + +## Do Not + +- Do not inject `IFusionCache` directly into application or domain services — use `IHybridCache`. +- Do not configure FusionCache without `AddFusionHybridCache()` — the `IHybridCache` registration is separate from the FusionCache builder setup. + +## Further Reading + +- [README](./README.md) — options translation and entry-options escape-hatch reference. +- [CoreEx](../CoreEx/README.md) — defines `IHybridCache` and `HybridCacheEntryOptions`. +- [FusionCache](https://github.com/ZiggyCreatures/FusionCache) — underlying caching library. +- [Hosts layer](../../samples/docs/hosts-layer.md) — `AddFusionCache()` / `AddFusionHybridCache()` registration in real API and subscriber hosts. +- [Infrastructure layer](../../samples/docs/infrastructure-layer.md) — Redis backplane configuration and idempotency provider wiring. diff --git a/src/CoreEx.Caching.FusionCache/README.md b/src/CoreEx.Caching.FusionCache/README.md index 3925df0d..16e28094 100644 --- a/src/CoreEx.Caching.FusionCache/README.md +++ b/src/CoreEx.Caching.FusionCache/README.md @@ -31,4 +31,8 @@ The package is deliberately thin: `FusionHybridCache` delegates every cache oper ## Additional resources - [ZiggyCreatures FusionCache](https://github.com/ZiggyCreatures/FusionCache) — the underlying caching library. -- [FusionCache backplane documentation](https://github.com/ZiggyCreatures/FusionCache/blob/main/docs/Backplane.md) — configuring Redis or memory backplanes for multi-node coherence. \ No newline at end of file +- [FusionCache backplane documentation](https://github.com/ZiggyCreatures/FusionCache/blob/main/docs/Backplane.md) — configuring Redis or memory backplanes for multi-node coherence. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx.CodeGen/AGENTS.md b/src/CoreEx.CodeGen/AGENTS.md new file mode 100644 index 00000000..e892860d --- /dev/null +++ b/src/CoreEx.CodeGen/AGENTS.md @@ -0,0 +1,52 @@ +# CoreEx.CodeGen — AI Usage Guide + +`CoreEx.CodeGen` is a **development-time** code generation tool — it is never deployed at runtime. It reads a `ref-data.yaml` file and generates the complete reference-data layer (contract, controller, service, repository, mapper) as `.g.cs` files. + +## Setup + +Create a console project (e.g. `MyApp.CodeGen`) and add a `Program.cs` with one line: + +```csharp +await CoreEx.CodeGen.CodeGenConsole.Create().RunAsync(args); +``` + +Place `ref-data.yaml` alongside `Program.cs`. + +## ref-data.yaml Structure + +```yaml +collectionSortOrder: Code # default sort for all reference data collections + +entities: + - name: Status + idType: int # Id property type; defaults to string + properties: + - name: IsExternal + type: bool + default: false + - name: Country + - name: Currency +``` + +Run the project (`dotnet run`) to regenerate all `.g.cs` files after changing the YAML. + +## Generated Outputs + +| Output | Layer | What changes it | +|---|---|---| +| `Contracts/**/*.g.cs` | Contracts | `ref-data.yaml` entity/property config | +| `**/Controllers/**/*.g.cs` | Api | `ref-data.yaml` route/entity config | +| `**/Services/**/*.g.cs` | Application | `ref-data.yaml` entity config | +| `**/Repositories/**/*.g.cs` | Infrastructure | `ref-data.yaml` repository/mapper config | +| `**/Mappers/**/*.g.cs` | Infrastructure | `ref-data.yaml` property config | + +## Do Not + +- Do not edit `*.g.cs` files — they are overwritten on every generation run. Edit `ref-data.yaml` or the Handlebars templates in the `CoreEx.CodeGen` package instead. +- Do not add `CoreEx.CodeGen` as a runtime dependency — it is a development tool only. + +## Further Reading + +- [README](./README.md) — full YAML schema, script structure, and template customisation reference. +- [Tooling](../../samples/docs/tooling.md) — how `*.CodeGen` and `*.Database` projects are used together in the sample solution, including run order and generated-file ownership. +- [Contracts layer](../../samples/docs/contracts-layer.md) — shows generated reference-data contracts (`[ReferenceData]`) and how `ref-data.yaml` drives the controller/service/repository layer. diff --git a/src/CoreEx.CodeGen/README.md b/src/CoreEx.CodeGen/README.md index b86d8abc..d6f17f5f 100644 --- a/src/CoreEx.CodeGen/README.md +++ b/src/CoreEx.CodeGen/README.md @@ -104,4 +104,8 @@ Configuration details for each of the above are as follows: ## Additional Resources - [OnRamp](https://github.com/Avanade/OnRamp) — the underlying code-generation orchestration framework used to load the script, resolve templates, and manage file output. -- [CoreEx ref-data schema](../../schema/coreex-refdata.json) — JSON Schema for `ref-data.yaml`; use it with IDE YAML language-server support for validation and auto-complete while authoring configuration. \ No newline at end of file +- [CoreEx ref-data schema](../../schema/coreex-refdata.json) — JSON Schema for `ref-data.yaml`; use it with IDE YAML language-server support for validation and auto-complete while authoring configuration. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx.Data/AGENTS.md b/src/CoreEx.Data/AGENTS.md new file mode 100644 index 00000000..03257076 --- /dev/null +++ b/src/CoreEx.Data/AGENTS.md @@ -0,0 +1,76 @@ +# CoreEx.Data — AI Usage Guide + +Provides `IUnitOfWork` (the transactional boundary), `DataResult` mutation outcomes, and the `QueryArgsConfig` safe dynamic-query pipeline. + +## IUnitOfWork — Transactional Boundary + +Wrap all database mutations **and** event publishing inside `TransactionAsync`. Both the database write and the outbox event are committed atomically or rolled back together. + +```csharp +public class OrderService(IUnitOfWork uow, IOrderRepository repo) +{ + public async Task CreateAsync(Order order, CancellationToken ct = default) + { + // validate first (outside the transaction) + await _validator.ValidateAndThrowAsync(order, ct).ConfigureAwait(false); + + return await uow.TransactionAsync(async () => + { + var created = await repo.CreateAsync(order, ct).ConfigureAwait(false); + + // enqueue event — published atomically with the DB commit via the outbox + uow.Events.Add(EventData.CreateEventWith(created, EventAction.Created)); + + return created; + }).ConfigureAwait(false); + } +} +``` + +## QueryArgsConfig — Safe Dynamic Filter / OrderBy + +Configure an explicit allow-list of filterable and sortable fields. Never expose raw `$filter` strings to LINQ without this. + +```csharp +private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() + .WithFilter(f => f + .AddField(nameof(Order.Status), c => c.WithDefault("A")) + .AddField(nameof(Order.CreatedOn))) + .WithOrderBy(o => o + .AddField(nameof(Order.CreatedOn)) + .WithDefault($"{nameof(Order.CreatedOn)} desc")); + +// In the repository +public async Task> GetAllAsync(QueryArgs args, CancellationToken ct = default) + => await _efDb.Model() + .Query(new EfDbArgs(args, _queryConfig)) + .ToItemsResultAsync(MapToEntity, ct).ConfigureAwait(false); +``` + +## DataResult + +Return `DataResult` (no value) or `DataResult` from mutation operations to distinguish "mutated" from "not found" without throwing. + +```csharp +public async Task DeleteAsync(Guid id, CancellationToken ct = default) + => await uow.TransactionAsync(async () => + { + var dr = await repo.DeleteAsync(id, ct).ConfigureAwait(false); + dr.WhereMutated(() => + uow.Events.Add(EventData.CreateEventWith(default, EventAction.Deleted).WithKey(id))); + return dr; + }).ConfigureAwait(false); +``` + +## Do Not + +- Prefer enqueuing events inside `TransactionAsync` so they are committed or rolled back atomically with the database write. Events added outside a transaction scope are still published but will not be rolled back if a subsequent operation fails — only do this intentionally when at-least-once delivery without rollback is the desired behaviour. +- Do not expose raw `$filter` or `$orderby` strings to LINQ — always use `QueryArgsConfig`. + +## Further Reading + +- [README](./README.md) — full `IUnitOfWork`, `QueryArgsConfig`, and `DataResult` API reference. +- [CoreEx.Database.SqlServer](../CoreEx.Database.SqlServer/README.md) / [CoreEx.Database.Postgres](../CoreEx.Database.Postgres/README.md) — concrete `IUnitOfWork` implementations. +- [CoreEx.EntityFrameworkCore](../CoreEx.EntityFrameworkCore/README.md) — `QueryArgsConfig` consumption via `EfDbModel`. +- [Application layer](../../samples/docs/application-layer.md) — real-world `TransactionAsync` usage, event enqueuing inside the unit-of-work, and service orchestration patterns. +- [Patterns](../../samples/docs/patterns.md) — transactional outbox, atomic commit with event publishing, and dynamic query patterns. diff --git a/src/CoreEx.Data/README.md b/src/CoreEx.Data/README.md index d6403ad8..7e5d2d28 100644 --- a/src/CoreEx.Data/README.md +++ b/src/CoreEx.Data/README.md @@ -50,4 +50,8 @@ - **[`CoreEx`](../CoreEx/README.md)** - `QueryArgs` (filter/orderby strings and paging), `PagingArgs`, and `IEventQueue` are defined in the root `CoreEx` package and consumed here. - **[`CoreEx.Database`](../CoreEx.Database/README.md)** - `IUnitOfWork` is implemented by the database unit-of-work; `QueryArgsConfig` is used by database query builders. -- **[`CoreEx.EntityFrameworkCore`](../CoreEx.EntityFrameworkCore/README.md)** - EF Core `IQueryable` extensions consume `QueryArgsConfig` via `Where`/`OrderBy`. \ No newline at end of file +- **[`CoreEx.EntityFrameworkCore`](../CoreEx.EntityFrameworkCore/README.md)** - EF Core `IQueryable` extensions consume `QueryArgsConfig` via `Where`/`OrderBy`. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx.Database.Postgres/AGENTS.md b/src/CoreEx.Database.Postgres/AGENTS.md new file mode 100644 index 00000000..7ab076c5 --- /dev/null +++ b/src/CoreEx.Database.Postgres/AGENTS.md @@ -0,0 +1,63 @@ +# CoreEx.Database.Postgres — AI Usage Guide + +PostgreSQL implementation of `IDatabase` / `IUnitOfWork` with transactional outbox support. + +## Registration + +```csharp +// Program.cs +builder.AddAzureNpgsqlDataSource("Postgres"); // Aspire resource name +builder.Services + .AddPostgresDatabase() + .AddPostgresUnitOfWork() + .AddPostgresOutboxPublisher() // transactional outbox publisher + .AddDbContext() + .AddEfDb(); +``` + +## Error Code Convention + +Functions/procedures raise `SQLSTATE` values to signal domain exceptions — no application-layer switch statements. + +| SQLSTATE | CoreEx exception | +|---|---| +| 56001 | `ValidationException` | +| 56002 | `BusinessException` | +| 56004 | `ConcurrencyException` | +| 56005 | `NotFoundException` | +| 56006 | `ConflictException` | +| 56007 | `DuplicateException` | + +## Outbox + +`PostgresOutboxPublisher` writes events to the outbox table within the current `TransactionAsync` scope. The `PostgresOutboxRelayHostedService` polls and forwards them to `IEventPublisher` (typically Azure Service Bus). + +```csharp +// Relay host Program.cs +builder.Services + .AddPostgresDatabase() + .AddPostgresUnitOfWork() + .AddPostgresOutboxRelay(); + +builder.AddPostgresOutboxRelayHostedService(); // called on builder, not builder.Services +``` + +## OpenTelemetry + +```csharp +builder.WithCoreExTelemetry() + .WithCoreExPostgresTelemetry() + .UseOtlpExporter(); +``` + +## Do Not + +- Do not mix `UseExpectedSqlServerOutboxPublisher` / `ExpectSqlServerOutboxEvents` in tests for a Postgres-backed domain — use the Postgres equivalents. +- Do not call `AddPostgresOutboxRelayHostedService()` on `builder.Services` — call it on `builder`. + +## Further Reading + +- [README](./README.md) — full API reference including `PostgresDatabase`, metrics, and Npgsql extensions. +- [CoreEx.Database](../CoreEx.Database/README.md) — abstract base types. +- [Infrastructure layer](../../samples/docs/infrastructure-layer.md) — PostgreSQL-specific repository, mapper, and outbox wiring in the Products sample. +- [Tooling](../../samples/docs/tooling.md) — `*.Database` project (DbEx) for PostgreSQL schema generation and outbox infrastructure setup. diff --git a/src/CoreEx.Database.Postgres/README.md b/src/CoreEx.Database.Postgres/README.md index 4f95808e..c2ccd9f2 100644 --- a/src/CoreEx.Database.Postgres/README.md +++ b/src/CoreEx.Database.Postgres/README.md @@ -47,4 +47,8 @@ The outbox sub-namespace provides ready-to-use `PostgresOutboxPublisher`, `Postg ## Additional Resources - [Npgsql](https://www.npgsql.org/) - The PostgreSQL ADO.NET driver this package uses. -- [PostgreSQL Error Codes](https://www.postgresql.org/docs/current/errcodes-appendix.html) - Reference for `SqlState` values including the CoreEx convention codes. \ No newline at end of file +- [PostgreSQL Error Codes](https://www.postgresql.org/docs/current/errcodes-appendix.html) - Reference for `SqlState` values including the CoreEx convention codes. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/AGENTS.md b/src/CoreEx.Database.SqlServer/AGENTS.md new file mode 100644 index 00000000..7563b02f --- /dev/null +++ b/src/CoreEx.Database.SqlServer/AGENTS.md @@ -0,0 +1,72 @@ +# CoreEx.Database.SqlServer — AI Usage Guide + +SQL Server implementation of `IDatabase` / `IUnitOfWork` with session-context stamping and transactional outbox support. + +## Registration + +```csharp +// Program.cs +builder.AddSqlServerClient("SqlServer"); // Aspire resource name +builder.Services + .AddSqlServerDatabase() + .AddSqlServerUnitOfWork() + .AddSqlServerOutboxPublisher() // transactional outbox publisher + .AddDbContext() + .AddEfDb(); +``` + +## Session Context + +Call `SetSqlSessionContextAsync` at the start of the unit-of-work to stamp `Username`, `Timestamp`, `TenantId`, and `UserId` into the SQL Server session context for audit triggers and row-level security. + +```csharp +// Typically called inside the unit-of-work invoker (automatic in SqlServerUnitOfWorkInvoker) +await _db.SetSqlSessionContextAsync(executionContext).ConfigureAwait(false); +``` + +## Error Number Convention + +Stored procedures raise user error numbers 56001–56007/56010 to signal domain exceptions. + +| Error number | CoreEx exception | +|---|---| +| 56001 | `ValidationException` | +| 56002 | `BusinessException` | +| 56004 | `ConcurrencyException` | +| 56005 | `NotFoundException` | +| 56006 | `ConflictException` | +| 56007 | `DuplicateException` | + +## Outbox + +`SqlServerOutboxPublisher` writes events to the outbox table within the current `TransactionAsync` scope. `SqlServerOutboxRelayHostedService` polls and forwards to `IEventPublisher` (typically Azure Service Bus). + +```csharp +// Relay host Program.cs +builder.Services + .AddSqlServerDatabase() + .AddSqlServerUnitOfWork() + .AddSqlServerOutboxRelay(); + +builder.AddSqlServerOutboxRelayHostedService(); // called on builder, not builder.Services +``` + +## OpenTelemetry + +```csharp +builder.WithCoreExTelemetry() + .WithCoreExSqlServerTelemetry() + .UseOtlpExporter(); +``` + +## Do Not + +- Do not mix `UseExpectedPostgresOutboxPublisher` / `ExpectPostgresOutboxEvents` in tests for a SQL Server-backed domain — use the SQL Server equivalents. +- Do not call `AddSqlServerOutboxRelayHostedService()` on `builder.Services` — call it on `builder`. + +## Further Reading + +- [README](./README.md) — full API reference including `SqlServerDatabase`, session context, metrics, and TVP extensions. +- [CoreEx.Database](../CoreEx.Database/README.md) — abstract base types. +- [Infrastructure layer](../../samples/docs/infrastructure-layer.md) — SQL Server-specific repository, mapper, and outbox wiring in the Shopping sample. +- [Tooling](../../samples/docs/tooling.md) — `*.Database` project (DbEx) for SQL Server schema generation, session-context setup, and outbox infrastructure. diff --git a/src/CoreEx.Database.SqlServer/README.md b/src/CoreEx.Database.SqlServer/README.md index 2d25759a..1ed4f653 100644 --- a/src/CoreEx.Database.SqlServer/README.md +++ b/src/CoreEx.Database.SqlServer/README.md @@ -48,4 +48,8 @@ The outbox sub-namespace provides ready-to-use `SqlServerOutboxPublisher`, `SqlS ## Additional Resources - [Microsoft.Data.SqlClient](https://github.com/dotnet/SqlClient) - The ADO.NET driver this package uses. -- [sp_set_session_context](https://docs.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-set-session-context-transact-sql) - SQL Server session-context stored procedure used by `SetSqlSessionContextAsync`. \ No newline at end of file +- [sp_set_session_context](https://docs.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-set-session-context-transact-sql) - SQL Server session-context stored procedure used by `SetSqlSessionContextAsync`. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx.Database/AGENTS.md b/src/CoreEx.Database/AGENTS.md new file mode 100644 index 00000000..90d8475a --- /dev/null +++ b/src/CoreEx.Database/AGENTS.md @@ -0,0 +1,73 @@ +# CoreEx.Database — AI Usage Guide + +Provides the `IDatabase` ADO.NET abstraction, `DatabaseCommand` fluent builder, explicit column mapping, and the transactional outbox relay base infrastructure. + +## IDatabase and DatabaseCommand + +Use `DatabaseCommand` for all ADO.NET queries. Never write raw `DbCommand` code. + +```csharp +// Single row +var order = await _db.SqlStatement("SELECT * FROM orders WHERE id = @id") + .Param("@id", id) + .SelectSingleAsync(OrderMapper.Default) + .ConfigureAwait(false); + +// Collection +var orders = await _db.StoredProcedure("spGetOrders") + .SelectQueryAsync(OrderMapper.Default) + .ConfigureAwait(false); +``` + +## Explicit Mappers + +Extend `DatabaseMapper` for each entity. Hand-write column assignments — do not use reflection or AutoMapper. + +```csharp +public class OrderMapper : DatabaseMapper +{ + public static readonly OrderMapper Default = new(); + + protected override Order OnMapFromDb(DatabaseRecord r, OperationType operationType) + { + var order = new Order + { + Id = r.GetValue("order_id"), + Code = r.GetValue("code")!, + }; + MapStandardFromDb(r, order); // reads RowVersion→ETag, change-log columns + return order; + } + + protected override void OnMapToDb(Order value, DatabaseParameterCollection p, OperationType operationType) + { + p.AddParameter("@code", value.Code); + MapStandardToDb(value, p, operationType); + } +} +``` + +## Error Number Convention + +Stored procedures raise user error numbers 56001–56007/56010 to signal domain exceptions — no application-layer switch statements required. + +| Error number | CoreEx exception | +|---|---| +| 56001 | `ValidationException` | +| 56002 | `BusinessException` | +| 56004 | `ConcurrencyException` | +| 56005 | `NotFoundException` | +| 56006 | `ConflictException` | +| 56007 | `DuplicateException` | + +## Do Not + +- Do not write raw `DbCommand` or `SqlCommand` code — use `DatabaseCommand`. +- Do not use reflection-based mappers — extend `DatabaseMapper` with explicit column reads. + +## Further Reading + +- [README](./README.md) — full `IDatabase`, `DatabaseCommand`, and outbox relay API reference. +- [CoreEx.Database.SqlServer](../CoreEx.Database.SqlServer/README.md) / [CoreEx.Database.Postgres](../CoreEx.Database.Postgres/README.md) — concrete implementations. +- [Infrastructure layer](../../samples/docs/infrastructure-layer.md) — repository implementation, mapper patterns, and outbox table wiring in real sample code. +- [Tooling](../../samples/docs/tooling.md) — how `*.Database` projects (DbEx) generate outbox infrastructure, schema, and seed scripts. diff --git a/src/CoreEx.Database/README.md b/src/CoreEx.Database/README.md index a725aedb..f4392043 100644 --- a/src/CoreEx.Database/README.md +++ b/src/CoreEx.Database/README.md @@ -64,4 +64,8 @@ The outbox sub-namespace implements the [Transactional Outbox pattern](https://m - **[`CoreEx.Invokers`](../CoreEx/Invokers/README.md)** - `DatabaseInvoker` and `DatabaseOutboxRelayInvoker` extend `InvokerBase` for OpenTelemetry tracing. - **[`CoreEx.Events`](../CoreEx.Events/README.md)** - `IEventPublisher` is the outbox relay's publication target; `EventData` is what the outbox table stores. - **[`CoreEx.Database.SqlServer`](../CoreEx.Database.SqlServer/README.md)** - SQL Server-specific `Database` implementation, `SqlServerDatabaseExtensions`, and stored-procedure conventions. -- **[`CoreEx.Database.Postgres`](../CoreEx.Database.Postgres/README.md)** - PostgreSQL-specific `Database` implementation and Npgsql extensions. \ No newline at end of file +- **[`CoreEx.Database.Postgres`](../CoreEx.Database.Postgres/README.md)** - PostgreSQL-specific `Database` implementation and Npgsql extensions. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx.DomainDriven/AGENTS.md b/src/CoreEx.DomainDriven/AGENTS.md new file mode 100644 index 00000000..aa5c1ec3 --- /dev/null +++ b/src/CoreEx.DomainDriven/AGENTS.md @@ -0,0 +1,79 @@ +# CoreEx.DomainDriven — AI Usage Guide + +Provides DDD building blocks: typed entities, aggregate roots with integration-event support, persistence-state tracking, and mutation-guard helpers. + +## Entity Base + +Extend `Entity` for domain entities that require identity-based equality and mutation guards. + +```csharp +public class Order : Entity +{ + public string? Reference { get; private set; } + + // Mutations always go through Modify/Remove to advance PersistenceState + public Result SetReference(string? value) => + Modify(() => Reference = value); + + // Pre-mutation business-rule validation — called automatically before every Modify/Remove + protected override Result OnCheckCanMutate() => + IsReadOnly ? Result.InvalidError("Order cannot be changed once dispatched.") : Result.Success; +} +``` + +## Aggregate Root + +Use `Aggregate` when the entity accumulates integration events. Call `AddEvent` within mutations, and let the application service drain `Events` into the unit-of-work publisher inside `TransactionAsync`. + +```csharp +public class Basket : Aggregate +{ + public Result Checkout() + { + return Modify(() => + { + Status = BasketStatus.CheckedOut; + AddEvent(new EventData { Subject = "contoso.shopping.basket.checkedout.v1", Key = Id.ToString() }); + }); + } +} + +// Application service +await _uow.TransactionAsync(async () => +{ + var basket = await _repo.GetAsync(id, ct).ConfigureAwait(false); + basket.Checkout().ThrowOnError(); + await _repo.UpdateAsync(basket, ct).ConfigureAwait(false); + + // Drain aggregate events into the outbox + foreach (var e in basket.Events) + _uow.Events.Publish(e); + + basket.ClearEvents(); +}).ConfigureAwait(false); +``` + +## PersistenceState + +Infrastructure layers use `SetPersistenceState`, `AsNew()`, `AsNotModified()` — never `Modify()` — to hydrate an entity from the database. + +```csharp +// Infrastructure mapper +entity.AsNotModified() + .SetChangeLog(changeLog) + .SetETag(etag); +``` + +## Do Not + +- Do not call `Modify()` or `Remove()` from infrastructure or persistence code — they are for domain mutations only. +- Do not set `IsReadOnly` from outside the entity; call `MakeReadOnly()` on the entity itself. +- Do not add domain events to this package — it intentionally supports only integration events (`EventData`). + +## Further Reading + +- [README](./README.md) — full `Entity`, `Aggregate`, `PersistenceState`, and mutation-guard API reference. +- [CoreEx](../CoreEx/README.md) — `IIdentifier`, `IChangeLog`, `IETag`, `EventData`, and `Result`. +- [CoreEx.EntityFrameworkCore](../CoreEx.EntityFrameworkCore/README.md) — persists `Entity`/`Aggregate` types using `PersistenceState`. +- [Domain layer](../../samples/docs/domain-layer.md) — real-world aggregate design, mutation guards, integration-event accumulation, and `Result` pipeline usage in the Shopping sample. +- [Patterns](../../samples/docs/patterns.md) — aggregate-oriented service patterns, domain event flow, and mutation-state tracking. diff --git a/src/CoreEx.DomainDriven/README.md b/src/CoreEx.DomainDriven/README.md index 8a15f1c2..640ba3e4 100644 --- a/src/CoreEx.DomainDriven/README.md +++ b/src/CoreEx.DomainDriven/README.md @@ -35,4 +35,8 @@ The package intentionally stays minimal: it does not dictate a persistence strat - **[`CoreEx`](../CoreEx/README.md)** - Provides `IIdentifier`, `IChangeLog`, `IETag`, `CompositeKey`, `EventData`, and `Result` consumed by the DDD types. - **[`CoreEx.EntityFrameworkCore`](../CoreEx.EntityFrameworkCore/README.md)** - Persists `Entity` and `Aggregate` via `EfDbModel`; uses `PersistenceState` to determine insert/update/delete operations. -- **[`CoreEx.Validation`](../CoreEx.Validation/README.md)** - Validates entity state; `OnCheckCanMutate` can delegate to a CoreEx validator for pre-mutation rule checks. \ No newline at end of file +- **[`CoreEx.Validation`](../CoreEx.Validation/README.md)** - Validates entity state; `OnCheckCanMutate` can delegate to a CoreEx validator for pre-mutation rule checks. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/AGENTS.md b/src/CoreEx.EntityFrameworkCore/AGENTS.md new file mode 100644 index 00000000..b067c8ca --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/AGENTS.md @@ -0,0 +1,81 @@ +# CoreEx.EntityFrameworkCore — AI Usage Guide + +Wraps EF Core's `DbContext` with the CoreEx data conventions: `ETag`/concurrency checking, multi-tenancy, logical delete, change-log stamping, paging, and `QueryArgsConfig` dynamic filter/orderby. + +## Registration + +```csharp +// Program.cs +builder.Services + .AddDbContext(o => o.UseNpgsql(connectionString)) + .AddEfDb(); // registers EfDb and bridges IDatabase +``` + +## EfDb — Entry Point + +Inject `EfDb` (or your `IEfDb`) into repositories. Access typed CRUD via `Model()`. + +```csharp +[ScopedService] +public class ProductRepository(EfDb efDb) : IProductRepository +{ + private readonly EfDbModel _model = efDb.Model(); + + public Task GetAsync(Guid id, CancellationToken ct = default) => + _model.GetAsync(new EfDbArgs(OperationType.Get), id, ProductMapper.Default.MapToEntity, ct); + + public Task CreateAsync(Product product, CancellationToken ct = default) => + _model.CreateAsync(new EfDbArgs(OperationType.Create), product, ProductMapper.Default, ct); +} +``` + +## Mapped Model (Separate EF Model Type) + +Use `EfDbMappedModel` when the domain entity type differs from the EF persistence model type. + +```csharp +private readonly EfDbMappedModel _model = + efDb.Model(); +``` + +## Dynamic Query with Paging + +Use `Query(args)` for paged, filtered list endpoints. Combine with `EfDbExtensions.ToItemsResultAsync`. + +```csharp +private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() + .WithFilter(f => f.AddField(nameof(ProductModel.Status))) + .WithOrderBy(o => o.AddField(nameof(ProductModel.Name)).WithDefault("Name")); + +public Task> GetAllAsync(QueryArgs? args, PagingArgs? paging, + CancellationToken ct = default) + => _model.Query(new EfDbArgs(args ?? new QueryArgs(), _queryConfig, paging)) + .ToMappedItemsResultAsync(ProductMapper.Default.MapToEntity, ct); +``` + +## ValueConverter Bridge + +Use `ValueConverterBridge` in `OnModelCreating` to reuse CoreEx `IConverter` instances as EF value converters. + +```csharp +protected override void OnModelCreating(ModelBuilder builder) +{ + builder.Entity() + .Property(p => p.Status) + .HasConversion(new ValueConverterBridge(new StatusEnumConverter())); +} +``` + +## Do Not + +- Do not inject `DbContext` directly into application services — use the repository behind an interface. +- Do not use EF `DbContext.Add`/`Update`/`Remove` directly — use `EfDbModel` methods so CoreEx cross-cutting (ETag, change-log, logical delete) runs correctly. +- Do not use AutoMapper — use explicit `IBiDirectionMapper` implementations. + +## Further Reading + +- [README](./README.md) — full `EfDb`, `EfDbModel`, `EfDbArgs`, `EfDbOptions`, and extension-method API reference. +- [CoreEx.Data](../CoreEx.Data/README.md) — `IUnitOfWork`, `QueryArgsConfig`, and `DataResult`. +- [CoreEx.Database](../CoreEx.Database/README.md) — `IDatabase` bridged into `EfDb` for transaction sharing. +- [Infrastructure layer](../../samples/docs/infrastructure-layer.md) — EF Core repository implementation, `IBiDirectionMapper` usage, `DbContext` configuration, and dynamic query wiring in real sample code. +- [Patterns](../../samples/docs/patterns.md) — repository patterns, explicit mapping conventions, and paged query construction. diff --git a/src/CoreEx.EntityFrameworkCore/README.md b/src/CoreEx.EntityFrameworkCore/README.md index 5b03038e..a7de12a9 100644 --- a/src/CoreEx.EntityFrameworkCore/README.md +++ b/src/CoreEx.EntityFrameworkCore/README.md @@ -51,4 +51,8 @@ The central type is `EfDb`, which holds the `DbContext`, bridges its - **[`CoreEx.Data`](../CoreEx.Data/README.md)** - `IUnitOfWork`, `DataResult`, `QueryArgsConfig`; EF unit-of-work is typically composed using `EfDb` with an outbox publisher. - **[`CoreEx.Database`](../CoreEx.Database/README.md)** - `IDatabase` is bridged into `EfDb` for transaction sharing and raw SQL fallback; `IDatabaseUnitOfWork` can wrap `EfDb`. - **[`CoreEx.Mapping`](../CoreEx/Mapping/README.md)** - `IBiDirectionMapper` is the mapper contract used by `EfDbMappedModel`; `Mapper.MapStandardFrom` handles standard entity-contract properties. -- **[`CoreEx.Invokers`](../CoreEx/Invokers/README.md)** - `EfDbInvoker` extends `InvokerBase` using the standard OpenTelemetry tracing and logging pipeline.- **[`CoreEx.Invokers`](../CoreEx/Invokers/README.md)** - `EfDbInvoker` extends `InvokerBase` using the standard OpenTelemetry tracing and logging pipeline. \ No newline at end of file +- **[`CoreEx.Invokers`](../CoreEx/Invokers/README.md)** - `EfDbInvoker` extends `InvokerBase` using the standard OpenTelemetry tracing and logging pipeline. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx.Events/AGENTS.md b/src/CoreEx.Events/AGENTS.md new file mode 100644 index 00000000..8cf3f16c --- /dev/null +++ b/src/CoreEx.Events/AGENTS.md @@ -0,0 +1,89 @@ +# CoreEx.Events — AI Usage Guide + +Provides the CoreEx event publishing and subscribing infrastructure: `EventData` ↔ CloudEvents formatting, a two-phase queue-then-publish pipeline, and configurable subscriber dispatch. + +## Publishing — Two-Phase Pattern + +Buffer events on `IUnitOfWork.Events` (the `IEventQueue`) inside `TransactionAsync`, then let the outbox publisher commit them with the database transaction. Do not call `PublishAsync()` directly from application code when using the outbox pattern. + +Use `EventData.CreateEventWith` to construct a single event and `EventData.CreateEventsWith` for multiple events. Pass an `EventAction` enum value (or a plain string) as the action. + +```csharp +using CoreEx.Events; +using CoreEx.Events.Publishing; + +// Single entity event — subject/entity derived automatically from SchemaAttribute or type name +return dr.WhereMutated(v => + _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Created))); + +// Delete — no value; set the key explicitly +dr.WhereMutated(() => + _unitOfWork.Events.Add( + EventData.CreateEventWith(default, EventAction.Deleted).WithKey(id))); + +// Multiple events from a collection +_unitOfWork.Events.Add( + EventData.CreateEventsWith(items, EventAction.Updated, ConfigureEvent)); + +// Custom action string and explicit destination topic +_unitOfWork.Events.Add( + "orders-topic", + EventData.CreateEventWith(contract, "checkedout")); +``` + +`EventAction` provides the standard past-tense action values: `Created`, `Updated`, `Deleted`, `Activated`, `Deactivated`, `Cancelled`, `CheckedOut`, and more. + +## IEventPublisher Registration + +`IEventPublisher` is registered by the database outbox publisher in API/application hosts and by Azure Service Bus in relay hosts. + +```csharp +// API host — outbox is the publisher; Service Bus is wired separately with addAsDefaultIEventPublisher: false +builder.Services.AddSqlServerOutboxPublisher(); + +// Relay host — Service Bus is the publisher +builder.Services.AddAzureServiceBusPublisher(); +``` + +## IEventFormatter + +Use the default `EventFormatter`; only customise when you need non-standard CloudEvents attribute mapping. + +```csharp +builder.Services.AddEventFormatter(); // registers EventFormatter as IEventFormatter +``` + +## Subscribers + +Subscriber dispatch is handled by `SubscribedManager`. Subscriber classes are marked `[Subscribe("subject")]` and discovered automatically by `AddSubscribersUsing()`. + +See [CoreEx.Azure.Messaging.ServiceBus](../CoreEx.Azure.Messaging.ServiceBus/README.md) for the full subscriber wiring example. + +## DestinationProvider + +Implement `IDestinationProvider` to derive topic/queue names from `EventData`. Return `null` to fall back to the next registered provider. + +```csharp +public class MyDestinationProvider : IDestinationProvider +{ + public string? GetDestination(EventData @event) + => @event.Subject?.StartsWith("contoso.orders") == true + ? "orders-topic" + : null; +} +``` + +## Do Not + +- Do not call `IEventPublisher.PublishAsync()` from application-service code — buffering via `IUnitOfWork.Events` is required so events are committed atomically with the database transaction. +- Do not enqueue events outside of `TransactionAsync` — they will not be committed atomically. +- Do not create `CloudEvent` objects manually — use `EventData` and let `IEventFormatter` handle CloudEvents serialization. + +## Further Reading + +- [README](./README.md) — `EventData`, `IEventFormatter`, `SubscribedManager`, `ErrorHandler`, and `ErrorHandling` reference. +- [CoreEx.Azure.Messaging.ServiceBus](../CoreEx.Azure.Messaging.ServiceBus/README.md) — Service Bus transport binding. +- [CoreEx.Database.SqlServer](../CoreEx.Database.SqlServer/README.md) / [CoreEx.Database.Postgres](../CoreEx.Database.Postgres/README.md) — outbox publisher implementations. +- [Application layer](../../samples/docs/application-layer.md) — how events are enqueued on `IUnitOfWork.Events` inside `TransactionAsync` and drained from aggregates in real service code. +- [Infrastructure layer](../../samples/docs/infrastructure-layer.md) — outbox table wiring, relay host setup, and `IEventPublisher` registration. +- [Patterns](../../samples/docs/patterns.md) — transactional outbox pattern, event naming conventions, and subscriber error-handling strategies. diff --git a/src/CoreEx.Events/README.md b/src/CoreEx.Events/README.md index 438a17b6..5228c589 100644 --- a/src/CoreEx.Events/README.md +++ b/src/CoreEx.Events/README.md @@ -40,4 +40,8 @@ - **[`CoreEx`](../CoreEx/README.md)** - Defines `EventData`, `CloudEvent` interop, `ExecutionContext`, and `Result` used throughout the events pipeline. - **[`CoreEx.Database.Outbox`](../CoreEx.Database/Outbox/README.md)** - Outbox-pattern publisher that wraps `IEventPublisher`; persists events transactionally and relays them via a background relay host. - **[`CoreEx.DomainDriven`](../CoreEx.DomainDriven/README.md)** - `Aggregate` accumulates `EventData` internally; the application layer forwards those to the publishing queue within the same unit-of-work. -- **[`CoreEx.Invokers`](../CoreEx/Invokers/README.md)** - `EventPublisherInvoker` and `SubscribedInvoker` provide OpenTelemetry activity wrapping for publish and receive operations. \ No newline at end of file +- **[`CoreEx.Invokers`](../CoreEx/Invokers/README.md)** - `EventPublisherInvoker` and `SubscribedInvoker` provide OpenTelemetry activity wrapping for publish and receive operations. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx.RefData/AGENTS.md b/src/CoreEx.RefData/AGENTS.md new file mode 100644 index 00000000..d061093c --- /dev/null +++ b/src/CoreEx.RefData/AGENTS.md @@ -0,0 +1,118 @@ +# CoreEx.RefData — AI Usage Guide + +Provides the reference data (lookup table) framework: typed base classes, thread-safe collections, a hybrid-cache-backed orchestrator, and code-serialization support. + +## Preferred Approach — Code Generation + +The canonical way to introduce reference data is through the **`*.CodeGen` project** (`ref-data.yaml` + `CoreEx.CodeGen`). This is the deterministic, preferred pattern used across all sample domains. Code generation produces: + +| Generated output | Description | +|---|---| +| `*.g.cs` — `ReferenceData` partial class | The typed reference data contract | +| `*.g.cs` — `ReferenceDataCollection` class | Thread-safe, cache-friendly collection | +| `*.g.cs` — `IReferenceDataRepository` | Repository interface for loading from the database | +| `*.g.cs` — `ReferenceDataRepository` | EF Core implementation of the repository | +| `*.g.cs` — `IReferenceDataProvider` / `ReferenceDataService` | Orchestrator provider wiring all types together | +| `*.g.cs` — `ReferenceDataController` | API controller exposing all reference data types | + +### `ref-data.yaml` — the single source of truth + +Declare every reference data type in `ref-data.yaml` inside the `*.CodeGen` project: + +```yaml +collectionSortOrder: Code +repository: EntityFramework +entities: +- name: MovementStatus +- name: UnitOfMeasure + plural: UnitsOfMeasure + properties: + - name: Scale + type: int +``` + +Run the `*.CodeGen` project to regenerate all `*.g.cs` outputs. Never edit generated files directly. + +### Hand-authored partial — `const string` code values + +After generation, add a hand-authored `partial class` alongside the generated one to declare the known code values as `const string` fields: + +```csharp +// MovementStatus.cs — hand-authored alongside MovementStatus.g.cs +public partial class MovementStatus +{ + public const string Pending = "P"; + public const string Confirmed = "C"; + public const string Canceled = "X"; +} +``` + +Use these constants directly in business logic and validators — no runtime lookup required: + +```csharp +if (movement.Status == MovementStatus.Pending) { ... } +``` + +### Registration + +Register the generated `IReferenceDataProvider` with the orchestrator, then dynamically register all generated services and repositories: + +```csharp +// Program.cs +builder.Services + .AddReferenceDataOrchestrator() // generated IReferenceDataProvider + ... + +// Dynamic registration discovers ReferenceDataService and ReferenceDataRepository via [ScopedService] +builder.Services.AddDynamicServicesUsing(); +``` + +The orchestrator resolves `IHybridCache` from DI to cache loaded collections. Register FusionCache separately — see [CoreEx.Caching.FusionCache](../CoreEx.Caching.FusionCache/README.md). + +--- + +## ReferenceDataCodeCollection — Wire Serialization + +For properties that serialise as a list of string codes on the wire but expose typed reference data objects in code: + +```csharp +public class Order : IIdentifier +{ + // Serialized as ["E","A"] on the wire; exposes ICollection in code + public ReferenceDataCodeCollection AllowedStatuses { get; set; } = []; +} +``` + +## Date Validity + +Reference data items with `StartsOn`/`EndsOn` control `IsValid` at runtime. The validation date defaults to `Runtime.UtcNow` but can be overridden per-request by injecting `IReferenceDataContext` (registered as a scoped service) and setting its `Date` property. + +```csharp +// Override the validation date for the current request scope +public class MyService(IReferenceDataContext refDataContext) +{ + public void SetValidationDate(DateTimeOffset date) + => refDataContext.Date = date; + + // Override for a specific type only + public void SetValidationDateForType(DateTimeOffset date) + => refDataContext[typeof(DiscountCoupon)] = date; +} +``` + +## Do Not + +- Do not hand-write the `ReferenceData` class, collection, repository, service, or controller — use `ref-data.yaml` and the `*.CodeGen` project to generate them. +- Do not edit `*.g.cs` files — they are overwritten on every generation run. +- Do not load reference data collections on every request — the orchestrator caches via `IHybridCache`; load functions are called only on a cache miss. +- Do not access reference data in `static` constructors — the orchestrator must be resolved from DI at runtime. +- Do not use `ReferenceDataCodeCollection` for single-value fields — use a plain `Code` string property on the contract instead. + +## Further Reading + +- [README](./README.md) — full `ReferenceData`, `ReferenceDataCollection`, `ReferenceDataHybridCache`, and `ReferenceDataOrchestrator` API reference. +- [CoreEx.Caching.FusionCache](../CoreEx.Caching.FusionCache/README.md) — recommended `IHybridCache` implementation for `ReferenceDataHybridCache`. +- [CoreEx](../CoreEx/README.md) — defines `IReferenceData`, `IReferenceDataCollection`, and `ReferenceDataOrchestrator`. +- [Contracts layer](../../samples/docs/contracts-layer.md) — how reference-data contracts are declared with `[ReferenceData]` and consumed via code properties in entity contracts. +- [Infrastructure layer](../../samples/docs/infrastructure-layer.md) — reference-data repository implementation and cache registration in real sample code. +- [Tooling](../../samples/docs/tooling.md) — how `ref-data.yaml` and `*.CodeGen` drive generation of the full reference-data controller/service/repository layer. diff --git a/src/CoreEx.RefData/README.md b/src/CoreEx.RefData/README.md index e28c529b..12a1d448 100644 --- a/src/CoreEx.RefData/README.md +++ b/src/CoreEx.RefData/README.md @@ -43,4 +43,8 @@ Two concrete base classes cover the most common identity types: `ReferenceData` covers events, outbox, Service Bus, caching, validation, HTTP, and all assertion helpers. + +## Project Reference + +```xml + +``` + +No additional CoreEx test packages are needed — this package covers everything. + +## Test Class Shape (NUnit) + +```csharp +[TestFixture] +public class OrderServiceTest : UnitTestBase +{ + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + // seed your test database here + } + + [Test] + public void Create_Order_Published_To_Outbox() + => Test.ScopedType() + .ExpectChangeLogCreated() + .ExpectIdentifier() + .ExpectSqlServerOutboxEvents(new CloudEvent { ... }) + .Run(s => s.CreateAsync(new Order { ... })); +} +``` + +## Event / Outbox Expectations + +Use the database-specific expectation method that matches your domain's persistence provider. Do not mix SQL Server and PostgreSQL helpers. + +```csharp +// SQL Server outbox +.ExpectSqlServerOutboxEvents(new CloudEvent { Subject = "contoso.orders.order.created.v1", ... }) +.ExpectNoSqlServerOutboxEvents() + +// PostgreSQL outbox +.ExpectPostgresOutboxEvents(new CloudEvent { Subject = "contoso.orders.order.created.v1", ... }) +.ExpectNoPostgresOutboxEvents() + +// Azure Service Bus direct publisher (no outbox) +.ExpectAzureServiceBusEvents(new CloudEvent { ... }) +.ExpectNoAzureServiceBusEvents() +``` + +## Validation Assertions + +```csharp +// Assert validator passes +await ProductValidator.Default.AssertSuccess(new Product { Sku = "SKU001" }); + +// Assert validator fails with specific field errors +await ProductValidator.Default.AssertErrors( + new Product { Sku = "" }, + ("Sku", "Sku is required.")); +``` + +## Subscribe / Relay Host Tests + +```csharp +// Subscribe host +[TestFixture] +public class OrderSubscriberTest : UnitTestBase +{ + [Test] + public void Receive_OrderCreated() + => Test.Type() + .ExpectNoSqlServerOutboxEvents() + .Run(s => s.ReceiveAsync(CreateCloudEvent("contoso.orders.order.created.v1", order))); +} +``` + +## JSON Seed Data + +```csharp +// Load seed data from embedded YAML with token substitution +var data = await JsonDataReader.ParseYamlAsync("Resources/data.yaml"); +await db.SeedAsync(data); +``` + +## ExecutionContext Scoping + +```csharp +Test.ScopedType() + .WithUser("test@contoso.com") + .Run(s => s.GetAsync(id)); +``` + +## Do Not + +- Do not add separate per-feature test packages (e.g. `CoreEx.UnitTesting.Events`) — they do not exist; all test helpers are in this package. +- Do not use `ExpectSqlServerOutboxEvents` for a PostgreSQL domain or vice versa. +- Do not call `PublishAsync()` in tests — the `EventPublisherDecorator` (registered by `UseExpectedEventPublisher`) captures events automatically. +- Do not forget `await Test.ClearFusionCacheAsync()` in `[OneTimeSetUp]` for tests involving cached reference data. +- Do not use FluentAssertions — the CoreEx test framework uses AwesomeAssertions (`AwesomeAssertions` NuGet package). + +## Further Reading + +- [README](./README.md) — full expectations, outbox helpers, `JsonDataReader`, and `UnitTestExExtensions` API reference. +- [UnitTestEx](https://github.com/Avanade/UnitTestEx) — the underlying test-host framework. +- [AwesomeAssertions](https://github.com/AwesomeAssertions/AwesomeAssertions) — fluent assertion library used internally. +- [Testing](../../samples/docs/testing.md) — comprehensive real-world guide covering unit, integration, API, Subscribe, and Relay test patterns with concrete examples from the sample solution. +- [Patterns](../../samples/docs/patterns.md) — test-specific patterns including outbox assertion, mock HTTP client, inter-domain mock strategy, and `FusionCache` reset. diff --git a/src/CoreEx.UnitTesting/README.md b/src/CoreEx.UnitTesting/README.md index 874bc39d..d3e0ba5f 100644 --- a/src/CoreEx.UnitTesting/README.md +++ b/src/CoreEx.UnitTesting/README.md @@ -59,4 +59,8 @@ A companion `Data` child namespace provides `JsonDataReader` — a JSON-to-`Json - [UnitTestEx](https://github.com/Avanade/UnitTestEx) - The underlying test-host framework that `CoreEx.UnitTesting` extends; provides `TesterBase`, `ApiTester`, `GenericTester`, `IExpectations`, and the framework-agnostic assertion infrastructure. - [AwesomeAssertions](https://github.com/AwesomeAssertions/AwesomeAssertions) - Fluent assertion library used by the validation shortcuts and internal assertion helpers. -- [YamlDotNet](https://github.com/aaubry/YamlDotNet) - Used internally to parse `data.yaml` seed files before they are handed to `JsonDataReader`. \ No newline at end of file +- [YamlDotNet](https://github.com/aaubry/YamlDotNet) - Used internally to parse `data.yaml` seed files before they are handed to `JsonDataReader`. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx.Validation/AGENTS.md b/src/CoreEx.Validation/AGENTS.md new file mode 100644 index 00000000..e3544252 --- /dev/null +++ b/src/CoreEx.Validation/AGENTS.md @@ -0,0 +1,98 @@ +# CoreEx.Validation — AI Usage Guide + +Fluent, property-centric validation framework tightly integrated with `ExecutionContext`, `LText` localisation, and the CoreEx exception model. + +## Define a Validator + +Extend `Validator` and declare property chains in the constructor. Assign a `Default` singleton to avoid repeated instantiation. + +```csharp +public class ProductValidator : Validator +{ + public static readonly ProductValidator Default = new(); + + public ProductValidator() + { + Property(p => p.Sku) + .Mandatory() + .String(maxLength: 20); + + Property(p => p.Name) + .Mandatory() + .String(maxLength: 100); + + Property(p => p.Price) + .Mandatory() + .CompareValue(CompareOperator.GreaterThan, 0m); + } +} +``` + +## Call from a Service + +Always call `ValidateAndThrowAsync` before mutating state. This produces a `ValidationException` with all field-level errors at once. + +```csharp +public async Task CreateAsync(Product product, CancellationToken ct = default) +{ + await ProductValidator.Default.ValidateAndThrowAsync(product, ct).ConfigureAwait(false); + + return await _uow.TransactionAsync(async () => + { + // ... persist and publish + }).ConfigureAwait(false); +} +``` + +## Common Rules Reference + +```csharp +Property(p => p.Email).Mandatory().Email(); +Property(p => p.Status).Mandatory().Enum(); // validates enum is defined +Property(p => p.Quantity).Mandatory().Numeric(allowNegatives: false); +Property(p => p.Description).String(maxLength: 500); +Property(p => p.Tags).Collection(maxCount: 10); +Property(p => p.Address).Entity(AddressValidator.Default); // child entity +``` + +## Conditional Rules + +Use `When`/`WhenHasValue`/`DependsOn` to guard rules — never write branching `if` statements in a validator. + +```csharp +Property(p => p.DiscountCode) + .Mandatory() + .When(() => product.HasDiscount); + +Property(p => p.ExpiresOn) + .CompareValue(CompareOperator.GreaterThan, DateTimeOffset.UtcNow) + .WhenHasValue(); +``` + +## Reusable Validators + +Use `CommonValidator` for logic reused across multiple entity validators. + +```csharp +public static readonly CommonValidator SkuValidator = Validator.CreateCommon(v => + v.String(minLength: 3, maxLength: 20)); + +// Usage +HasRuleFor(p => p.Sku).Common(SkuValidator); +``` + +## Do Not + +- Do not call `ValidateAsync` and ignore errors — always call `ValidateAndThrowAsync` or check `HasErrors` and throw `ValidationException` explicitly. +- Do not add try/catch around `ValidateAndThrowAsync` to rethrow as a different exception type — `ValidationException` maps to HTTP 400 automatically. +- Do not validate inside `TransactionAsync` — validate first (before the transaction) so failed validation never opens a database transaction. +- Do not use `DataAnnotations` attributes alongside CoreEx validators — pick one approach per entity and stay consistent. +- Do not use FluentValidation alongside CoreEx validators unless bridging with `InteropRule` is explicitly needed. + +## Further Reading + +- [README](./README.md) — full rule catalogue, clause types, `CommonValidator`, `RuleSet`, and `ValidationExtensions` API reference. +- [CoreEx](../CoreEx/README.md) — `ValidationException`, `MessageItemCollection`, and `LText` localisation. +- [CoreEx.AspNetCore](../CoreEx.AspNetCore/README.md) — `ValidationException` is translated to `400 application/problem+json` with field-level errors by `WebApi`. +- [Application layer](../../samples/docs/application-layer.md) — where and how validators are called in application services, including the validate-before-transaction pattern. +- [Patterns](../../samples/docs/patterns.md) — validation patterns, conditional rules, reusable `CommonValidator` usage, and the relationship between validators and policies. diff --git a/src/CoreEx.Validation/README.md b/src/CoreEx.Validation/README.md index 3260b731..7f8562db 100644 --- a/src/CoreEx.Validation/README.md +++ b/src/CoreEx.Validation/README.md @@ -97,4 +97,8 @@ Rules apply the actual validation logic to a property value. Each rule class is - **[`CoreEx`](../CoreEx/README.md)** - Defines `ValidationException`, `MessageItem`, `IValidationResult`, `LText`, `ExecutionContext`, and `Wildcard` consumed throughout. - **[`CoreEx.RefData`](../CoreEx.RefData/README.md)** - Provides `IReferenceData` and `IReferenceDataCodeCollection` validated by `ReferenceDataRule`, `ReferenceDataCodeRule`, and `ReferenceDataCodeCollectionRule`. -- **[`CoreEx.AspNetCore`](../CoreEx.AspNetCore/README.md)** - Converts `ValidationException` raised by `ValidateAndThrowAsync` into HTTP 400 responses with a structured error body. \ No newline at end of file +- **[`CoreEx.AspNetCore`](../CoreEx.AspNetCore/README.md)** - Converts `ValidationException` raised by `ValidateAndThrowAsync` into HTTP 400 responses with a structured error body. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. \ No newline at end of file diff --git a/src/CoreEx/AGENTS.md b/src/CoreEx/AGENTS.md new file mode 100644 index 00000000..9728fca7 --- /dev/null +++ b/src/CoreEx/AGENTS.md @@ -0,0 +1,125 @@ +# CoreEx — AI Usage Guide + +`CoreEx` is the shared kernel of the CoreEx framework. All other CoreEx packages depend on it. + +## Semantic Exceptions + +Throw the most specific exception; the framework maps each to its HTTP status automatically. + +```csharp +// 400 — field-level validation errors +throw new ValidationException(new MessageItemCollection { new("sku", "Sku is required.") }); + +// 404 — resource not found +throw new NotFoundException(); + +// 400 — business rule violation safe to surface to the caller +throw new BusinessException("Basket is already checked out."); + +// 412 — optimistic concurrency conflict +throw new ConcurrencyException(); + +// 409 — duplicate record +throw new DuplicateException(); +``` + +## ExecutionContext + +`ExecutionContext` is `AsyncLocal`-scoped and carries user identity, tenant ID, timestamp, and operation type for the lifetime of a request. Access it via DI injection, or use the static accessors when outside the DI graph. + +```csharp +// Preferred — inject via DI (scoped per request) +public class MyService(ExecutionContext context) { } + +// Static access — returns the ambient instance for the current async context +var ctx = ExecutionContext.Current; // throws if none set +var ctx = ExecutionContext.TryGetCurrent(); // returns null if none set +``` + +## Result / Result\ + +Use `Result` for expected business-error paths instead of throwing exceptions. Compose pipelines with `Then`, `ThenAs`, `GoAsync`, `ThenAsAsync`. + +```csharp +public async Task> GetOrderAsync(Guid id) +{ + var order = await _repo.GetAsync(id).ConfigureAwait(false); + return order is null ? Result.NotFoundError() : Result.Ok(order); +} + +// Pipeline composition +return await GetOrderAsync(id) + .ThenAsAsync(order => _validator.ValidateAndThrowAsync(order)) + .ConfigureAwait(false); +``` + +## Entity Contracts + +Use the standard interfaces on your contracts so the framework's ETag, paging, and change-log handling works automatically. + +For entities authored in a project that references `CoreEx.CodeGen`, prefer the Roslyn source generator — declare a `partial` class with `[Contract]` and the generator emits the boilerplate (constructors, `CopyFrom`, `Clone`, equality, `CleanUp`, etc.). + +```csharp +// Source-generated contract — preferred when CoreEx.CodeGen is referenced +[Contract] +public partial class Product : IIdentifier, IETag, IChangeLog +{ + public Guid Id { get; set; } + public string? ETag { get; set; } + public ChangeLog? ChangeLog { get; set; } + public string? Name { get; set; } +} + +// Reference-data contract — generator emits the full ReferenceData shape +[ReferenceData] +public partial class Status { } +``` + +When `CoreEx.CodeGen` is not available, implement the interfaces directly: + +```csharp +public class Product : IIdentifier, IETag, IChangeLog +{ + public Guid Id { get; set; } + public string? ETag { get; set; } + public ChangeLog? ChangeLog { get; set; } +} +``` + +Server-managed fields (e.g. `ETag`, `ChangeLog`) should be marked `[ReadOnly(true)]` on generated contracts so they are excluded from inbound deserialization. + +## Dependency Injection Attributes + +Mark implementation classes with `[ScopedService]`, `[SingletonService]`, or `[TransientService]` and register them all at once via `AddDynamicServicesUsing()`. + +```csharp +[ScopedService] +public class ProductService : IProductService { } + +// Program.cs +builder.Services.AddDynamicServicesUsing(); +``` + +## PrecisionTimeProvider + +Register in `Program.cs` to ensure timestamps are truncated to database-compatible precision. + +```csharp +builder.Services.AddPrecisionTimeProvider(); +``` + +## Do Not + +- Do not catch `IExtendedException` types to re-wrap them — let the framework middleware translate them. +- Do not use `AutoMapper` — use explicit `Mapper` or `BiDirectionMapper`. +- Do not use `DateTime.UtcNow` directly — use `Runtime.UtcNow`, which returns `ExecutionContext.Timestamp` when a context is active and falls back to `TimeProvider.System.GetUtcNow()` otherwise. + +## Further Reading + +- [README](./README.md) — full API surface and namespace index. +- [CoreEx.AspNetCore](../CoreEx.AspNetCore/README.md) — HTTP translation of these exceptions and Result types. +- [CoreEx.Validation](../CoreEx.Validation/README.md) — ValidationException production. +- [Contracts layer](../../samples/docs/contracts-layer.md) — how entity contracts, `[Contract]`, `[ReferenceData]`, and standard interfaces are used in practice. +- [Application layer](../../samples/docs/application-layer.md) — real-world usage of `Result`, semantic exceptions, and `ExecutionContext` in application services. +- [Patterns](../../samples/docs/patterns.md) — pattern catalogue covering error handling, railway-oriented flows, and cross-cutting concerns. +- [Layers overview](../../samples/docs/layers.md) — full layer dependency diagram and design-time tooling overview. diff --git a/src/CoreEx/README.md b/src/CoreEx/README.md index f5e9a0ca..06241a6e 100644 --- a/src/CoreEx/README.md +++ b/src/CoreEx/README.md @@ -41,21 +41,66 @@ All other CoreEx packages (`CoreEx.Events`, `CoreEx.Database`, `CoreEx.AspNetCor | **[`ExecutionContext`](./ExecutionContext.cs)** | `AsyncLocal`-scoped ambient context carrying user identity, tenant ID, operation type, timestamp, and attributes for the lifetime of a request. | | **[`Result`](./Results/Result.cs)** | Value type representing a successful or failed operation with no return value; the basis of railway-oriented programming in CoreEx. | | **[`Result`](./Results/ResultT.cs)** | Value type representing a successful operation with a value, or a typed error — used for functional pipeline-style error propagation. | -| **[`ValidationException`](./ValidationException.cs)** | Exception for known data validation errors (HTTP 400); carries a `MessageItemCollection` of field-level messages. | -| **[`NotFoundException`](./NotFoundException.cs)** | Exception for missing resources (HTTP 404). | -| **[`BusinessException`](./BusinessException.cs)** | Exception for business rule violations that are safe to surface to the consumer (HTTP 422). | -| **[`ConcurrencyException`](./ConcurrencyException.cs)** | Exception for optimistic concurrency conflicts (HTTP 412). | -| **[`AuthorizationException`](./AuthorizationException.cs)** | Exception for authorization failures (HTTP 403). | -| **[`AuthenticationException`](./AuthenticationException.cs)** | Exception for authentication failures (HTTP 401). | -| **[`ConflictException`](./ConflictException.cs)** | Exception for resource state conflicts (HTTP 409). | -| **[`DuplicateException`](./DuplicateException.cs)** | Exception for duplicate-record violations (HTTP 409). | -| **[`TransientException`](./TransientException.cs)** | Exception signalling a transient, retryable error with a configurable retry-after interval. | | **[`DataConsistencyException`](./DataConsistencyException.cs)** | Non-error exception used to signal a potential data consistency issue without treating it as an application error. | | **[`PrecisionTimeProvider`](./PrecisionTimeProvider.cs)** | `TimeProvider` implementation that truncates timestamps to a configurable fractional-second precision for database compatibility. | | **[`OperationType`](./OperationType.cs)** | Enum representing the CRUD operation type (Get, Create, Update, Delete, Query) carried on the `ExecutionContext`. | | _[`ExtendedException`](./Abstractions/ExtendedException.cs)_ | Abstract base for all CoreEx semantic exceptions; provides HTTP status, error type, error code, detail, and configurable logging. | | [`IExtendedException`](./Abstractions/IExtendedException.cs) | Interface defining the extended exception contract: `StatusCode`, `ErrorType`, `ErrorCode`, `IsError`, and `ShouldBeLogged`. | +See the [Extended Exceptions](#extended-exceptions) section below for the full list of semantic exception types. + +## Errors vs Exceptions + +`CoreEx` distinguishes between expected and unexpected errors: + +| Aspect | **Errors** (Expected) | **Exceptions** (Unexpected) | +|--------|------------------------|----------------------------| +| **Use For** | Validation failures, business rules, "not found" | System failures, infrastructure errors | +| **Types** | `CoreEx` semantic exceptions (implement [`IExtendedException`](./Abstractions/IExtendedException.cs)) | Standard .NET, and non-semantic, exceptions | +| **Benefits** | Explicit error handling | Rich diagnostics (stack traces) | +| **Example** | Customer not found | Database connection timeout | + +## Extended Exceptions + +Semantic (error-oriented) exception types with automatic HTTP status mapping: + +| Exception | Description | HTTP Status | Error Type | +|-----------|-------------|-------------|------------| +| [`AuthenticationException`](./AuthenticationException.cs) | User not authenticated. | 401-Unauthorized | `AuthenticationError` | +| [`AuthorizationException`](./AuthorizationException.cs) | User lacks permissions. | 403-Forbidden | `AuthorizationError` | +| [`BusinessException`](./BusinessException.cs) | Business rule violation (message shown to consumer). | 400-Bad Request | `BusinessError` | +| [`ConcurrencyException`](./ConcurrencyException.cs) | Data concurrency conflict (ETag mismatch). | 412-Precondition Failed | `ConcurrencyError` | +| [`ConflictException`](./ConflictException.cs) | Data conflict (e.g., identifier already exists on create). | 409-Conflict | `ConflictError` | +| [`DuplicateException`](./DuplicateException.cs) | Duplicate value (e.g., unique code already in use). | 409-Conflict | `DuplicateError` | +| [`NotFoundException`](./NotFoundException.cs) | Entity not found. | 404-Not Found | `NotFoundError` | +| [`TransientException`](./TransientException.cs) | Transient failure (retry candidate). | 503-Service Unavailable | `TransientError` | +| [`ValidationException`](./ValidationException.cs) | Validation failure with message collection. | 400-Bad Request | `ValidationError` | + +All inherit from [`ExtendedException`](./Abstractions/ExtendedExceptionT.cs) implementing [`IExtendedException`](./Abstractions/IExtendedException.cs). + +Additionally, these support `With*`-style methods to add additional context that is added to the resulting `ProblemDetails`: + +``` csharp +throw new BusinessException($"Product '{movement.ProductId}' does not have sufficient quantity on hand.") + .WithErrorCode("insufficient-quantity") + .WithKey(movement.ProductId); +``` + +The above would result in the following `ProblemDetails`: + +``` json +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "Product \u002700000001-0000-0000-0000-000000000000\u0027 does not have sufficient quantity on hand.", + "status": 400, + "traceId": "00-a8e8623ef74c2b53820d0ff5d799850d-df28b0bc787429f5-01", + "errorType": "business", + "errorCode": "insufficient-quantity", + "key": "00000001-0000-0000-0000-000000000000" +} +``` + + ## Namespaces | Namespace | Description | Documentation | @@ -90,3 +135,7 @@ All other CoreEx packages (`CoreEx.Events`, `CoreEx.Database`, `CoreEx.AspNetCor - **[`CoreEx.EntityFrameworkCore`](../CoreEx.EntityFrameworkCore/README.md)** - Entity Framework Core integration consuming the entity contracts and mapping types from this package. - **[`CoreEx.RefData`](../CoreEx.RefData/README.md)** - Full reference data implementation extending the `IReferenceData` and `ReferenceDataOrchestrator` types defined here. - **[`CoreEx.UnitTesting`](../CoreEx.UnitTesting/README.md)** - Test helpers and fluent assertion extensions targeting the types and patterns from this package. _(test only)_ + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e074c1fd..874001de 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -59,10 +59,20 @@ PackagePath="/" Visible="false" /> - + + Visible="false" + Condition="Exists('README.md')" /> + + + + From 40f750b0dc799cfe567d22e50bd6b119761f8962 Mon Sep 17 00:00:00 2001 From: "Eric Sibly [chullybun]" Date: Wed, 27 May 2026 10:19:41 -0700 Subject: [PATCH 02/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Eric Sibly [chullybun] --- .github/instructions/tests.instructions.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md index b07a3434..70107ec6 100644 --- a/.github/instructions/tests.instructions.md +++ b/.github/instructions/tests.instructions.md @@ -10,11 +10,7 @@ tags: ["testing", "unit-tests", "integration-tests", "test-helpers", "nunit"] | Package | Key types provided | |---|---| -| `CoreEx.UnitTesting` | `WithApiTester`, `WithGenericTester`, `Test.Http()`, `Test.Http()`, `Test.Scoped()`, `Test.ScopedType()`, `Test.ClearFusionCacheAsync()`, `Test.ReplaceHttpClientFactory()` | -| `CoreEx.UnitTesting.Database.SqlServer` | `Test.MigrateSqlServerDataAsync()`, `Test.UseExpectedSqlServerOutboxPublisher()`, `.ExpectSqlServerOutboxEvents()`, `.ExpectNoSqlServerOutboxEvents()` | -| `CoreEx.UnitTesting.Database.Postgres` | `Test.MigratePostgresDataAsync()`, `Test.UseExpectedPostgresOutboxPublisher()`, `.ExpectPostgresOutboxEvents()`, `.ExpectNoPostgresOutboxEvents()` | -| `CoreEx.UnitTesting.Azure.ServiceBus` | `Test.UseExpectedAzureServiceBusPublisher()`, `Test.GetAndClearAzureServiceBusAsync()` | -| `CoreEx.UnitTesting.AspNetCore` | `.ExpectIdentifier()`, `.ExpectETag()`, `.ExpectChangeLogCreated()`, `.ExpectJsonFromResource()`, `.AssertCreated()`, `.AssertOK()`, `.AssertBadRequest()`, `.AssertErrors()`, `.AssertJsonFromResource()`, `.AssertLocationHeader()` | +| `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/extensions: `.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()` | From 20f3f069a1665c87b4a3112d5b8470f89c6514ec Mon Sep 17 00:00:00 2001 From: "Eric Sibly [chullybun]" Date: Wed, 27 May 2026 10:21:03 -0700 Subject: [PATCH 03/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Eric Sibly [chullybun] --- .github/instructions/tooling.instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/instructions/tooling.instructions.md b/.github/instructions/tooling.instructions.md index 80491fef..674dc394 100644 --- a/.github/instructions/tooling.instructions.md +++ b/.github/instructions/tooling.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: "**/*.Database/**,**/*.CodeGen/**" +applyTo: "**/*.CodeGen/Program.cs;**/*.CodeGen/ref-data.yaml;**/*.Database/Program.cs;**/*.Database/dbex.yaml;**/*.Database/ref-data.yaml;**/*.Database/Migrations/**;**/*.Database/Schema/**" 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"] --- From 5c43833ad492fc527b5d05665aeb862c56899dba Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 27 May 2026 10:52:54 -0700 Subject: [PATCH 04/17] Updates based on review. --- .../event-subscribers.instructions.md | 13 +-- samples/docs/domain-layer.md | 24 ++++++ samples/docs/patterns.md | 1 + src/CoreEx.DomainDriven/AGENTS.md | 85 +++++++++++-------- src/CoreEx.DomainDriven/README.md | 10 +++ src/CoreEx.Validation/AGENTS.md | 4 +- 6 files changed, 95 insertions(+), 42 deletions(-) diff --git a/.github/instructions/event-subscribers.instructions.md b/.github/instructions/event-subscribers.instructions.md index d8933be4..ee9e0309 100644 --- a/.github/instructions/event-subscribers.instructions.md +++ b/.github/instructions/event-subscribers.instructions.md @@ -32,17 +32,20 @@ Use when the relevant data is carried in the message key, not the payload: ```csharp [ScopedService, Subscribe("contoso.products.reservation.confirm")] -public class ReservationConfirmSubscriber(IMovementService service) : SubscribedBase +public class ReservationConfirmSubscriber : SubscribedBase { - private readonly IMovementService _service = service.ThrowIfNull(); - internal static readonly ErrorHandler DefaultErrorHandler = new ErrorHandler() .Add(ex => ex.ErrorCode == "pending-reservation-not-found" ? ErrorHandling.CompleteAsInformation : null); - public ReservationConfirmSubscriber(IMovementService service) : this(service) - => ErrorHandler = DefaultErrorHandler; + private readonly IMovementService _service; + + public ReservationConfirmSubscriber(IMovementService service) + { + _service = service.ThrowIfNull(); + ErrorHandler = DefaultErrorHandler; + } protected async override Task OnReceiveAsync( EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default) diff --git a/samples/docs/domain-layer.md b/samples/docs/domain-layer.md index a7212307..ef266713 100644 --- a/samples/docs/domain-layer.md +++ b/samples/docs/domain-layer.md @@ -62,3 +62,27 @@ public sealed record class ItemPricing Value objects enforce their own invariants in property initialisers using CoreEx guard extensions (`ThrowIfInactive`, `ThrowIfLessThanZero`) so that invalid instances simply cannot exist. > **See also**: [Value Object pattern](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/implement-value-objects) + +--- + +## Domain Events — Intentionally Not Supported + +CoreEx deliberately does not provide a native domain-event mechanism (e.g. MediatR `INotification` dispatch or an in-process event bus). This is a conscious architectural decision: + +- **Chatty emission** — fine-grained domain events (`PropertyChanged`, `ItemAdded`, etc.) generate high volumes of events that produce implicit, hard-to-trace side-effects throughout the application layer. +- **Non-explicit side-effects** — handler chains driven by in-process events obscure control flow, making it difficult to reason about what a single aggregate mutation causes. +- **Integration events are sufficient** — coarse-grained integration events are added to `IUnitOfWork.Events` by the application service after a successful repository operation, committed atomically via the transactional outbox, and consumed by other systems explicitly and auditably. + +```csharp +// Events are added by the application SERVICE after the repository operation — not by the aggregate itself +return ur.ThenAs(basket => +{ + var contract = BasketMapper.Map(basket); + _unitOfWork.Events.Add(EventData.CreateEventWith(contract, EventAction.CheckedOut)); + return contract; +}); +``` + +A developer can opt in to a domain-event mechanism if genuinely needed — for example, dispatching via MediatR after the transaction commits — but this is an explicit extension, not a framework default. + +> **See also**: [`IAggregateRoot`](../../src/CoreEx.DomainDriven/IAggregateRoot.cs) · [`CoreEx.DomainDriven` README](../../src/CoreEx.DomainDriven/README.md#domain-events--intentionally-not-supported) diff --git a/samples/docs/patterns.md b/samples/docs/patterns.md index 35326796..7f2bc0e2 100644 --- a/samples/docs/patterns.md +++ b/samples/docs/patterns.md @@ -38,6 +38,7 @@ The samples are built on two overarching architectural styles — **Domain-based | 🏛️ **DDD** | 🧱 | **Aggregate** | A cluster of related entities treated as a single consistency boundary, with all mutations enforced through a root that protects invariants and tracks persistence state. | [Domain](domain-layer.md#aggregates) | | | 🔹 | **Entity** | A domain object with a distinct, stable identity that persists across state changes, owned and tracked within an aggregate boundary. | [Domain](domain-layer.md#aggregates) | | | 💎 | **Value Object** | An immutable, identity-free concept defined entirely by its values, enforcing its own invariants at construction and compared by value rather than by reference. | [Domain](domain-layer.md#value-objects) | +| | 🚫 | **Domain Events — Not Native** | CoreEx intentionally does not provide an in-process domain-event bus (e.g. MediatR). Coarse-grained **integration events** added to `IUnitOfWork.Events` and committed via the transactional outbox are the preferred pattern; fine-grained in-process dispatch can be added as an explicit opt-in extension. | [Domain](domain-layer.md#domain-events--intentionally-not-supported) | | 🗄️ **Infrastructure** | 🔀 | **Adapter** | An anti-corruption layer that wraps one or more external dependencies (HTTP clients, event publishers, local stores) behind a domain-idiomatic interface, decoupling the application from remote schemas, transports, and versioning concerns. | [Application](application-layer.md#adapters-anti-corruption-layer) · [Infrastructure](infrastructure-layer.md#external-clients-and-adapter-implementations) · [↗ Azure](https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer) | | | 🔌 | **HTTP Client** | A strongly-typed wrapper around a single outbound HTTP dependency handling serialization, response mapping, and error translation in one focused, independently testable class. | [Infrastructure](infrastructure-layer.md#external-clients-and-adapter-implementations) | | | 🗺️ | **Mapper** | An explicit, uni/bidirectional translation class between two representations of the same concept — such as a domain contract and a persistence model — with no convention magic or reflection overhead. | [Infrastructure](infrastructure-layer.md#mapping) | diff --git a/src/CoreEx.DomainDriven/AGENTS.md b/src/CoreEx.DomainDriven/AGENTS.md index aa5c1ec3..182674fc 100644 --- a/src/CoreEx.DomainDriven/AGENTS.md +++ b/src/CoreEx.DomainDriven/AGENTS.md @@ -1,64 +1,74 @@ # CoreEx.DomainDriven — AI Usage Guide -Provides DDD building blocks: typed entities, aggregate roots with integration-event support, persistence-state tracking, and mutation-guard helpers. +Provides DDD building blocks: aggregate roots (with integration events), typed entities, persistence-state tracking, and mutation-guard helpers. -## Entity Base +## Aggregate Root -Extend `Entity` for domain entities that require identity-based equality and mutation guards. +`Aggregate` is an `Entity` with an `Events` collection for accumulating integration events. The aggregate owns its invariants; the application service is responsible for forwarding any accumulated events to `IUnitOfWork.Events` after a successful repository operation. ```csharp -public class Order : Entity +public sealed class Basket : Aggregate { - public string? Reference { get; private set; } + public Result Checkout() + { + if (Status == BasketStatus.Empty) + return Result.BusinessError("An empty basket cannot be checked out.", + c => c.WithKey(Id).WithErrorCode("empty-basket")); - // Mutations always go through Modify/Remove to advance PersistenceState - public Result SetReference(string? value) => - Modify(() => Reference = value); + Modify(() => Status = BasketStatus.CheckedOut); + return Result.Success; + } - // Pre-mutation business-rule validation — called automatically before every Modify/Remove - protected override Result OnCheckCanMutate() => - IsReadOnly ? Result.InvalidError("Order cannot be changed once dispatched.") : Result.Success; + 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")); } ``` -## Aggregate Root - -Use `Aggregate` when the entity accumulates integration events. Call `AddEvent` within mutations, and let the application service drain `Events` into the unit-of-work publisher inside `TransactionAsync`. +In the application service, events are added to `IUnitOfWork.Events` after the repository update — typically using `EventData.CreateEventWith` on the mapped contract, not inside the aggregate itself: ```csharp -public class Basket : Aggregate +// Application service — events added by the service, not the aggregate +return await _unitOfWork.TransactionAsync(async () => { - public Result Checkout() + var ur = await _repository.UpdateAsync(basket).ConfigureAwait(false); + return ur.ThenAs(b => { - return Modify(() => - { - Status = BasketStatus.CheckedOut; - AddEvent(new EventData { Subject = "contoso.shopping.basket.checkedout.v1", Key = Id.ToString() }); - }); - } -} + var contract = BasketMapper.Map(b); + _unitOfWork.Events.Add(EventData.CreateEventWith(contract, EventAction.CheckedOut)); + return contract; + }); +}).ConfigureAwait(false); +``` + +> `Aggregate` also exposes `AddEvent` / `ClearEvents` for cases where the aggregate itself needs to accumulate events internally, but this is not the primary pattern used in the sample domains. + +## Entity Base + +`Entity` provides identity-based equality, `PersistenceState` tracking, and mutation guards. All state changes go through `Modify()` or `Remove()` so the framework can track whether an entity is new, modified, or deleted. -// Application service -await _uow.TransactionAsync(async () => +```csharp +public class BasketItem : Entity { - var basket = await _repo.GetAsync(id, ct).ConfigureAwait(false); - basket.Checkout().ThrowOnError(); - await _repo.UpdateAsync(basket, ct).ConfigureAwait(false); + public decimal Quantity { get; private set; } - // Drain aggregate events into the outbox - foreach (var e in basket.Events) - _uow.Events.Publish(e); + public Result OverrideQuantity(decimal quantity) => + Modify(() => Quantity = quantity); - basket.ClearEvents(); -}).ConfigureAwait(false); + protected override Result OnCheckCanMutate() => + PersistenceState.IsDeleted + ? Result.BusinessError("Cannot modify a deleted item.") + : Result.Success; +} ``` ## PersistenceState -Infrastructure layers use `SetPersistenceState`, `AsNew()`, `AsNotModified()` — never `Modify()` — to hydrate an entity from the database. +Infrastructure layers use `AsNew()`, `AsNotModified()`, and `SetPersistenceState()` to hydrate entities from the database — never `Modify()`. ```csharp -// Infrastructure mapper +// Infrastructure mapper — hydrating from DB entity.AsNotModified() .SetChangeLog(changeLog) .SetETag(etag); @@ -70,6 +80,10 @@ entity.AsNotModified() - Do not set `IsReadOnly` from outside the entity; call `MakeReadOnly()` on the entity itself. - Do not add domain events to this package — it intentionally supports only integration events (`EventData`). +## Domain Events — Intentionally Not Supported + +CoreEx does not provide a native in-process domain-event bus. Use `IUnitOfWork.Events` and the transactional outbox for integration events instead. See [README — Domain Events](./README.md#domain-events--intentionally-not-supported) for the full rationale. + ## Further Reading - [README](./README.md) — full `Entity`, `Aggregate`, `PersistenceState`, and mutation-guard API reference. @@ -77,3 +91,4 @@ entity.AsNotModified() - [CoreEx.EntityFrameworkCore](../CoreEx.EntityFrameworkCore/README.md) — persists `Entity`/`Aggregate` types using `PersistenceState`. - [Domain layer](../../samples/docs/domain-layer.md) — real-world aggregate design, mutation guards, integration-event accumulation, and `Result` pipeline usage in the Shopping sample. - [Patterns](../../samples/docs/patterns.md) — aggregate-oriented service patterns, domain event flow, and mutation-state tracking. + diff --git a/src/CoreEx.DomainDriven/README.md b/src/CoreEx.DomainDriven/README.md index 640ba3e4..ad2fb826 100644 --- a/src/CoreEx.DomainDriven/README.md +++ b/src/CoreEx.DomainDriven/README.md @@ -31,6 +31,16 @@ The package intentionally stays minimal: it does not dictate a persistence strat | **[`PersistenceState`](./PersistenceState.cs)** | Enum: `Unknown`, `New`, `NotModified`, `Modified`, `Removed`; governs the entity lifecycle from creation through persistence to deletion. | | **[`DomainDrivenExtensions`](./DomainDrivenExtensions.cs)** | Extension methods on `PersistenceState`: `IsNew`, `IsNotModified`, `IsModified`, `IsRemoved`, `IsNotRemoved`, `IsNewOrModified`. | +## Domain Events — Intentionally Not Supported + +CoreEx deliberately does not provide a native domain-event mechanism (e.g. MediatR `INotification` dispatch or an in-process event bus). This is a conscious architectural decision: + +- **Chatty emission** — fine-grained domain events (`PropertyChanged`, `ItemAdded`, etc.) generate high volumes of events that produce implicit, hard-to-trace side-effects throughout the application layer. +- **Non-explicit side-effects** — handler chains driven by in-process events obscure control flow, making it difficult to reason about what happens as a result of a single aggregate mutation. +- **Integration events are sufficient** — coarse-grained integration events via `IUnitOfWork.Events` and the transactional outbox communicate meaningful state changes to other systems in an explicit, auditable, and transactional way. + +A developer can extend CoreEx with a domain-event mechanism if a genuine use case exists — for example, by raising events from aggregate mutations and dispatching them via MediatR after the transaction commits. This is an opt-in extension, not a framework default. + ## Related namespaces - **[`CoreEx`](../CoreEx/README.md)** - Provides `IIdentifier`, `IChangeLog`, `IETag`, `CompositeKey`, `EventData`, and `Result` consumed by the DDD types. diff --git a/src/CoreEx.Validation/AGENTS.md b/src/CoreEx.Validation/AGENTS.md index e3544252..684b1890 100644 --- a/src/CoreEx.Validation/AGENTS.md +++ b/src/CoreEx.Validation/AGENTS.md @@ -57,7 +57,7 @@ Property(p => p.Address).Entity(AddressValidator.Default); // child entity ## Conditional Rules -Use `When`/`WhenHasValue`/`DependsOn` to guard rules — never write branching `if` statements in a validator. +Use `When`/`WhenHasValue`/`DependsOn` to guard the declarative rules. ```csharp Property(p => p.DiscountCode) @@ -65,7 +65,7 @@ Property(p => p.DiscountCode) .When(() => product.HasDiscount); Property(p => p.ExpiresOn) - .CompareValue(CompareOperator.GreaterThan, DateTimeOffset.UtcNow) + .CompareValue(CompareOperator.GreaterThan, () => Runtime.UtcNow) .WhenHasValue(); ``` From f4ead760cd0e1e73acf5efec7fef2d861f666b69 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 27 May 2026 11:30:46 -0700 Subject: [PATCH 05/17] Further review tweaks, --- .../application-services.instructions.md | 1 - .../event-subscribers.instructions.md | 82 ++++++++++++++++--- .github/instructions/tooling.instructions.md | 2 +- src/CoreEx.Validation/AGENTS.md | 2 +- 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/.github/instructions/application-services.instructions.md b/.github/instructions/application-services.instructions.md index 95af14e0..d5f44eff 100644 --- a/.github/instructions/application-services.instructions.md +++ b/.github/instructions/application-services.instructions.md @@ -252,7 +252,6 @@ Always call `.ConfigureAwait(false)` on every `await` inside service and reposit ## Do Not -- Do not call `_unitOfWork.ExecuteAsync(...)` — the correct method is `_unitOfWork.TransactionAsync(...)`. - Do not publish events outside of `_unitOfWork.TransactionAsync(...)` — events must be committed atomically with the database write. - 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. diff --git a/.github/instructions/event-subscribers.instructions.md b/.github/instructions/event-subscribers.instructions.md index ee9e0309..a1bf4a43 100644 --- a/.github/instructions/event-subscribers.instructions.md +++ b/.github/instructions/event-subscribers.instructions.md @@ -145,28 +145,64 @@ The Subscribe host `Program.cs` follows a predictable CoreEx shape. Key sections // 1. Execution context and dynamic service discovery builder.Services .AddExecutionContext() - .AddDynamicServicesUsing(); // discovers all [ScopedService] types in the assembly + .AddReferenceDataOrchestrator() + .AddMvcWebApi() + .AddHttpWebApi() + .AddHostedServiceManager(); + +// Discover all [ScopedService] types in the subscriber, ref-data, and repository assemblies +builder.Services.AddDynamicServicesUsing(); + +// 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 = sp.GetRequiredService>().Value.ToString() })) + .WithSystemTextJsonSerializer(JsonDefaults.SerializerOptions); -// 2. Infrastructure — database, EF, outbox publisher (for transactional writes inside subscribers) +builder.Services + .AddFusionHybridCache() + .AddDefaultCacheKeyProvider() + .AddHybridCacheIdempotencyProvider(); + +// 3. Infrastructure — database, EF, outbox publisher (for transactional writes inside subscribers) +// SQL Server (Shopping) variant: +builder.AddSqlServerClient("SqlServer"); builder.Services .AddSqlServerDatabase() .AddSqlServerUnitOfWork() - .AddSqlServerOutboxPublisher() + .AddSqlServerOutboxPublisher() // <-- outbox publisher becomes the default IEventPublisher .AddDbContext() .AddEfDb(); -// 3. Azure Service Bus — add the primary publisher and/or a direct publisher if needed +// PostgreSQL (Products) variant: +builder.AddAzureNpgsqlDataSource("Postgres"); +builder.Services + .AddPostgresDatabase() + .AddPostgresUnitOfWork() + .AddEventFormatter() // <-- required for message formatting for publishing + .AddPostgresOutboxPublisher() // <-- outbox publisher becomes the default IEventPublisher + .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 when outbox publisher is the default IEventPublisher +}, addAsDefaultIEventPublisher: false); // false because outbox publisher is already the default -// 4. Event formatter + subscriber manager +// 5. Event formatter + subscriber manager (Shopping only — Products included AddEventFormatter earlier) builder.Services - .AddEventFormatter() // required for message parsing - .AddSubscribedManager((_, c) => c.AddSubscribersUsing()); + .AddEventFormatter() // Adds the EventFormatter to enable message parsing. + .AddSubscribedManager((_, c) => c.AddSubscribersUsing()); // Adds the SubscribedManager and dynamically links to the individual Subscribers. -// 5. Azure Service Bus receiver wiring +// Products variant (AddEventFormatter already called): +builder.Services.AddSubscribedManager((_, c) => c.AddSubscribersUsing()); + +// 6. Azure Service Bus receiver wiring builder.Services.AzureServiceBusReceiving() .WithSessionReceiver(_ => { @@ -178,14 +214,38 @@ builder.Services.AzureServiceBusReceiving() .WithHostedService() // runs the receiver as a BackgroundService .Build(); -// 6. Health checks, OpenTelemetry, middleware +// 7. External API clients (if needed — Shopping only) +builder.AddTypedHttpClient("ProductsApi"); + +// 8. Health checks, OpenTelemetry builder.Services.PostConfigureAllHealthChecks(); -// ...OpenTelemetry... +builder.Services.AddControllers(); +builder.Services.AddOpenApiDocument(s => +{ + s.Title = builder.Environment.ApplicationName; + s.AddCoreExConfiguration(); +}); + +builder.WithCoreExTelemetry() + .WithCoreExServiceBusTelemetry() + .WithCoreExSqlServerTelemetry() // or .WithCoreExPostgresTelemetry() for Products + .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. diff --git a/.github/instructions/tooling.instructions.md b/.github/instructions/tooling.instructions.md index 674dc394..5a981653 100644 --- a/.github/instructions/tooling.instructions.md +++ b/.github/instructions/tooling.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: "**/*.CodeGen/Program.cs;**/*.CodeGen/ref-data.yaml;**/*.Database/Program.cs;**/*.Database/dbex.yaml;**/*.Database/ref-data.yaml;**/*.Database/Migrations/**;**/*.Database/Schema/**" +applyTo: "**/*.CodeGen/Program.cs;**/*.CodeGen/ref-data.yaml;**/*.Database/Program.cs;**/*.Database/dbex.yaml;**/*.Database/Migrations/**;**/*.Database/Data/**" 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"] --- diff --git a/src/CoreEx.Validation/AGENTS.md b/src/CoreEx.Validation/AGENTS.md index 684b1890..d97faf35 100644 --- a/src/CoreEx.Validation/AGENTS.md +++ b/src/CoreEx.Validation/AGENTS.md @@ -62,7 +62,7 @@ Use `When`/`WhenHasValue`/`DependsOn` to guard the declarative rules. ```csharp Property(p => p.DiscountCode) .Mandatory() - .When(() => product.HasDiscount); + .WhenEntity(product => product.HasDiscount); Property(p => p.ExpiresOn) .CompareValue(CompareOperator.GreaterThan, () => Runtime.UtcNow) From e88ecf47980484d78fda2b24be53eb4ad40c2740 Mon Sep 17 00:00:00 2001 From: "Eric Sibly [chullybun]" Date: Wed, 27 May 2026 12:35:58 -0700 Subject: [PATCH 06/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Eric Sibly [chullybun] --- src/CoreEx.AspNetCore/Mvc/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CoreEx.AspNetCore/Mvc/README.md b/src/CoreEx.AspNetCore/Mvc/README.md index 14ae8a03..be418b06 100644 --- a/src/CoreEx.AspNetCore/Mvc/README.md +++ b/src/CoreEx.AspNetCore/Mvc/README.md @@ -10,7 +10,7 @@ The namespace also provides a set of small, targeted MVC attributes. These carry ## Key capabilities -- 🎯 **MVC result creation**: `Mvc.WebApi.CreateResult` translates `WebApiResult` to the full range of `IActionResult` types, including field-level `ValidationException` → `400 Bad Request` with `errors` extension, and `ConcurrencyException` → `409 Conflict`. +- 🎯 **MVC result creation**: `Mvc.WebApi.CreateResult` translates `WebApiResult` to the full range of `IActionResult` types, including field-level `ValidationException` → `400 Bad Request` with `errors` extension, and `ConcurrencyException` → `412 Precondition Failed`. - 📑 **Paging attribute**: `[PagingAttribute]` marks operations that accept paging arguments via query string without declaring `PagingArgs` as an explicit method parameter; NSwag reads this to add `$skip`, `$take`, `$count`, `$page` query parameters to the spec. - 🔍 **Query attribute**: `[QueryAttribute]` marks operations that accept OData-style `$filter` / `$orderby` query arguments; NSwag adds the corresponding parameters. - ✅ **Accepts attribute**: `[AcceptsAttribute]` declares the request body `Content-Type` and schema type for NSwag, replacing the need for `[Consumes]` with schema inference. From 6b8bd4ba5b1a7c57c3d8800d643b4525d10bb91b Mon Sep 17 00:00:00 2001 From: "Eric Sibly [chullybun]" Date: Wed, 27 May 2026 12:39:06 -0700 Subject: [PATCH 07/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Eric Sibly [chullybun] --- .github/instructions/event-subscribers.instructions.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/instructions/event-subscribers.instructions.md b/.github/instructions/event-subscribers.instructions.md index a1bf4a43..11a6675e 100644 --- a/.github/instructions/event-subscribers.instructions.md +++ b/.github/instructions/event-subscribers.instructions.md @@ -11,7 +11,8 @@ tags: ["subscribers", "messaging", "service-bus", "event-handling", "integration | Package | Key types provided | |---|---| | `CoreEx.Azure.Messaging.ServiceBus` | `SubscribedBase`, `SubscribedBase`, `[Subscribe(...)]`, `EventSubscriberArgs`, `ErrorHandler`, `ErrorHandling`, `ServiceBusSessionReceiverOptions`, `.AzureServiceBusReceiving()`, `.WithSessionReceiver()`, `.WithSubscribedSubscriber()`, `.WithHostedService()` | -| `CoreEx.Events` | `EventData`, `.Key`, `.Required()`, `.ToData()`, `IValidator` | +| `CoreEx.Events` | `EventData`, `.Key`, `.Required()`, `.ToData()` | +| `CoreEx.Validation` | `IValidator` | | `CoreEx.Results` | `Result`, `Result.Success` | | `CoreEx` | `[ScopedService]`, `.ThrowIfNull()` | From eab852928b3219eb40c9e330597e2c1b6075ae60 Mon Sep 17 00:00:00 2001 From: "Eric Sibly [chullybun]" Date: Wed, 27 May 2026 12:42:48 -0700 Subject: [PATCH 08/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Eric Sibly [chullybun] --- .github/instructions/tooling.instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/instructions/tooling.instructions.md b/.github/instructions/tooling.instructions.md index 5a981653..3c282ee5 100644 --- a/.github/instructions/tooling.instructions.md +++ b/.github/instructions/tooling.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: "**/*.CodeGen/Program.cs;**/*.CodeGen/ref-data.yaml;**/*.Database/Program.cs;**/*.Database/dbex.yaml;**/*.Database/Migrations/**;**/*.Database/Data/**" +applyTo: "**/*.CodeGen/Program.cs;**/*.CodeGen/ref-data.yaml;**/*.Database/Program.cs;**/*.Database/dbex.yaml;**/*.Database/Migrations/**;**/*.Database/Data/**;**/*.Database/Schema/**" 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"] --- From ea2f94d95f0c922e6751ca688722693778f84d6a Mon Sep 17 00:00:00 2001 From: "Eric Sibly [chullybun]" Date: Wed, 27 May 2026 12:43:31 -0700 Subject: [PATCH 09/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Eric Sibly [chullybun] --- src/CoreEx.AspNetCore.NSwag/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CoreEx.AspNetCore.NSwag/README.md b/src/CoreEx.AspNetCore.NSwag/README.md index 280f2306..9eb31291 100644 --- a/src/CoreEx.AspNetCore.NSwag/README.md +++ b/src/CoreEx.AspNetCore.NSwag/README.md @@ -17,7 +17,7 @@ - 📦 **Request body content types**: Reads `[AcceptsAttribute]` and populates the operation `RequestBody` with the declared content type(s) and NSwag-inferred JSON schema for the body type. - 🔑 **Idempotency-key header**: Reads `[IdempotencyKeyAttribute]` and adds an `x-idempotency-key` header parameter to the operation. - 🚫 **Not-found response**: Reads `[ProducesNotFoundProblemAttribute]` and adds a `404 application/problem+json` response entry. -- ⚠️ **Standard ProblemDetails responses**: Optionally injects `400`, `4xx`, and `500` `application/problem+json` response entries for all operations via `OpenApiOptions.IncludeStandardProblemDetailsResponses`. +- ⚠️ **ProblemDetails responses**: Optionally injects `application/problem+json` response entries for the HTTP status codes configured via `OpenApiOptions.IncludeProblemDetailsHttpStatusCodes` / `OpenApiOptions.IncludeValidationProblemDetailsHttpStatusCodes` and their corresponding status-code lists; `500` is only included when it is present in the configured list, not by default via a separate `IncludeStandardProblemDetailsResponses` option. - 📡 **Fields query string**: When `OpenApiOptions.IncludeFieldsRequestHeaders` is set, adds the `$fields` query-string parameter for response field projection. - 💬 **Message response headers**: When `OpenApiOptions.IncludeMessagesResponseHeaders` is set, documents `x-messages-warning` and `x-messages-info` response headers. - ⚙️ **STJ schema settings**: `ConfigureSchemaSettings()` aligns NSwag's JSON schema generation with `JsonDefaults.SerializerOptions` (camelCase, enum-as-string, `WhenWritingDefault`) so the generated schema matches the actual serialized output. From 286c56a6da2ba701ea4a36ac174c4a1b1c70c5ca Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 27 May 2026 12:44:35 -0700 Subject: [PATCH 10/17] Review tweaks. --- .../instructions/host-setup.instructions.md | 42 +++++++++++++++---- .github/instructions/tests.instructions.md | 21 +++++++++- .../Contoso.Products.Contracts/ProductLite.cs | 3 +- .../ProductReserve.cs | 3 +- src/CoreEx/README.md | 18 ++++---- src/Directory.Build.props | 5 +-- 6 files changed, 65 insertions(+), 27 deletions(-) diff --git a/.github/instructions/host-setup.instructions.md b/.github/instructions/host-setup.instructions.md index 135a82dc..db3d80f6 100644 --- a/.github/instructions/host-setup.instructions.md +++ b/.github/instructions/host-setup.instructions.md @@ -35,11 +35,16 @@ The host is a **composition root only** — no business logic. There are three h | Package | Key registrations | |---|---| | `CoreEx.AspNetCore` | `AddMvcWebApi()`, `AddHttpWebApi()`, `AddExecutionContext()`, `AddHostedServiceManager()`, `UseCoreExExceptionHandler()`, `UseExecutionContext()`, `MapHealthChecks()`, `MapHostedServices()` | -| `CoreEx.Events` | `AddEventFormatter()` | +| `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.Azure.Messaging.ServiceBus` | `AddAzureServiceBusClient("ServiceBus")`, `AddAzureServiceBusPublisher(..., addAsDefaultIEventPublisher: false)`, `AddSubscribedManager()`, `AzureServiceBusReceiving()`, `WithCoreExServiceBusTelemetry()` | -| `OpenTelemetry.*` | `WithCoreExTelemetry()`, `WithCoreExServiceBusTelemetry()`, `WithCoreExSqlServerTelemetry()`, `UseOtlpExporter()` | +| `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 @@ -113,8 +118,8 @@ app.Run(); ``` Key points: -- `AddReferenceDataOrchestrator()` and `AddDynamicServicesUsing<...>()` are exclusive to the API host. -- FusionCache (L1/L2) and `AddHybridCacheIdempotencyProvider()` are exclusive to the API host. +- `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()` (no generic type parameter). - Products uses `AddPostgresDatabase()` / `AddPostgresUnitOfWork()` / `AddPostgresOutboxPublisher()` / `WithCoreExPostgresTelemetry()` instead of the SQL Server variants. @@ -125,16 +130,32 @@ Key points: ## Subscribe Host -The Subscribe host receives broker messages and delegates to Application-layer services. It does **not** have reference data, FusionCache, or idempotency — but it does have a database/outbox for its own domain writes, plus Service Bus wiring. +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 builder.Services .AddPrecisionTimeProvider() .AddExecutionContext() + .AddReferenceDataOrchestrator() .AddMvcWebApi() .AddHttpWebApi() .AddHostedServiceManager(); +builder.Services.AddDynamicServicesUsing(); + +// 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 (for writes triggered by inbound events). builder.AddSqlServerClient("SqlServer"); builder.Services @@ -144,7 +165,7 @@ builder.Services .AddDbContext() .AddEfDb(); -// Service Bus: outbox relay is the default publisher; Service Bus is NOT the default IEventPublisher. +// Service Bus: outbox publisher is the default IEventPublisher. builder.AddAzureServiceBusClient("ServiceBus"); builder.Services.AddAzureServiceBusPublisher((_, c) => { @@ -187,18 +208,20 @@ app.Run(); ``` Key points: +- Subscribe hosts **do** include `AddReferenceDataOrchestrator()` and `AddDynamicServicesUsing<...>()` — subscribers call application services that need reference data for validation and business logic. +- 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 as part of their message-processing logic. - `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. - `AddEventFormatter()` is required for message parsing and formatting. - `MapHostedServices()` must come **after** `MapHealthChecks()`. -- No `AddReferenceDataOrchestrator`, no FusionCache, no `UseIdempotencyKey` in a Subscribe host. --- ## Outbox Relay Host -The Outbox Relay host is minimal: it polls the outbox table and forwards committed events to Azure Service Bus. No controllers, no OpenAPI, no FusionCache. +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 @@ -238,6 +261,7 @@ 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()` registers the background relay pump — call these on `builder`, not `builder.Services`. - No `AddControllers()`, no `AddOpenApiDocument()`, no `UseOpenApi()`, no `UseSwaggerUi()`, no `UseIdempotencyKey()`. diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md index 70107ec6..960e6d74 100644 --- a/.github/instructions/tests.instructions.md +++ b/.github/instructions/tests.instructions.md @@ -215,7 +215,7 @@ public class InventoryValidatorTests : WithGenericTester ## Subscribe Host Tests -Subscribe test classes extend `WithApiTester` over the subscriber host. The `[OneTimeSetUp]` migrates/seeds the domain DB just like an API test. There is no FusionCache to clear (Subscribe hosts have no cache). +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 @@ -224,11 +224,28 @@ public class ProductModifySubscriberTests : WithApiTester(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + Test.UseExpectedSqlServerOutboxPublisher(); } } ``` +**Products Subscribe host tests (Postgres):** +```csharp +public partial class SubscriberTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigratePostgresDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + + Test.UseExpectedPostgresOutboxPublisher(); + } +} +``` + --- ## Outbox Relay Host Tests @@ -294,7 +311,7 @@ Basket_Checkout_Insufficient_Quantity - Do not use `[TestCase]` for integration tests — create separate named test methods for each scenario. - Do not use `UseExpectedSqlServerOutboxPublisher` / `ExpectSqlServerOutboxEvents` in Products tests — use the Postgres equivalents. - Do not use `UseExpectedPostgresOutboxPublisher` / `ExpectPostgresOutboxEvents` in Shopping tests — use the SQL Server equivalents. -- Do not call `ClearFusionCacheAsync()` in Subscribe or Outbox Relay host tests — those hosts have no cache. +- Do not call `ClearFusionCacheAsync()` in Outbox Relay host tests — relay hosts have no cache (they are minimal forwarding infrastructure only). - 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). diff --git a/samples/src/Contoso.Products.Contracts/ProductLite.cs b/samples/src/Contoso.Products.Contracts/ProductLite.cs index 888023a7..08232f36 100644 --- a/samples/src/Contoso.Products.Contracts/ProductLite.cs +++ b/samples/src/Contoso.Products.Contracts/ProductLite.cs @@ -1,7 +1,6 @@ namespace Contoso.Products.Contracts; -[Contract] -public partial class ProductLite : ProductBase +public class ProductLite : ProductBase { public decimal QtyOnHand { get; set; } } \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/ProductReserve.cs b/samples/src/Contoso.Products.Contracts/ProductReserve.cs index 154d79d4..5d297bee 100644 --- a/samples/src/Contoso.Products.Contracts/ProductReserve.cs +++ b/samples/src/Contoso.Products.Contracts/ProductReserve.cs @@ -1,7 +1,6 @@ namespace Contoso.Products.Contracts; -[Contract] -public partial class ProductReserve : IIdentifier +public class ProductReserve : IIdentifier { public string Id { get; set; } = default!; diff --git a/src/CoreEx/README.md b/src/CoreEx/README.md index 06241a6e..89cbbd62 100644 --- a/src/CoreEx/README.md +++ b/src/CoreEx/README.md @@ -66,15 +66,15 @@ Semantic (error-oriented) exception types with automatic HTTP status mapping: | Exception | Description | HTTP Status | Error Type | |-----------|-------------|-------------|------------| -| [`AuthenticationException`](./AuthenticationException.cs) | User not authenticated. | 401-Unauthorized | `AuthenticationError` | -| [`AuthorizationException`](./AuthorizationException.cs) | User lacks permissions. | 403-Forbidden | `AuthorizationError` | -| [`BusinessException`](./BusinessException.cs) | Business rule violation (message shown to consumer). | 400-Bad Request | `BusinessError` | -| [`ConcurrencyException`](./ConcurrencyException.cs) | Data concurrency conflict (ETag mismatch). | 412-Precondition Failed | `ConcurrencyError` | -| [`ConflictException`](./ConflictException.cs) | Data conflict (e.g., identifier already exists on create). | 409-Conflict | `ConflictError` | -| [`DuplicateException`](./DuplicateException.cs) | Duplicate value (e.g., unique code already in use). | 409-Conflict | `DuplicateError` | -| [`NotFoundException`](./NotFoundException.cs) | Entity not found. | 404-Not Found | `NotFoundError` | -| [`TransientException`](./TransientException.cs) | Transient failure (retry candidate). | 503-Service Unavailable | `TransientError` | -| [`ValidationException`](./ValidationException.cs) | Validation failure with message collection. | 400-Bad Request | `ValidationError` | +| [`AuthenticationException`](./AuthenticationException.cs) | User not authenticated. | 401-Unauthorized | `authentication` | +| [`AuthorizationException`](./AuthorizationException.cs) | User lacks permissions. | 403-Forbidden | `authorization` | +| [`BusinessException`](./BusinessException.cs) | Business rule violation (message shown to consumer). | 400-Bad Request | `business` | +| [`ConcurrencyException`](./ConcurrencyException.cs) | Data concurrency conflict (ETag mismatch). | 412-Precondition Failed | `concurrency` | +| [`ConflictException`](./ConflictException.cs) | Data conflict (e.g., identifier already exists on create). | 409-Conflict | `conflict` | +| [`DuplicateException`](./DuplicateException.cs) | Duplicate value (e.g., unique code already in use). | 409-Conflict | `duplicate` | +| [`NotFoundException`](./NotFoundException.cs) | Entity not found. | 404-Not Found | `not-found` | +| [`TransientException`](./TransientException.cs) | Transient failure (retry candidate). | 503-Service Unavailable | `transient` | +| [`ValidationException`](./ValidationException.cs) | Validation failure with message collection. | 400-Bad Request | `validation` | All inherit from [`ExtendedException`](./Abstractions/ExtendedExceptionT.cs) implementing [`IExtendedException`](./Abstractions/IExtendedException.cs). diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 874001de..10cdece3 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -59,12 +59,11 @@ PackagePath="/" Visible="false" /> - + + Visible="false" /> From ad9d6d1f157d4207259cc7ebad75b5321bf8f119 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 27 May 2026 12:56:43 -0700 Subject: [PATCH 11/17] Fix exclude. --- .github/instructions/tooling.instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/instructions/tooling.instructions.md b/.github/instructions/tooling.instructions.md index 3c282ee5..b9567c90 100644 --- a/.github/instructions/tooling.instructions.md +++ b/.github/instructions/tooling.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: "**/*.CodeGen/Program.cs;**/*.CodeGen/ref-data.yaml;**/*.Database/Program.cs;**/*.Database/dbex.yaml;**/*.Database/Migrations/**;**/*.Database/Data/**;**/*.Database/Schema/**" +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"] --- From 07df52d59d8d5098282a35cad97bfb9eff916562 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Thu, 28 May 2026 13:53:13 -0700 Subject: [PATCH 12/17] Consumerization of instructions. Prefix instructions. --- ...ers.instructions.md => coreex-api-controllers.instructions.md} | 0 ...nstructions.md => coreex-application-services.instructions.md} | 0 ...contracts.instructions.md => coreex-contracts.instructions.md} | 0 .../{domain.instructions.md => coreex-domain.instructions.md} | 0 ...s.instructions.md => coreex-event-subscribers.instructions.md} | 0 ...st-setup.instructions.md => coreex-host-setup.instructions.md} | 0 ...tories.instructions.md => coreex-repositories.instructions.md} | 0 .../{tests.instructions.md => coreex-tests.instructions.md} | 0 .../{tooling.instructions.md => coreex-tooling.instructions.md} | 0 ...lidators.instructions.md => coreex-validators.instructions.md} | 0 10 files changed, 0 insertions(+), 0 deletions(-) rename .github/instructions/{api-controllers.instructions.md => coreex-api-controllers.instructions.md} (100%) rename .github/instructions/{application-services.instructions.md => coreex-application-services.instructions.md} (100%) rename .github/instructions/{contracts.instructions.md => coreex-contracts.instructions.md} (100%) rename .github/instructions/{domain.instructions.md => coreex-domain.instructions.md} (100%) rename .github/instructions/{event-subscribers.instructions.md => coreex-event-subscribers.instructions.md} (100%) rename .github/instructions/{host-setup.instructions.md => coreex-host-setup.instructions.md} (100%) rename .github/instructions/{repositories.instructions.md => coreex-repositories.instructions.md} (100%) rename .github/instructions/{tests.instructions.md => coreex-tests.instructions.md} (100%) rename .github/instructions/{tooling.instructions.md => coreex-tooling.instructions.md} (100%) rename .github/instructions/{validators.instructions.md => coreex-validators.instructions.md} (100%) diff --git a/.github/instructions/api-controllers.instructions.md b/.github/instructions/coreex-api-controllers.instructions.md similarity index 100% rename from .github/instructions/api-controllers.instructions.md rename to .github/instructions/coreex-api-controllers.instructions.md diff --git a/.github/instructions/application-services.instructions.md b/.github/instructions/coreex-application-services.instructions.md similarity index 100% rename from .github/instructions/application-services.instructions.md rename to .github/instructions/coreex-application-services.instructions.md diff --git a/.github/instructions/contracts.instructions.md b/.github/instructions/coreex-contracts.instructions.md similarity index 100% rename from .github/instructions/contracts.instructions.md rename to .github/instructions/coreex-contracts.instructions.md diff --git a/.github/instructions/domain.instructions.md b/.github/instructions/coreex-domain.instructions.md similarity index 100% rename from .github/instructions/domain.instructions.md rename to .github/instructions/coreex-domain.instructions.md diff --git a/.github/instructions/event-subscribers.instructions.md b/.github/instructions/coreex-event-subscribers.instructions.md similarity index 100% rename from .github/instructions/event-subscribers.instructions.md rename to .github/instructions/coreex-event-subscribers.instructions.md diff --git a/.github/instructions/host-setup.instructions.md b/.github/instructions/coreex-host-setup.instructions.md similarity index 100% rename from .github/instructions/host-setup.instructions.md rename to .github/instructions/coreex-host-setup.instructions.md diff --git a/.github/instructions/repositories.instructions.md b/.github/instructions/coreex-repositories.instructions.md similarity index 100% rename from .github/instructions/repositories.instructions.md rename to .github/instructions/coreex-repositories.instructions.md diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/coreex-tests.instructions.md similarity index 100% rename from .github/instructions/tests.instructions.md rename to .github/instructions/coreex-tests.instructions.md diff --git a/.github/instructions/tooling.instructions.md b/.github/instructions/coreex-tooling.instructions.md similarity index 100% rename from .github/instructions/tooling.instructions.md rename to .github/instructions/coreex-tooling.instructions.md diff --git a/.github/instructions/validators.instructions.md b/.github/instructions/coreex-validators.instructions.md similarity index 100% rename from .github/instructions/validators.instructions.md rename to .github/instructions/coreex-validators.instructions.md From ed93df64f7667967105758088e775ad7f649c66c Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Thu, 28 May 2026 13:53:48 -0700 Subject: [PATCH 13/17] Instructions consumerized and renamed. --- .github/SKILL_AUTHORING.md | 8 +- .github/agents/coreex-expert.agent.md | 20 +-- .../coreex-api-controllers.instructions.md | 148 ++++++++++++--- ...oreex-application-services.instructions.md | 168 +++++++++++++----- .../coreex-contracts.instructions.md | 45 ++--- .../coreex-domain.instructions.md | 25 +-- .../coreex-event-subscribers.instructions.md | 149 +++++++--------- .../coreex-host-setup.instructions.md | 70 +++++--- .../coreex-repositories.instructions.md | 88 ++++++--- .../instructions/coreex-tests.instructions.md | 47 ++--- .../coreex-tooling.instructions.md | 58 ++++-- .../coreex-validators.instructions.md | 12 +- .github/skills/add-capability/SKILL.md | 8 +- .../add-capability/references/workflow.md | 8 +- README.md | 4 +- .../.github/copilot-instructions.md | 127 +++++++++++++ consumer-ai-context/README.md | 60 +++++++ 17 files changed, 739 insertions(+), 306 deletions(-) create mode 100644 consumer-ai-context/.github/copilot-instructions.md create mode 100644 consumer-ai-context/README.md diff --git a/.github/SKILL_AUTHORING.md b/.github/SKILL_AUTHORING.md index 9a30c34d..27fd041c 100644 --- a/.github/SKILL_AUTHORING.md +++ b/.github/SKILL_AUTHORING.md @@ -68,7 +68,7 @@ Reusable templates and examples: When skills reference each other, instructions, or samples: - **Relative paths**: `../other-skill/references/...` (for other skills) -- **Absolute workspace paths**: `/.github/instructions/host-setup.instructions.md`, `/samples/src/Contoso.Products.Api/Program.cs` +- **Absolute workspace paths**: `/.github/instructions/coreex-host-setup.instructions.md`, `/samples/src/Contoso.Products.Api/Program.cs` - Always verify links work before committing - Prefer workspace-relative links for durability @@ -132,9 +132,9 @@ For detailed step-by-step guidance, see [`references/workflow.md`](references/wo ## Key References -- [Application Services Instructions](/.github/instructions/application-services.instructions.md) -- [Contracts Instructions](/.github/instructions/contracts.instructions.md) -- [Host Setup Instructions](/.github/instructions/host-setup.instructions.md) +- [Application Services Instructions](/.github/instructions/coreex-application-services.instructions.md) +- [Contracts Instructions](/.github/instructions/coreex-contracts.instructions.md) +- [Host Setup Instructions](/.github/instructions/coreex-host-setup.instructions.md) - [Sample Domains](./samples/src/Contoso.Products/) - [Roslyn Source Generation](./docs/capabilities.md) ``` diff --git a/.github/agents/coreex-expert.agent.md b/.github/agents/coreex-expert.agent.md index 7e629ed3..01752e50 100644 --- a/.github/agents/coreex-expert.agent.md +++ b/.github/agents/coreex-expert.agent.md @@ -20,16 +20,16 @@ Your mission: - `.github/SKILL_AUTHORING.md` — standards for authoring skills (`SKILL.md` files). ### Scoped instruction files (auto-applied by file glob, read these for area-specific rules) -- `.github/instructions/contracts.instructions.md` — entity contracts, `[Contract]`, `[ReferenceData]`, source generation. -- `.github/instructions/domain.instructions.md` — DDD aggregates, `Entity`, mutation guards, `Result` pipelines. -- `.github/instructions/application-services.instructions.md` — service shape, `TransactionAsync`, validation-before-transaction, event enqueuing. -- `.github/instructions/validators.instructions.md` — `AbstractValidator`, rule chains, `CommonValidator`, `ValidateAndThrowAsync`. -- `.github/instructions/repositories.instructions.md` — `EfDbModel`, `IBiDirectionMapper`, `QueryArgsConfig`, paging. -- `.github/instructions/api-controllers.instructions.md` — controller shape, `WebApi` helpers, `[IdempotencyKey]`, PATCH. -- `.github/instructions/event-subscribers.instructions.md` — subscriber classes, `[Subscribe]`, `SubscribedManager`, error handling. -- `.github/instructions/host-setup.instructions.md` — `Program.cs` shape, middleware order, service registration, outbox relay hosts. -- `.github/instructions/tooling.instructions.md` — `*.CodeGen` and `*.Database` projects, `ref-data.yaml`, DbEx, generated-file ownership. -- `.github/instructions/tests.instructions.md` — `UnitTestEx`, `NUnit`, `AwesomeAssertions`, outbox/event expectations, seed data. +- `.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` — `AbstractValidator`, 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. ### Sample architecture docs (real-world usage patterns) - `samples/docs/layers.md` — full layer dependency diagram, design-time tooling overview, dependency rules. diff --git a/.github/instructions/coreex-api-controllers.instructions.md b/.github/instructions/coreex-api-controllers.instructions.md index 1b40b895..68c5ff77 100644 --- a/.github/instructions/coreex-api-controllers.instructions.md +++ b/.github/instructions/coreex-api-controllers.instructions.md @@ -1,25 +1,39 @@ --- applyTo: "**/Controllers/**/*.cs" -description: "API controller conventions for CoreEx: inheritance, routing, dependency injection, CQRS separation, and WebApi integration" -tags: ["controllers", "api", "routing", "cqrs", "dependency-injection"] +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 Controller Conventions +# API Conventions + +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`, `.Required()`, `.Adjust(...)` | +| `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(...)` | + +--- -## Structure +## MVC Controllers + +### Structure - Inherit from `ControllerBase`. Never inherit from `Controller` (that brings View support). - Decorate with `[ApiController]` and `[Route("...")]` on the class. - Add `[OpenApiTag("TagName")]` to group endpoints in the generated OpenAPI document. Can also be placed on an individual action method to cross-tag it into a different OpenAPI group. - Inject `WebApi` and the relevant service interface via primary constructor. Guard with `.ThrowIfNull()`. -- Split read operations and write operations into separate controller classes (`ProductController` for mutations, `ProductReadController` for queries) following CQRS conventions. +- Split read operations and write operations into separate controller classes (e.g., `ProductController` for mutations, `ProductReadController` for queries) following CQRS conventions. ```csharp [ApiController, Route("/api/products"), OpenApiTag("Products")] @@ -30,11 +44,11 @@ public class ProductController(WebApi webApi, IProductService service) : Control } ``` -## Method Signatures +### Method Signatures All action methods return `Task` using the `WebApi` helper. Do not return typed `ActionResult` directly. -### Standard (exception-based services — Products style) +#### Standard (exception-based services) | HTTP Verb | WebApi helper | Notes | |---|---|---| @@ -44,7 +58,7 @@ All action methods return `Task` using the `WebApi` helper. Do no | `PATCH` | `_webApi.PatchAsync(...)` | Requires `get:` and `put:` lambdas | | `DELETE` | `_webApi.DeleteAsync(...)` | Returns 204 No Content | -### Result-based (`Result` pipeline services — Shopping style) +#### Result-based (`Result` pipeline services) When the service returns `Result`, use the `WithResult` variants. The controller code is equally thin. @@ -57,9 +71,9 @@ When the service returns `Result`, use the `WithResult` variants. The control | `PUT` (in + out) | `_webApi.PutWithResultAsync(...)` | | | `DELETE` (typed) | `_webApi.DeleteWithResultAsync(...)` | Use when delete returns the deleted resource | -## Route Parameters +### Route Parameters -Validate route parameters inline using `.Required()`: +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}")] @@ -67,7 +81,9 @@ public Task GetAsync(string id) => _webApi.GetAsync(Request, (_, _) => _service.GetAsync(id.Required())); ``` -## POST — Create with Location Header +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: @@ -83,7 +99,7 @@ public Task PostAsync() => _webApi.PostAsync(Re }); ``` -## PATCH — Merge-Patch +### PATCH — Merge-Patch Always supply both `get:` and `put:` delegates. PATCH merges the incoming patch document over the fetched entity and calls `put`: @@ -95,7 +111,7 @@ public Task PatchAsync(string id) => _webApi.PatchAsync( put: (ro, _) => _service.UpdateAsync(ro.Value.Adjust(p => p.Id = id))); ``` -## Query Endpoints +### Query Endpoints Expose `QueryArgs` and `PagingArgs` via `[Query]` and `[Paging]` action attributes. Access them via the request options object (`ro`): @@ -106,7 +122,7 @@ public Task QueryAsync() => _webApi.GetAsync(Request, (ro, _) => _service.QueryAsync(ro.QueryArgs, ro.PagingArgs)); ``` -## Reference Data Endpoints +### Reference Data Endpoints Delegate to `ReferenceDataOrchestrator.Current.GetWithFilterAsync()`. Support `codes`, `text`, and `isIncludeInactive` filter parameters: @@ -116,7 +132,7 @@ public Task GetCategoriesAsync([FromQuery] IEnumerable? c => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetWithFilterAsync(codes, text, ro.IsIncludeInactive, ct)); ``` -## Response Metadata Attributes +### Response Metadata Attributes Decorate actions with standard response metadata attributes: @@ -125,7 +141,7 @@ Decorate actions with standard response metadata attributes: - `[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 +### 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: @@ -136,9 +152,9 @@ public Task QuerySchemaAsync() => _webApi.GetAsync(Request, (ro, _) => _service.QuerySchemaAsync()); ``` -## Result-Based Services +### Result-Based Services -When the service returns `Result` (Shopping-style domain services), use the `WithResult` variants. See the Method Signatures table above for the full variant list. +When the service returns `Result`, use the `WithResult` variants: ```csharp [HttpPost("{basketId}/checkout")] @@ -157,15 +173,101 @@ public Task ItemAddAsync(string basketId) => _service.ItemAddAsync(basketId.Required(), ro.Value), HttpStatusCode.OK); ``` +--- + +## 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) + => webApi.GetWithResultAsync(request, (_, _) => service.GetAsync(id.Required()))) + .Produces().ProducesNotFoundProblem(); +``` + +**POST — create with Location header:** +```csharp +app.MapPost("api/products", + (HttpRequest request, WebApi webApi, IProductService service) + => webApi.PostWithResultAsync(request, async (ro, _) => + { + ro.WithLocationUri(p => new Uri($"api/products/{p.Id}", UriKind.Relative)); + return await service.CreateAsync(ro.Value).ConfigureAwait(false); + })) + .Accepts().ProducesCreated().WithIdempotencyKey(); +``` + +**PUT:** +```csharp +app.MapPut("api/products/{id}", + (HttpRequest request, WebApi webApi, IProductService service, string id) + => webApi.PutWithResultAsync(request, (ro, _) => + service.UpdateAsync(ro.Value.Adjust(p => p.Id = id)))) + .Accepts().Produces().ProducesNotFoundProblem(); +``` + +**PATCH — JSON Merge-Patch:** +```csharp +app.MapPatch("api/products/{id}", + (HttpRequest request, WebApi webApi, IProductService service, string id) + => webApi.PatchWithResultAsync(request, + get: (_, _) => service.GetAsync(id.Required()), + put: (ro, _) => service.UpdateAsync(ro.Value.Adjust(p => p.Id = id)))) + .Accepts(HttpNames.MergePatchJsonMediaTypeName).Produces().ProducesNotFoundProblem(); +``` + +**DELETE:** +```csharp +app.MapDelete("api/products/{id}", + (HttpRequest request, WebApi webApi, IProductService service, string id) + => webApi.DeleteWithResultAsync(request, (_, _) => service.DeleteAsync(id.Required()))) + .ProducesNoContent(); +``` + +**Query with filtering and paging:** +```csharp +app.MapGet("api/products", + (HttpRequest request, WebApi webApi, IProductReadService service) + => webApi.GetWithResultAsync(request, (ro, _) => service.QueryAsync(ro.QueryArgs, ro.PagingArgs))) + .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. + +--- + ## 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 — it belongs in the application service. -- Do not put business logic in controllers — delegate immediately to the application service. +- 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. ## Further Reading -- [`samples/docs/hosts-layer.md`](../../samples/docs/hosts-layer.md) — API host composition, controller patterns, and `Program.cs` shape. -- [`src/CoreEx.AspNetCore/README.md`](../../src/CoreEx.AspNetCore/README.md) — `WebApi` helper API reference. +- [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/.github/instructions/coreex-application-services.instructions.md b/.github/instructions/coreex-application-services.instructions.md index d5f44eff..b30f65b6 100644 --- a/.github/instructions/coreex-application-services.instructions.md +++ b/.github/instructions/coreex-application-services.instructions.md @@ -10,16 +10,15 @@ tags: ["services", "application-layer", "dependency-injection", "validation", "u | Package | Key types provided | |---|---| -| `CoreEx` | `[ScopedService]`, `IUnitOfWork`, `Runtime`, `NotFoundException`, `BusinessException`, `ValidationException`, `.ThrowIfNull()`, `.ThrowIfNullOrEmpty()` | -| `CoreEx.Data` | `DataResult`, `ItemsResult`, `QueryArgs`, `PagingArgs` | +| `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`, `.ValidateAndThrowAsync()`, `.ValidateWithResultAsync()` | -| `CoreEx.Results` | `Result`, `Result.GoAsync()`, `.ThenAs()`, `.ThenAsAsync()` | +| `CoreEx.Validation` | `Validator`, `Validator`, `.ValidateAndThrowAsync()`, `.ValidateWithResultAsync()` | | `CoreEx.RefData` | `ReferenceDataOrchestrator` | ## Structure -- Define a public interface (e.g., `IProductService`) in the Application project. +- 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()`. @@ -34,27 +33,38 @@ public class ProductService(IUnitOfWork unitOfWork, IProductRepository repositor ## Guard Clauses -Use CoreEx null/empty guards at the top of each method before any logic: +`.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()).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) { - product.ThrowIfNull(); - product.Id.ThrowIfNullOrEmpty(); + product.ThrowIfNull(); // checked here; not passed anywhere yet + await ProductValidator.Default.ValidateAndThrowAsync(product).ConfigureAwait(false); + var current = await _repository.GetAsync(product.Id.ThrowIfNullOrEmpty()).ConfigureAwait(false); // ... } ``` ## Validation -Validators live in `Application/Validators/` and extend `Validator`. They combine two phases. +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: -**Declarative phase** — property rules composed fluently in the constructor using the built-in rule set (`Mandatory()`, `MaximumLength()`, `IsValid()`, `PrecisionScale()`, `GreaterThanOrEqualTo()`, `Dictionary()`, `Entity()`, etc.). Run synchronously before any I/O. - -**Programmatic phase** — `OnValidateAsync` override for rules that require I/O (repository lookups, cross-field checks, dynamically-constructed validators). Always guard with `if (context.HasErrors) return;` to fail fast when declarative rules have already failed. +**`Validator`** — use when no constructor injection is required. Exposes a static `Default` singleton; always call via the singleton: ```csharp -// Declarative-only validator. public class ProductValidator : Validator { public ProductValidator() @@ -64,33 +74,46 @@ public class ProductValidator : Validator 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); ``` +**`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 -// Declarative + programmatic validator with I/O. -protected async override Task OnValidateAsync( - ValidationContext context, CancellationToken cancellationToken) +public class MovementRequestValidator : Validator { - if (context.HasErrors) return; // fail fast — skip I/O if phase 1 already found errors + private readonly IProductRepository _repository; - var ids = context.Value.Products!.Select(kvp => kvp.Key).ToArray(); - var products = await _repository.GetForReservationAsync(ids).ConfigureAwait(false); + public MovementRequestValidator(IProductRepository repository) + { + _repository = repository.ThrowIfNull(); + Property(x => x.Id).Mandatory().MaximumLength(50); + // ... declarative rules + } - 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); -} -``` + protected async override Task OnValidateAsync( + ValidationContext context, CancellationToken cancellationToken) + { + if (context.HasErrors) return; // fail fast — skip I/O if declarative phase found errors -Call the validator in the service before any persistence operations. Throw on the first error set (exception-based services): + var ids = context.Value.Products!.Select(kvp => kvp.Key).ToArray(); + var products = await _repository.GetForReservationAsync(ids).ConfigureAwait(false); -```csharp -await ProductValidator.Default.ValidateAndThrowAsync(product); + 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); ``` -For `Result` pipelines, use `ValidateWithResultAsync`: +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)); @@ -115,6 +138,35 @@ 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."); +``` + ## 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. @@ -139,9 +191,9 @@ _unitOfWork.Events.Add( EventData.CreateEventWith(default, EventAction.Deleted).WithKey(id)); ``` -## Result Style (Domain-Aggregate Services) +## Result<T> Pipeline Style -For services operating on DDD aggregates (e.g., Shopping Basket), use `Result` chains instead of exceptions for expected failures. Compose with `Result.GoAsync`, `.ThenAs`, `.ThenAsAsync`. The unit of work is still `TransactionAsync`: +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) @@ -175,6 +227,8 @@ if (pr.IsFailure) Split read operations into a separate service with an `IXxxReadService` interface. 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. +The interface lives in `Interfaces/` alongside the write service interface (e.g., `IProductReadService.cs` next to `IProductService.cs`). The implementation lives in the same folder as the write service implementation. + ```csharp [ScopedService] public class ProductReadService(IProductRepository repository) : IProductReadService @@ -189,10 +243,12 @@ public class ProductReadService(IProductRepository repository) : IProductReadSer ## 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: +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 layer — interface only (domain-idiomatic, not a mirror of the remote API) +// Application/Adapters/IProductAdapter.cs — interface only (domain-idiomatic, not a mirror of the remote API) public interface IProductAdapter { Task> GetAsync(string id); @@ -205,11 +261,13 @@ public interface IProductAdapter public class ProductAdapter(ProductsHttpClient httpClient) : IProductAdapter { ... } ``` -A second adapter interface (`IProductSyncAdapter`) 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. +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 — for example, confirming a referenced entity exists before allowing a mutation. +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()`: @@ -217,19 +275,24 @@ Policies return `Result` or `Result` and compose naturally into `Result` p // Application/Policies/ProductPolicy.cs public class ProductPolicy(IProductAdapter productAdapter) { + private readonly IProductAdapter _productAdapter = productAdapter.ThrowIfNull(); + public Task> EnsureExistsAsync(string productId) => Result .GoAsync(() => _productAdapter.GetAsync(productId)) .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); ``` ## Application-Level Mapping -When a domain has a Domain layer (e.g., Shopping), 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. +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): +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 @@ -242,10 +305,26 @@ public class BasketMapper : Mapper 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. @@ -257,11 +336,14 @@ Always call `.ConfigureAwait(false)` on every `await` inside service and reposit - 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`. ## Further Reading -- [`samples/docs/application-layer.md`](../../../samples/docs/application-layer.md) — full walkthrough of services, validators, adapters, policies, mapping, and the unit-of-work pattern. -- [`samples/docs/patterns.md`](../../../samples/docs/patterns.md) — pattern catalog: CQRS, Service, Unit of Work, Validator, Policy, Adapter, and Event patterns with cross-links. -- [`samples/docs/layers.md`](../../../samples/docs/layers.md) — layer dependency rules: Application depends inward only on Contracts and its own interfaces. -- [`src/CoreEx.Validation/README.md`](../../../src/CoreEx.Validation/README.md) — `Validator`, rule set, `OnValidateAsync`, and `ValidateFurtherAsync`. -- [`src/CoreEx/README.md`](../../../src/CoreEx/README.md) — `IUnitOfWork`, `Result`, `[ScopedService]`, and CoreEx exception types. +- [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/.github/instructions/coreex-contracts.instructions.md b/.github/instructions/coreex-contracts.instructions.md index 1d048fdb..fd098d51 100644 --- a/.github/instructions/coreex-contracts.instructions.md +++ b/.github/instructions/coreex-contracts.instructions.md @@ -10,15 +10,13 @@ tags: ["contracts", "dto", "source-generation", "reference-data", "etag"] | Package | Key types provided | |---|---| -| `CoreEx` | `[Contract]`, `IIdentifier`, `ICompositeKey`, `IETag`, `IChangeLog`, `ChangeLog`, `[ReadOnly]`, `[Localization]` | +| `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` | -| `CoreEx.Generator` | Roslyn source generator — add as `OutputItemType="Analyzer" ReferenceOutputAssembly="false"` | ```xml - - - + + ``` @@ -30,7 +28,7 @@ When a domain **consumes** events from another domain, declare a local internal ## Source Generation -Mark entity contract classes with the `[Contract]` attribute and declare them `partial`. The Roslyn source generator ([`CoreEx.Generator`](../../../gen/CoreEx.Generator)) emits serialization, equality, and change-tracking code into a paired `*.g.cs` file. Never manually implement those generated members. +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. ```csharp [Contract] @@ -82,19 +80,26 @@ Decorate server-assigned properties with `[ReadOnly(true)]` to signal that clien ## Reference Data Properties -Use `[ReferenceData]` on code properties that back a reference data relationship. Declare the property `partial` so source generation can emit the navigation accessor: +Use `[ReferenceData]` on code properties that back a reference data relationship. Three conditions must all 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 -[ReferenceData] -[Localization("Sub-category")] -public partial string? SubCategoryCode { get; set; } +[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; } + [ReferenceData] + [Localization("Unit-of-measure")] + public partial string? UnitOfMeasureCode { get; set; } +} ``` -The generated code exposes a strongly-typed `SubCategory` property alongside the raw code. +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. ## Localization Labels @@ -197,11 +202,11 @@ Contracts are data transfer objects. Keep them free of domain rules, validation ## Generated Code -Reference-data contract types (`*.g.cs`) are produced by the domain's `*.CodeGen` project. Never create or edit these files directly. +Never create or edit `*.g.cs` files directly. | File pattern | Generator | Change instead | |---|---|---| -| `*.g.cs` (ref-data types) | `*.CodeGen` project (`ref-data.yaml` + Handlebars templates) | `ref-data.yaml` or the templates in `CoreEx.CodeGen/RefData/Templates/` | +| `*.g.cs` (ref-data types) | `*.CodeGen` project (`ref-data.yaml` + Handlebars templates) | `ref-data.yaml` or the templates | | `*.g.cs` (contract members) | Roslyn source generator (`CoreEx.Generator`) | The `[Contract]`-decorated partial class | ## Do Not @@ -214,7 +219,7 @@ Reference-data contract types (`*.g.cs`) are produced by the domain's `*.CodeGen ## Further Reading -- [`samples/docs/contracts-layer.md`](../../../samples/docs/contracts-layer.md) — unified API/event surface, source generation, reference data, and internal adapter models. -- [`gen/CoreEx.Generator`](../../../gen/CoreEx.Generator) — Roslyn source generator that processes `[Contract]` and `[ReferenceData]` annotations. -- [`src/CoreEx.RefData/README.md`](../../../src/CoreEx.RefData/README.md) — reference data types, collections, and sort order. -- [`samples/docs/tooling.md`](../../../samples/docs/tooling.md) — `*.CodeGen` project usage and `ref-data.yaml` configuration. +- [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/.github/instructions/coreex-domain.instructions.md b/.github/instructions/coreex-domain.instructions.md index 4b723c6d..b4a742c2 100644 --- a/.github/instructions/coreex-domain.instructions.md +++ b/.github/instructions/coreex-domain.instructions.md @@ -1,20 +1,19 @@ --- applyTo: "**/Domain/**/*.cs" -description: "Domain layer conventions: aggregates, entities, value objects, PersistenceState tracking, and Result-based mutation methods" +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. Shopping includes this layer; Products, being a largely CRUD-oriented domain, does not. +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`, `Runtime.NewId()`, `.ThrowIfNull()`, `.ThrowIfNullOrEmpty()`, `.ThrowIfInactive()`, `.ThrowIfLessThanZero()`, `ValidationException` | -| `CoreEx.Results` | `Result.GoAsync()`, `.ThenAs()`, `.ThenAsAsync()`, `Result.BusinessError()`, `Result.NotFoundError()`, `Result.ValidationError()` | +| `CoreEx` | `Result`, `Result`, `Result.GoAsync()`, `.ThenAs()`, `.ThenAsAsync()`, `Result.BusinessError()`, `Result.NotFoundError()`, `Result.ValidationError()`, `Runtime.NewId()`, `.ThrowIfNull()`, `.ThrowIfNullOrEmpty()`, `.ThrowIfInactive()`, `.ThrowIfLessThanZero()`, `ValidationException` | ## Aggregates @@ -85,7 +84,7 @@ protected override void OnMutate() ### Public Mutation Methods -All public mutation methods must return `Result` or `Result` so the Application layer can compose them in pipelines. Use `Modify(...)` to apply the mutation, which enforces the `OnCheckCanMutate()` guard: +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(() => @@ -143,6 +142,8 @@ public sealed class BasketItem : Entity 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: @@ -173,7 +174,6 @@ public sealed record class ItemPricing public decimal Quantity { get; init => field = value.ThrowIfLessThanZero(); } public decimal Total => UnitPrice * Quantity; - // Additional validation that cannot be expressed in a single property rule. public ItemPricing EnsureIsValid() => DecimalRuleHelper.CheckScale(Quantity, UnitOfMeasure.Scale) ? this : throw new ValidationException($"Quantity decimal places exceed the unit-of-measure scale of {UnitOfMeasure.Scale}."); } @@ -189,19 +189,20 @@ Only introduce a Domain layer when the domain genuinely has: - 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 (like Products), skip the Domain layer entirely and let the Application service orchestrate directly against repository interfaces. +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. -- Do not throw exceptions for expected business failures in domain methods — return `Result.BusinessError(...)` or `Result.NotFoundError()` and let the Application layer propagate. +- 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 -- [`samples/docs/domain-layer.md`](../../../samples/docs/domain-layer.md) — aggregates, entities, value objects, and `PersistenceState` walkthrough. -- [`samples/docs/patterns.md`](../../../samples/docs/patterns.md) — Aggregate, Entity, and Value Object pattern entries with cross-links. -- [`samples/docs/layers.md`](../../../samples/docs/layers.md) — when to introduce the Domain layer and its position in the dependency graph. -- [`src/CoreEx.DomainDriven/README.md`](../../../src/CoreEx.DomainDriven/README.md) — `Aggregate`, `Entity`, and `PersistenceState`. +- [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/.github/instructions/coreex-event-subscribers.instructions.md b/.github/instructions/coreex-event-subscribers.instructions.md index 11a6675e..2d3c412b 100644 --- a/.github/instructions/coreex-event-subscribers.instructions.md +++ b/.github/instructions/coreex-event-subscribers.instructions.md @@ -10,11 +10,9 @@ tags: ["subscribers", "messaging", "service-bus", "event-handling", "integration | Package | Key types provided | |---|---| -| `CoreEx.Azure.Messaging.ServiceBus` | `SubscribedBase`, `SubscribedBase`, `[Subscribe(...)]`, `EventSubscriberArgs`, `ErrorHandler`, `ErrorHandling`, `ServiceBusSessionReceiverOptions`, `.AzureServiceBusReceiving()`, `.WithSessionReceiver()`, `.WithSubscribedSubscriber()`, `.WithHostedService()` | -| `CoreEx.Events` | `EventData`, `.Key`, `.Required()`, `.ToData()` | -| `CoreEx.Validation` | `IValidator` | -| `CoreEx.Results` | `Result`, `Result.Success` | -| `CoreEx` | `[ScopedService]`, `.ThrowIfNull()` | +| `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 @@ -29,35 +27,22 @@ All subscribers are decorated with `[ScopedService]` for automatic DI discovery. ### Untyped subscriber — `SubscribedBase` -Use when the relevant data is carried in the message key, not the payload: +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.reservation.confirm")] -public class ReservationConfirmSubscriber : SubscribedBase +[ScopedService, Subscribe("contoso.products.product.deleted")] +public class ProductDeleteSubscriber(IProductSyncAdapter adapter) : SubscribedBase { - internal static readonly ErrorHandler DefaultErrorHandler = new ErrorHandler() - .Add(ex => ex.ErrorCode == "pending-reservation-not-found" - ? ErrorHandling.CompleteAsInformation - : null); - - private readonly IMovementService _service; - - public ReservationConfirmSubscriber(IMovementService service) - { - _service = service.ThrowIfNull(); - ErrorHandler = DefaultErrorHandler; - } + private readonly IProductSyncAdapter _adapter = adapter.ThrowIfNull(); - protected async override Task OnReceiveAsync( + protected override Task OnReceiveAsync( EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default) - { - var referenceId = @event.Key.Required(); - await _service.ConfirmReservationAsync(referenceId).ConfigureAwait(false); - return Result.Success; - } + => _adapter.DeleteAsync(@event.Key.Required()); } ``` +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: @@ -80,22 +65,6 @@ public class ProductModifySubscriber(IProductSyncAdapter adapter) : SubscribedBa Multiple `[Subscribe]` attributes on a single class handle multiple subjects with the same logic — no duplication required. -### Key-only untyped subscriber - -When only the key is needed (no payload), use `SubscribedBase` and extract the key directly: - -```csharp -[ScopedService, Subscribe("contoso.products.product.deleted.v1")] -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()); -} -``` - ## Subject Naming Use dot-separated lowercase subject strings: @@ -104,15 +73,31 @@ Use dot-separated lowercase subject strings: {solution}.{domain}.{entity}.{action}[.v{n}] ``` -- **Domain events** (published from the outbox) include a version suffix: `contoso.products.product.created.v1` -- **Command messages** (point-to-point, no versioning semantics): `contoso.products.reservation.confirm` +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` -- `contoso.products.product.updated.v1` -- `contoso.products.product.deleted.v1` -- `contoso.products.reservation.confirm` -- `contoso.products.reservation.cancel` +- `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 @@ -132,11 +117,18 @@ Share the same `ErrorHandler` instance across related subscribers (e.g., both Co ## Accessing Event Data ```csharp -var key = @event.Key.Required(); // Key from the message — throws if missing -var value = @event.ToData(); // Deserialize typed payload from untyped subscriber +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. +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 @@ -151,8 +143,7 @@ builder.Services .AddHttpWebApi() .AddHostedServiceManager(); -// Discover all [ScopedService] types in the subscriber, ref-data, and repository assemblies -builder.Services.AddDynamicServicesUsing(); +builder.Services.AddDynamicServicesUsing(); // 2. Caching — L1 memory cache + L2 Redis + FusionCache hybrid + idempotency provider builder.Services.AddMemoryCache(); @@ -160,33 +151,32 @@ builder.AddRedisDistributedCache("redis"); builder.Services.AddFusionCache() .WithRegisteredMemoryCache() .WithRegisteredDistributedCache() - .WithBackplane(sp => new RedisBackplane(new RedisBackplaneOptions { Configuration = sp.GetRequiredService>().Value.ToString() })) + .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 (Shopping) variant: +// SQL Server variant: builder.AddSqlServerClient("SqlServer"); builder.Services .AddSqlServerDatabase() .AddSqlServerUnitOfWork() - .AddSqlServerOutboxPublisher() // <-- outbox publisher becomes the default IEventPublisher - .AddDbContext() - .AddEfDb(); - -// PostgreSQL (Products) variant: -builder.AddAzureNpgsqlDataSource("Postgres"); -builder.Services - .AddPostgresDatabase() - .AddPostgresUnitOfWork() - .AddEventFormatter() // <-- required for message formatting for publishing - .AddPostgresOutboxPublisher() // <-- outbox publisher becomes the default IEventPublisher - .AddDbContext() - .AddEfDb(); + .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"); @@ -195,13 +185,10 @@ builder.Services.AddAzureServiceBusPublisher((_, c) => c.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId; }, addAsDefaultIEventPublisher: false); // false because outbox publisher is already the default -// 5. Event formatter + subscriber manager (Shopping only — Products included AddEventFormatter earlier) +// 5. Event formatter + subscriber manager builder.Services - .AddEventFormatter() // Adds the EventFormatter to enable message parsing. - .AddSubscribedManager((_, c) => c.AddSubscribersUsing()); // Adds the SubscribedManager and dynamically links to the individual Subscribers. - -// Products variant (AddEventFormatter already called): -builder.Services.AddSubscribedManager((_, c) => c.AddSubscribersUsing()); + .AddEventFormatter() + .AddSubscribedManager((_, c) => c.AddSubscribersUsing()); // 6. Azure Service Bus receiver wiring builder.Services.AzureServiceBusReceiving() @@ -215,7 +202,7 @@ builder.Services.AzureServiceBusReceiving() .WithHostedService() // runs the receiver as a BackgroundService .Build(); -// 7. External API clients (if needed — Shopping only) +// 7. External API clients (if needed — for domains with inter-domain HTTP calls) builder.AddTypedHttpClient("ProductsApi"); // 8. Health checks, OpenTelemetry @@ -229,7 +216,7 @@ builder.Services.AddOpenApiDocument(s => builder.WithCoreExTelemetry() .WithCoreExServiceBusTelemetry() - .WithCoreExSqlServerTelemetry() // or .WithCoreExPostgresTelemetry() for Products + .WithCoreExSqlServerTelemetry() // or .WithCoreExPostgresTelemetry() for PostgreSQL .UseOtlpExporter(); // 9. Build and middleware pipeline @@ -263,6 +250,6 @@ app.Run(); ## Further Reading -- [`samples/docs/hosts-layer.md`](../../../samples/docs/hosts-layer.md#subscribe-host) — Subscribe host architecture, Program.cs shape, and subscriber patterns. -- [`samples/docs/patterns.md`](../../../samples/docs/patterns.md) — Subscribe, Publish, Transactional Outbox, and Event-Driven Replication pattern entries. -- [`src/CoreEx.Azure.Messaging.ServiceBus/README.md`](../../../src/CoreEx.Azure.Messaging.ServiceBus/README.md) — `SubscribedBase`, `ErrorHandler`, and Service Bus receiver configuration. +- [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/.github/instructions/coreex-host-setup.instructions.md b/.github/instructions/coreex-host-setup.instructions.md index db3d80f6..e66d9d32 100644 --- a/.github/instructions/coreex-host-setup.instructions.md +++ b/.github/instructions/coreex-host-setup.instructions.md @@ -1,14 +1,14 @@ --- applyTo: "**/Program.cs" -description: "Host setup conventions for Program.cs: API host, Subscribe host, middleware, service registration, and distributed caching" +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. Each follows the same opening skeleton, then diverges based on its responsibilities. +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**: [`samples/docs/hosts-layer.md`](../../samples/docs/hosts-layer.md) · [`samples/docs/layers.md`](../../samples/docs/layers.md) · [`samples/docs/patterns.md`](../../samples/docs/patterns.md) +> **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) --- @@ -88,15 +88,26 @@ builder.Services .AddDefaultCacheKeyProvider() .AddHybridCacheIdempotencyProvider(); -// Database, EF, outbox publisher (SQL Server example; use Postgres equivalents for Products). +// Database, EF, outbox publisher. +// SQL Server variant: builder.AddSqlServerClient("SqlServer"); builder.Services .AddSqlServerDatabase() .AddSqlServerUnitOfWork() .AddEventFormatter() .AddSqlServerOutboxPublisher() - .AddDbContext() - .AddEfDb(); + .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(); @@ -121,8 +132,7 @@ Key points: - `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()` (no generic type parameter). -- Products uses `AddPostgresDatabase()` / `AddPostgresUnitOfWork()` / `AddPostgresOutboxPublisher()` / `WithCoreExPostgresTelemetry()` instead of the SQL Server variants. +- `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`. @@ -156,16 +166,26 @@ builder.Services .AddDefaultCacheKeyProvider() .AddHybridCacheIdempotencyProvider(); -// Domain database + outbox publisher (for writes triggered by inbound events). +// Domain database + outbox publisher. +// SQL Server variant: builder.AddSqlServerClient("SqlServer"); builder.Services .AddSqlServerDatabase() .AddSqlServerUnitOfWork() .AddSqlServerOutboxPublisher() - .AddDbContext() - .AddEfDb(); - -// Service Bus: outbox publisher is the default IEventPublisher. + .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) => { @@ -192,7 +212,10 @@ builder.Services.PostConfigureAllHealthChecks(); builder.Services.AddControllers(); builder.Services.AddOpenApiDocument(s => { s.Title = builder.Environment.ApplicationName; s.AddCoreExConfiguration(); }); -builder.WithCoreExTelemetry().WithCoreExServiceBusTelemetry().WithCoreExSqlServerTelemetry().UseOtlpExporter(); +builder.WithCoreExTelemetry() + .WithCoreExServiceBusTelemetry() + .WithCoreExSqlServerTelemetry() // or .WithCoreExPostgresTelemetry() for PostgreSQL + .UseOtlpExporter(); var app = builder.Build(); app.UseCoreExExceptionHandler(); @@ -210,11 +233,10 @@ app.Run(); Key points: - Subscribe hosts **do** include `AddReferenceDataOrchestrator()` and `AddDynamicServicesUsing<...>()` — subscribers call application services that need reference data for validation and business logic. - 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 as part of their message-processing logic. +- 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. -- `AddEventFormatter()` is required for message parsing and formatting. - `MapHostedServices()` must come **after** `MapHealthChecks()`. --- @@ -231,7 +253,7 @@ builder.Services .AddHttpWebApi() .AddHostedServiceManager(); -// Database + outbox relay (SQL Server example; use Postgres equivalents for Products). +// SQL Server example; use Postgres equivalents for PostgreSQL domains. builder.AddSqlServerClient("SqlServer"); builder.Services .AddSqlServerDatabase() @@ -240,6 +262,14 @@ builder.Services 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) => @@ -263,7 +293,5 @@ 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()` registers the background relay pump — call these on `builder`, not `builder.Services`. -- No `AddControllers()`, no `AddOpenApiDocument()`, no `UseOpenApi()`, no `UseSwaggerUi()`, no `UseIdempotencyKey()`. -- `UseAuthorization()` is also omitted in the Relay host. -- Products uses `AddAzureNpgsqlDataSource("Postgres")` + `AddPostgresDatabase()` / `AddPostgresUnitOfWork()` / `AddPostgresOutboxRelay()` / `AddPostgresOutboxRelayHostedService()` / `WithCoreExPostgresTelemetry()` instead of the SQL Server variants. +- `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/.github/instructions/coreex-repositories.instructions.md b/.github/instructions/coreex-repositories.instructions.md index 4480686d..cad217da 100644 --- a/.github/instructions/coreex-repositories.instructions.md +++ b/.github/instructions/coreex-repositories.instructions.md @@ -10,20 +10,28 @@ tags: ["repositories", "infrastructure", "data-access", "efcore", "mapping", "ad | Package | Key types provided | |---|---| -| `CoreEx` | `[ScopedService]`, `.ThrowIfNull()`, `EventData` | -| `CoreEx.EntityFrameworkCore` | `EfDb`, `EfDbModel`, `EfDbMappedModel`, `EfDbOptions`, `.GetAsync()`, `.CreateAsync()`, `.UpdateAsync()`, `.DeleteAsync()`, `.GetWithResultAsync()`, `.CreateWithResultAsync()`, `.UpdateWithResultAsync()`, `.Query()` | -| `CoreEx.Database.SqlServer` | SQL Server outbox publisher, ADO.NET helpers — **Shopping only** | -| `CoreEx.Database.Postgres` | PostgreSQL outbox publisher, ADO.NET helpers — **Products only** | -| `CoreEx.Data` | `DataResult`, `ItemsResult`, `QueryArgsConfig`, `QueryFilterOperator`, `.Where(parsed)`, `.OrderBy(parsed)`, `.ToMappedItemsResultAsync()` | -| `CoreEx.Results` | `Result`, `.GoAsync()`, `.ThenAs()`, `.ThenAsAsync()` | +| `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**: Products uses PostgreSQL (`CoreEx.Database.Postgres` + `Npgsql.EntityFrameworkCore.PostgreSQL`); Shopping uses SQL Server (`CoreEx.Database.SqlServer` + `Microsoft.EntityFrameworkCore.SqlServer`). Layers above Infrastructure are database-agnostic. +> **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 -- Define the repository interface in the Application project under `Application/Repositories/`. -- Implement in the Infrastructure project under `Repositories/`. Register with `[ScopedService]`. -- Inject the domain's `*EfDb` class via primary constructor and guard with `.ThrowIfNull()`. +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] @@ -41,11 +49,33 @@ public class ProductRepository(ProductsEfDb ef) : IProductRepository | Create / Update | `Task>` | Includes mutation flag for event decisions | | Delete | `Task` | Carries mutation flag only | | Collection query | `Task>` | Items + optional total count | -| Domain aggregate (Shopping) | `Task>` | Wraps EF result with domain-model mapping | +| Result pipeline (optional) | `Task>` | Developer choice — can be used on any repository method; enables explicit failure propagation without exceptions | -## EfDb — Unit-of-Work Facade +## EfDb and DbContext -The `*EfDb` sub-class (`ProductsEfDb` / `ShoppingEfDb`) is the **unit-of-work facade** over the `DbContext`. It declares a typed property per entity model and configures global options (e.g., logical-delete filters). The `DbContext` delegates connection and transaction management to CoreEx's `IDatabase`, so the same connection is shared across a request. +### 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) + { + base.OnModelCreating(modelBuilder); + modelBuilder.AddGeneratedModels(); // wires in the generated *DbContext.g.cs ModelBuilder configurations + } +} +``` + +`*DbContext.g.cs` (generated by the `*.Database` tooling) contains the `ModelBuilder` entity configurations. The hand-authored `*DbContext.cs` must call `modelBuilder.AddGeneratedModels()` in `OnModelCreating` to apply them — without this call the generated configuration is not wired up. + +### 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) @@ -62,6 +92,10 @@ public sealed class ProductsEfDb(ProductsDbContext dbContext) : EfDb **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: @@ -131,9 +165,9 @@ Expose the query schema for the `$query` endpoint via `ToJsonSchema()`: public Task QuerySchemaAsync() => Task.FromResult(_queryConfig.ToJsonSchema()); ``` -## Domain-Aggregate Repositories (Result Pattern) +## Result<T> Pipeline in Repositories -For Shopping-style aggregate repositories, chain `Result` operations using `.GoAsync` / `.ThenAs` / `.ThenAsAsync`. Map between persistence models and domain aggregates using the explicit infrastructure mappers: +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) => Result @@ -177,14 +211,14 @@ public class ProductMapper : BiDirectionMapper`. Do not conflate the two. +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 (e.g., reading from the local event-replicated store) and event publication. +- **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 @@ -208,7 +242,7 @@ public class ProductAdapter(ShoppingEfDb ef, ProductsHttpClient client, IEventPu .GoAsync(() => _ef.Products.GetWithResultAsync(id)) .ThenAs(p => ProductMapper.From.Map(p)); - // ReserveInventoryAsync — calls the Products API in real time (synchronous integration). + // ReserveInventoryAsync — calls the remote API in real time (synchronous integration). public async Task ReserveInventoryAsync(Domain.Basket basket) => await _client.CreateReservationAsync(BuildRequest(basket)).ConfigureAwait(false); } @@ -218,12 +252,7 @@ Keep the typed HTTP client and the adapter orchestration in separate, independen ## 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. - -| File pattern | Generator | Change instead | -|---|---|---| -| `Persistence/*.g.cs` | `*.Database` project (DbEx) | DbEx YAML config or SQL migration scripts | -| `*DbContext.g.cs` | `*.Database` project (DbEx) | DbEx YAML config | +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 @@ -240,8 +269,9 @@ Always call `.ConfigureAwait(false)` on every `await` inside repository and adap ## Further Reading -- [`samples/docs/infrastructure-layer.md`](../../../samples/docs/infrastructure-layer.md) — full walkthrough of persistence models, repositories, mapping, and external client/adapter patterns. -- [`samples/docs/patterns.md`](../../../samples/docs/patterns.md) — Adapter, Repository, Mapper, Persistence, and HTTP Client pattern entries with cross-links. -- [`samples/docs/layers.md`](../../../samples/docs/layers.md) — layer dependency rules and the role of the Infrastructure layer. -- [`samples/docs/tooling.md`](../../../samples/docs/tooling.md) — `*.Database` project: schema, persistence-model generation, and outbox provisioning. -- [`src/CoreEx.EntityFrameworkCore/README.md`](../../../src/CoreEx.EntityFrameworkCore/README.md) — `EfDb`, `EfDbModel`, `EfDbMappedModel`, and `EfDbOptions`. +- [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/.github/instructions/coreex-tests.instructions.md b/.github/instructions/coreex-tests.instructions.md index 960e6d74..ada2ee3e 100644 --- a/.github/instructions/coreex-tests.instructions.md +++ b/.github/instructions/coreex-tests.instructions.md @@ -10,7 +10,7 @@ tags: ["testing", "unit-tests", "integration-tests", "test-helpers", "nunit"] | 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/extensions: `.ExpectIdentifier()`, `.ExpectETag()`, `.ExpectChangeLogCreated()`, `.ExpectJsonFromResource()`, `.AssertCreated()`, `.AssertOK()`, `.AssertBadRequest()`, `.AssertErrors()`, `.AssertJsonFromResource()`, `.AssertLocationHeader()` | +| `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()` | @@ -35,9 +35,9 @@ Every integration test class must have a `[OneTimeSetUp]` method. Order of opera 1. Migrate + seed the domain database. 2. Clear the hybrid cache. 3. Register event-capture publishers. -4. Set up inter-domain HTTP mocks (Shopping only). +4. Set up inter-domain HTTP mocks (for domains with cross-domain adapters). -**Shopping (SQL Server):** +**(SQL Server example):** ```csharp [OneTimeSetUp] public async Task OneTimeSetUpAsync() @@ -55,7 +55,7 @@ public async Task OneTimeSetUpAsync() } ``` -**Products (Postgres):** +**(PostgreSQL example):** ```csharp [OneTimeSetUp] public async Task OneTimeSetUpAsync() @@ -67,9 +67,9 @@ public async Task OneTimeSetUpAsync() } ``` -**Outbox assertion helpers are database-specific.** Use `UseExpectedPostgresOutboxPublisher` / `ExpectPostgresOutboxEvents` for Products; use `UseExpectedSqlServerOutboxPublisher` / `ExpectSqlServerOutboxEvents` for Shopping. Never mix them. +**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 — Products and Shopping test runs do not corrupt each other even when run concurrently. +`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. --- @@ -96,7 +96,7 @@ Test.Http() .AssertOK() .AssertJsonFromResource("ReadTests.Product_Get_Found.res.json", "etag", "changelog"); -// POST — Products (Postgres outbox) +// POST — PostgreSQL domain (Postgres outbox) var created = Test.Http() .ExpectIdentifier() .ExpectETag() @@ -109,7 +109,7 @@ var created = Test.Http() .AssertLocationHeader(r => new Uri($"/api/products/{r!.Id}", UriKind.Relative)) .Value!; -// POST — Shopping (SQL Server outbox) +// POST — SQL Server domain (SQL Server outbox) Test.Http() .ExpectSqlServerOutboxEvents(e => e .AssertWithValue("contoso", "contoso.shopping.basket.checkedout.v1")) @@ -138,7 +138,7 @@ Expected response bodies live in `Resources/` as `.res.json` files. Reference th .AssertJsonFromResource("Basket_Checkout_Insufficient_Quantity.products.res.json", "traceid"); ``` -Mock request bodies use `.req.json`; mock response bodies from a downstream API use `.products.res.json` (by convention, prefixed with the remote domain name). +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). --- @@ -218,7 +218,7 @@ public class InventoryValidatorTests : WithGenericTester 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 +public class ProductModifySubscriberTests : WithApiTester { [OneTimeSetUp] public async Task OneTimeSetUpAsync() @@ -231,21 +231,6 @@ public class ProductModifySubscriberTests : WithApiTester -{ - [OneTimeSetUp] - public async Task OneTimeSetUpAsync() - { - await Test.MigratePostgresDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); - await Test.ClearFusionCacheAsync().ConfigureAwait(false); - - Test.UseExpectedPostgresOutboxPublisher(); - } -} -``` - --- ## Outbox Relay Host Tests @@ -253,7 +238,7 @@ public partial class SubscriberTests : 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 +public class RelayTests : WithApiTester { [Test] public async Task Outbox_Relay() @@ -309,9 +294,9 @@ 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 Products tests — use the Postgres equivalents. -- Do not use `UseExpectedPostgresOutboxPublisher` / `ExpectPostgresOutboxEvents` in Shopping tests — use the SQL Server equivalents. -- Do not call `ClearFusionCacheAsync()` in Outbox Relay host tests — relay hosts have no cache (they are minimal forwarding infrastructure only). +- 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). @@ -319,5 +304,5 @@ Basket_Checkout_Insufficient_Quantity ## Further Reading -- [`samples/docs/testing.md`](../../samples/docs/testing.md) — full test architecture, data seeding, schema isolation, and E2E runner. -- [`samples/docs/patterns.md`](../../samples/docs/patterns.md) — pattern catalog linking testing patterns to layer docs. +- [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/.github/instructions/coreex-tooling.instructions.md b/.github/instructions/coreex-tooling.instructions.md index b9567c90..aecfa2a2 100644 --- a/.github/instructions/coreex-tooling.instructions.md +++ b/.github/instructions/coreex-tooling.instructions.md @@ -65,7 +65,7 @@ entities: excludeContract: true # exclude from generated contract (present in persistence model only) ``` -The full schema reference is in [`src/CoreEx.CodeGen/docs/`](../../../src/CoreEx.CodeGen/docs/). Add the `$schema` annotation to the file for IDE YAML validation and auto-complete. +Add the `$schema` annotation to the file for IDE YAML validation and auto-complete. --- @@ -75,24 +75,24 @@ The full schema reference is in [`src/CoreEx.CodeGen/docs/`](../../../src/CoreEx | Package | Use case | |---|---| -| `DbEx.SqlServer` + `DbEx.SqlServer.Console` | SQL Server domains (e.g. Shopping) | -| `DbEx.Postgres` + `DbEx.Postgres.Console` | PostgreSQL domains (e.g. Products) | +| `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**: Products uses `PostgresMigrationConsole` with `.pgsql` scripts and PostgreSQL functions; Shopping uses `SqlServerMigrationConsole` with `.sql` scripts and stored procedures. Choose the correct package per domain. +> **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 ```csharp -// PostgreSQL domain (Products) +// PostgreSQL domain example public static Task Main(string[] args) => PostgresMigrationConsole - .Create("Server=127.0.0.1;Database=contoso;Username=postgres;Password=...") + .Create("Server=127.0.0.1;Database=mydb;Username=postgres;Password=...") .Configure(c => ConfigureMigrationArgs(c.Args)) .RunAsync(args); -// SQL Server domain (Shopping) +// SQL Server domain example public static Task Main(string[] args) => SqlServerMigrationConsole - .Create("Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=...") + .Create("Data Source=127.0.0.1,1433;Initial Catalog=MyDb;User id=sa;Password=...") .Configure(c => ConfigureMigrationArgs(c.Args)) .RunAsync(args); @@ -101,8 +101,8 @@ public static MigrationArgs ConfigureMigrationArgs(MigrationArgs args) args.AddAssembly().AddAssembly() .IncludeExtendedSchemaScripts() .DataParserArgs - .RefDataColumnDefault("SortOrder", _ => 0) - .RefDataColumnDefault("Scale", _ => 0); + .RefDataColumnDefault("SortOrder", _ => 0) // standard — always include; defaults every ref-data row's SortOrder to zero + .RefDataColumnDefault("Scale", _ => 0); // domain-specific example — only needed when a ref-data entity has a Scale column (e.g. UnitOfMeasure); omit if not applicable // Scope data reset to this domain's schema only. args.DataResetFilterPredicate = ts => ts.Schema == "{domain-schema}"; @@ -136,11 +136,16 @@ Composite commands for common scenarios: ### `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 # database schema name (omit for SQL Server PascalCase schemas) +schema: products # snake_case schema name outbox: true # generate full transactional outbox infrastructure -outboxName: outbox # prefix for outbox tables and procedures/functions +outboxName: outbox # prefix for outbox tables and functions tables: # Reference-data tables - name: brand @@ -151,7 +156,23 @@ tables: - name: inventory ``` -Add the `$schema` annotation for IDE YAML validation and auto-complete. +```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. ### `CodeGen` phase — generated Infrastructure C# @@ -187,7 +208,8 @@ SQL conventions: - Wrap each script in `BEGIN TRANSACTION ... COMMIT TRANSACTION` (SQL Server) or equivalent. - Use explicit schema-qualified names. - Include `CreatedBy`, `CreatedOn`, `UpdatedBy`, `UpdatedOn` audit columns on aggregate tables. -- Use `TIMESTAMP` / `ROWVERSION` for optimistic-concurrency columns mapped to `ETag`. +- **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. ### `Schema` — idempotent objects @@ -206,7 +228,9 @@ These `.g.sql` / `.g.pgsql` files are generated by DbEx — never edit them dire ### `Data` — seeding -Seed data lives in `Data/ref-data.yaml`. The root node is the schema/domain name. DbEx infers column types from the live schema. +Seed data in `Data/ref-data.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. + +The root node is the schema/domain name. DbEx infers column types from the live schema. Prefixes control merge behaviour and identifier generation: @@ -238,7 +262,7 @@ products: ## Further Reading -- [`samples/docs/tooling.md`](../../../samples/docs/tooling.md) — full `*.CodeGen` and `*.Database` walkthrough with command reference. -- [`src/CoreEx.CodeGen/docs/`](../../../src/CoreEx.CodeGen/docs/) — `ref-data.yaml` schema: `CodeGeneration.md`, `Entity.md`, `Property.md`. +- [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/.github/instructions/coreex-validators.instructions.md b/.github/instructions/coreex-validators.instructions.md index 2a620e63..7702ae6c 100644 --- a/.github/instructions/coreex-validators.instructions.md +++ b/.github/instructions/coreex-validators.instructions.md @@ -10,9 +10,9 @@ tags: ["validators", "validation", "fluent-api", "rules", "error-handling", "app | 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()`, `.AssertErrors()` (test helper) | -| `CoreEx` | `LText` — localised text label for use in `.WithKeyValidator(label, ...)` and similar | -| `CoreEx.Localization` | `[Localization(...)]` attribute on contract properties | +| `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 @@ -199,6 +199,6 @@ Property(x => x.Quantity, c => c ## Further Reading -- [`samples/docs/application-layer.md`](../../../samples/docs/application-layer.md#validators) — full validator walkthrough including declarative and programmatic phases. -- [`samples/docs/patterns.md`](../../../samples/docs/patterns.md) — Validator pattern entry with cross-links. -- [`src/CoreEx.Validation/README.md`](../../../src/CoreEx.Validation/README.md) — `Validator`, rule set, `OnValidateAsync`, `ValidateFurtherAsync`, and `AbstractValidator`. +- [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/.github/skills/add-capability/SKILL.md b/.github/skills/add-capability/SKILL.md index 49a9d50e..f48f4119 100644 --- a/.github/skills/add-capability/SKILL.md +++ b/.github/skills/add-capability/SKILL.md @@ -43,8 +43,8 @@ For detailed step-by-step workflow, see [`references/workflow.md`](references/wo ## Key References -- [Host Setup Conventions](/.github/instructions/host-setup.instructions.md) -- [Event Subscriber Conventions](/.github/instructions/event-subscribers.instructions.md) -- [Application Service Conventions](/.github/instructions/application-services.instructions.md) -- [Developer Tooling Conventions](/.github/instructions/tooling.instructions.md) +- [Host Setup Conventions](/.github/instructions/coreex-host-setup.instructions.md) +- [Event Subscriber Conventions](/.github/instructions/coreex-event-subscribers.instructions.md) +- [Application Service Conventions](/.github/instructions/coreex-application-services.instructions.md) +- [Developer Tooling Conventions](/.github/instructions/coreex-tooling.instructions.md) - Sample hosts: `samples/src/Contoso.Products.Api/Program.cs`, `samples/src/Contoso.Products.Subscribe/Program.cs`, `samples/src/Contoso.Products.Outbox.Relay/Program.cs` diff --git a/.github/skills/add-capability/references/workflow.md b/.github/skills/add-capability/references/workflow.md index f6bc5314..c766597a 100644 --- a/.github/skills/add-capability/references/workflow.md +++ b/.github/skills/add-capability/references/workflow.md @@ -5,10 +5,10 @@ Before making changes, load: 1. Instruction files in `/.github/instructions/`: - - `host-setup.instructions.md` - - `event-subscribers.instructions.md` - - `application-services.instructions.md` - - `tooling.instructions.md` + - `coreex-host-setup.instructions.md` + - `coreex-event-subscribers.instructions.md` + - `coreex-application-services.instructions.md` + - `coreex-tooling.instructions.md` 2. Sample host wiring from: - `samples/src/Contoso.Products.Api/Program.cs` diff --git a/README.md b/README.md index 321eeb4f..7a3cb0a4 100644 --- a/README.md +++ b/README.md @@ -66,13 +66,15 @@ The [Pattern Catalog](./samples/docs/patterns.md) is the best entry point: it in → **[View the full pattern catalog](./samples/docs/patterns.md)** -## Version 4 +## Version 4 (preview) This is a **major** version release; a re-imagine / re-invention of the existing capabilities to enable a more modern, flexible and maintainable codebase. - This release contains **significant breaking changes** - there is **no** upgrade path from the previous `v3.x` versions; however, the core capabilities and patterns remain largely consistent. - A number of capabilities have been removed as they were not widely used, considered legacy/obsolete, or there are better alternatives available. - Not all existing capabilities have been re-implemented in this release; the intention is to (re-)add further capabilities in future releases as required. +Version 4 is currently in **preview**; the packages are published with a `-preview` suffix and may contain future breaking changes. The packages in their current state can be used for Production-based solutions. Feedback is very welcome to help shape the final release. + ## Status The build status is [![CI](https://github.com/Avanade/CoreEx/actions/workflows/CI.yml/badge.svg)](https://github.com/Avanade/CoreEx/actions/workflows/CI.yml) with the NuGet package status as follows, including links to the underlying source code and documentation: diff --git a/consumer-ai-context/.github/copilot-instructions.md b/consumer-ai-context/.github/copilot-instructions.md new file mode 100644 index 00000000..f7b0525b --- /dev/null +++ b/consumer-ai-context/.github/copilot-instructions.md @@ -0,0 +1,127 @@ +# CoreEx — AI Coding Context + +## What CoreEx Is + +CoreEx is a modular .NET framework for enterprise back-end services. It provides opinionated primitives, patterns, and extensions for HTTP API development, data access, event-driven messaging, validation, reference data, caching, and testing. Add it via NuGet — favor CoreEx-native primitives over ad-hoc implementations wherever a CoreEx type or extension exists. + +## Package Map + +| NuGet Package | Capability | +|---|---| +| `CoreEx` | Core: exceptions, `Result`, execution context, DI helpers, entity markers, JSON, mapping | +| `CoreEx.AspNetCore` | HTTP API: `WebApi` helpers, idempotency, health checks, middleware | +| `CoreEx.AspNetCore.NSwag` | OpenAPI / NSwag integration | +| `CoreEx.Validation` | Fluent validation: `Validator`, `AbstractValidator`, rules | +| `CoreEx.EntityFrameworkCore` | EF Core: `EfDb`, `EfDbModel`, `EfDbMappedModel` | +| `CoreEx.Database` | Core database abstractions: `IDatabase`, `IUnitOfWork` | +| `CoreEx.Database.SqlServer` | SQL Server: unit of work, outbox publisher, outbox relay | +| `CoreEx.Database.Postgres` | PostgreSQL: unit of work, outbox publisher, outbox relay | +| `CoreEx.Events` | Event publishing/subscribing: `EventData`, `IEventPublisher` | +| `CoreEx.Azure.Messaging.ServiceBus` | Azure Service Bus: subscriber base classes, publisher, receiver wiring | +| `CoreEx.RefData` | Reference data: `ReferenceData`, `ReferenceDataOrchestrator` | +| `CoreEx.Caching.FusionCache` | Hybrid L1/L2 cache: FusionCache + Redis backplane | +| `CoreEx.DomainDriven` | DDD: `Aggregate`, `Entity`, `PersistenceState` | +| `CoreEx.UnitTesting` | Test helpers: `WithApiTester`, `WithGenericTester`, `Test.Http()` | +| `CoreEx.CodeGen` | Design-time: generates reference-data C# artefacts from `ref-data.yaml` | +| `CoreEx.Generator` | Roslyn source generator: emits members for `[Contract]`-decorated types | + +## Recommended Layer Structure + +CoreEx does not impose a specific folder or project structure. The following layers are a recommended pattern, not a requirement. Introduce only the layers your domain needs. + +| Layer | Typical project suffix | Notes | +|---|---|---| +| Contracts | `*.Contracts` | DTOs, entity marker interfaces, reference-data types | +| Application | `*.Application` | Services, validators, repository interfaces, adapters, policies | +| Domain | `*.Domain` | Aggregates, entities, value objects — optional; introduce only when needed | +| Infrastructure | `*.Infrastructure` | EF repositories, persistence models, typed HTTP clients, adapter impls | +| API host | `*.Api` | HTTP composition root — controllers and `Program.cs` | +| Subscribe host | `*.Subscribe` | Message consumer composition root | +| Outbox Relay host | `*.Outbox.Relay` | Relay background service composition root | +| Database tooling | `*.Database` | Design-time: schema, migrations, outbox provisioning (no runtime presence) | +| CodeGen tooling | `*.CodeGen` | Design-time: reference-data C# generation (no runtime presence) | + +The Domain layer is **optional**. Introduce it only when the domain has aggregates with non-trivial invariants enforced at the model level. Simple CRUD-oriented domains can skip it entirely. + +## Universal Rules + +### Always Prefer CoreEx Primitives + +- Use CoreEx exception types — `NotFoundException`, `ValidationException`, `BusinessException`, `ConcurrencyException`, `AuthenticationException`, `AuthorizationException`. These auto-map to correct HTTP status codes via `UseCoreExExceptionHandler()`. +- Use `Result` pipelines for multi-step orchestration, aggregate-oriented flows, and subscriber handlers. +- Use exception-based flow for simpler CRUD-oriented services where pipeline composition adds no value. +- Use `WebApi` helpers in controllers — never return typed `ActionResult` directly. +- Use `[ScopedService]` on service and repository classes for automatic DI discovery — avoid manual `services.AddScoped<>()` registration. +- Use `AddDynamicServicesUsing()` in `Program.cs` to discover and register all `[ScopedService]`-decorated types from the specified assemblies. + +### Mapping + +- Never use AutoMapper. Use explicit mappers: + - `BiDirectionMapper` — Contract ↔ Persistence model (Infrastructure layer). + - `Mapper` — Domain aggregate ↔ Contract (Application layer, only when a Domain layer exists). +- Do not conflate the two mapping layers. Infrastructure mapping is a persistence concern; Application mapping is a domain surface concern. + +### Async + +- Always call `.ConfigureAwait(false)` on every `await` in service, repository, adapter, and validator methods. + +### Dependency Direction (strict — enforce at all times) + +- Application layer depends inward only: on Contracts and its own interfaces. It must never reference the Infrastructure project. +- Infrastructure implements Application interfaces — never the reverse. +- Do not call `HttpClient` directly from services or adapters — use typed HTTP client classes in `Infrastructure/Clients/`. +- Do not put business logic in controllers or subscribers — delegate immediately to Application-layer services. +- Do not inject `IUnitOfWork` into controllers — it belongs in application services. + +### Events and Unit of Work + +- Always wrap database writes **and** event publication together inside `_unitOfWork.TransactionAsync(...)`. Both are committed atomically or not at all. +- Never publish events outside of `_unitOfWork.TransactionAsync(...)`. + +### Generated Code + +Never create or edit the following file types directly — they are owned by tooling: + +| Pattern | Owner | +|---|---| +| `*.g.cs` | `CoreEx.Generator` (contracts) or `*.CodeGen` / `*.Database` tooling | +| `*.g.sql` / `*.g.pgsql` | `*.Database` tooling (outbox schema objects) | + +Regenerate by re-running the relevant tooling project (`dotnet run` in `*.CodeGen` or `*.Database`). + +## Layer Dependency Summary + +``` +Host (Api / Subscribe / Relay) ← composition root only, no business logic + └─ Application + ├─ Contracts + └─ Domain (optional) + Infrastructure ← implements Application interfaces + └─ Contracts +``` + +Hosts depend on all layers to compose the application. They contain no business logic. + +## Capability-Specific Guidance + +File-scoped instruction files provide detailed guidance when you edit matching file types. Copy the relevant files from the CoreEx repo's `.github/instructions/` into your project's `.github/instructions/` folder. + +| When editing | Guidance provided by | +|---|---| +| `**/Controllers/**/*.cs` | `coreex-api-controllers.instructions.md` | +| `**/Contracts/**/*.cs` | `coreex-contracts.instructions.md` | +| `**/Application/**/*.cs` | `coreex-application-services.instructions.md` | +| `**/Infrastructure/**/*.cs` | `coreex-repositories.instructions.md` | +| `**/*Validator*.cs` | `coreex-validators.instructions.md` | +| `**/Program.cs` | `coreex-host-setup.instructions.md` | +| `**/Subscribe/**/*.cs` | `coreex-event-subscribers.instructions.md` | +| `**/Domain/**/*.cs` | `coreex-domain.instructions.md` | +| `**/*.Test*/**/*.cs` | `coreex-tests.instructions.md` | +| `**/*.CodeGen/**` or `**/*.Database/**` | `coreex-tooling.instructions.md` | + +## Further Reading + +- [CoreEx on GitHub](https://github.com/Avanade/CoreEx) +- [Capabilities Guide](https://github.com/Avanade/CoreEx/blob/main/docs/capabilities.md) — deep capability and pattern explanations +- [Application Scaffolding Guide](https://github.com/Avanade/CoreEx/blob/main/docs/application-scaffolding-guide.md) — deciding what to scaffold for a new service +- [Sample Reference Architecture](https://github.com/Avanade/CoreEx/tree/main/samples) — complete Contoso domains demonstrating all patterns end-to-end diff --git a/consumer-ai-context/README.md b/consumer-ai-context/README.md new file mode 100644 index 00000000..3b7f53e3 --- /dev/null +++ b/consumer-ai-context/README.md @@ -0,0 +1,60 @@ +# CoreEx Consumer AI Context + +This folder contains AI context files for developers building services that consume the CoreEx NuGet packages. Drop these files into your own project to give GitHub Copilot (and Claude Code) accurate, CoreEx-specific guidance. + +## What's Included + +| Path | Purpose | +|---|---| +| `.github/copilot-instructions.md` | Global CoreEx context applied to every Copilot interaction | + +The per-capability instruction files live in the repo's root [`.github/instructions/`](../.github/instructions/) directory and are shared between framework developers and consumers — no separate consumer copies. + +## Setup + +### GitHub Copilot + +1. Copy `consumer-ai-context/.github/copilot-instructions.md` to your project's `.github/copilot-instructions.md`. +2. Copy the instruction files from the repo's `.github/instructions/` that match what you're building into your project's `.github/instructions/` folder. + +Copilot applies the global instructions to every chat interaction and injects the file-scoped instructions automatically based on which file is open. + +### Claude Code + +1. Follow the Copilot setup steps above. +2. Create a `CLAUDE.md` at your project root that imports the copied files: + +```markdown +@.github/copilot-instructions.md +@.github/instructions/coreex-api-controllers.instructions.md +@.github/instructions/coreex-application-services.instructions.md +@.github/instructions/coreex-repositories.instructions.md +@.github/instructions/coreex-validators.instructions.md +@.github/instructions/coreex-host-setup.instructions.md +``` + +Add or remove `@` import lines to match which instruction files you copied. Claude Code reads `CLAUDE.md` on startup and follows the imports — no content duplication needed. + +## Which Instruction Files to Copy + +Copy only the files that match what your project contains. Add more as you introduce new capabilities. + +| File | Copy when you have... | +|---|---| +| `coreex-api-controllers.instructions.md` | An API host with MVC controllers | +| `coreex-contracts.instructions.md` | A Contracts project with DTOs / entity types | +| `coreex-application-services.instructions.md` | An Application layer with services, validators, adapters, policies | +| `coreex-repositories.instructions.md` | An Infrastructure layer with EF Core repositories | +| `coreex-validators.instructions.md` | CoreEx validators (`Validator`, `AbstractValidator`) | +| `coreex-host-setup.instructions.md` | Any `Program.cs` — API, Subscribe, or Outbox Relay host | +| `coreex-event-subscribers.instructions.md` | A Subscribe host consuming Azure Service Bus messages | +| `coreex-domain.instructions.md` | A Domain layer with aggregates, entities, value objects | +| `coreex-tests.instructions.md` | Test projects using `CoreEx.UnitTesting` | +| `coreex-tooling.instructions.md` | `*.CodeGen` or `*.Database` design-time tooling projects | + +## Further Reading + +- [CoreEx on GitHub](https://github.com/Avanade/CoreEx) +- [Capabilities Guide](https://github.com/Avanade/CoreEx/blob/main/docs/capabilities.md) — deep capability and pattern explanations +- [Application Scaffolding Guide](https://github.com/Avanade/CoreEx/blob/main/docs/application-scaffolding-guide.md) — deciding what to build for a new service +- [Sample Reference Architecture](https://github.com/Avanade/CoreEx/tree/main/samples) — complete Contoso domains (Products / Shopping) demonstrating all patterns From 79e25cfcf3d473b420a85d2c63688ab2b19621c5 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Thu, 28 May 2026 15:00:50 -0700 Subject: [PATCH 14/17] Agent and content sync. --- .claude/commands/coreex-expert.md | 8 ++ .claude/commands/sync-coreex-docs.md | 86 ++++++++++++++++ .github/agents/coreex-expert.agent.md | 119 ++++++++++++++--------- .github/skills/sync-coreex-docs/SKILL.md | 82 ++++++++++++++++ 4 files changed, 251 insertions(+), 44 deletions(-) create mode 100644 .claude/commands/coreex-expert.md create mode 100644 .claude/commands/sync-coreex-docs.md create mode 100644 .github/skills/sync-coreex-docs/SKILL.md diff --git a/.claude/commands/coreex-expert.md b/.claude/commands/coreex-expert.md new file mode 100644 index 00000000..286e8183 --- /dev/null +++ b/.claude/commands/coreex-expert.md @@ -0,0 +1,8 @@ +--- +description: "CoreEx Expert — architecture guidance, pattern decisions, and sample-aligned advice for projects using CoreEx NuGet packages." +allowed-tools: [Read, Glob, Grep, WebFetch, WebSearch, Edit, Write] +--- + +Read `.github/agents/coreex-expert.agent.md` and follow the instructions in that file. + +The user's question or request follows. Apply the CoreEx Expert role, operating rules, and response format defined in the agent file. diff --git a/.claude/commands/sync-coreex-docs.md b/.claude/commands/sync-coreex-docs.md new file mode 100644 index 00000000..b6208394 --- /dev/null +++ b/.claude/commands/sync-coreex-docs.md @@ -0,0 +1,86 @@ +--- +description: "Fetch the CoreEx sample architecture docs and all per-package AI usage guides from GitHub and cache them locally under .github/docs/coreex/ for offline, faster expert guidance." +allowed-tools: [Read, Write, Glob, Grep, WebFetch] +--- + +Sync the CoreEx docs to `.github/docs/coreex/`. Follow these steps exactly. + +## Step 1 — Detect current CoreEx version + +Search for the `CoreEx` NuGet package version in this order, stopping at the first match: +1. `Directory.Packages.props` — look for `` +2. Any `*.csproj` file — look for `` +3. `Directory.Build.props` + +Record the version (or `unknown` if not found). Use it in the manifest. + +## Step 2 — Detect referenced CoreEx packages (for manifest only) + +Scan `Directory.Packages.props` and all `*.csproj` files for `PackageVersion` or `PackageReference` entries whose `Include` attribute starts with `CoreEx`. Collect the distinct package names. This list goes into the manifest so the CoreEx Expert knows which packages the project currently uses — it does not limit which guides are synced. + +## Step 3 — Create cache directories + +Ensure both directories exist. Create them if absent: +- `.github/docs/coreex/` +- `.github/docs/coreex/agents/` + +## Step 4 — Fetch and write the sample architecture docs + +Fetch each URL below and write to the corresponding local path. Report each as it completes. + +| URL | Local path | +|---|---| +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/layers.md` | `.github/docs/coreex/layers.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/patterns.md` | `.github/docs/coreex/patterns.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/contracts-layer.md` | `.github/docs/coreex/contracts-layer.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/domain-layer.md` | `.github/docs/coreex/domain-layer.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/application-layer.md` | `.github/docs/coreex/application-layer.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/infrastructure-layer.md` | `.github/docs/coreex/infrastructure-layer.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/hosts-layer.md` | `.github/docs/coreex/hosts-layer.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/testing.md` | `.github/docs/coreex/testing.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/tooling.md` | `.github/docs/coreex/tooling.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/aspire.md` | `.github/docs/coreex/aspire.md` | + +## Step 5 — Fetch and write all per-package guides + +Fetch the `AGENTS.md` for every CoreEx package listed below — regardless of whether the project currently references them. This allows the CoreEx Expert to guide on and recommend any package, including ones not yet adopted. + +| URL | Local path | +|---|---| +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.AspNetCore/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.AspNetCore.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.AspNetCore.NSwag/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.AspNetCore.NSwag.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.Azure.Messaging.ServiceBus/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.Azure.Messaging.ServiceBus.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.Caching.FusionCache/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.Caching.FusionCache.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.CodeGen/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.CodeGen.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.Data/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.Data.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.Database/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.Database.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.Database.Postgres/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.Database.Postgres.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.Database.SqlServer/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.Database.SqlServer.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.DomainDriven/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.DomainDriven.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.EntityFrameworkCore/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.EntityFrameworkCore.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.Events/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.Events.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.RefData/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.RefData.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.UnitTesting/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.UnitTesting.md` | +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/src/CoreEx.Validation/AGENTS.md` | `.github/docs/coreex/agents/CoreEx.Validation.md` | + +If any fetch fails, record the failure, skip that file, and continue. + +## Step 6 — Write the manifest + +Write `.github/docs/coreex/.manifest` with this exact format: + +``` +synced: YYYY-MM-DD +coreex-version: +referenced-packages: +``` + +## Step 7 — Report + +Summarise: +- How many architecture docs were written successfully. +- How many package guides were written successfully (out of 16). +- Any files that failed to fetch (with the error). +- The CoreEx version and referenced packages recorded in the manifest. +- A reminder: *"Re-run `/sync-coreex-docs` after bumping the CoreEx NuGet version or when the CoreEx Expert suggests the cache is stale."* diff --git a/.github/agents/coreex-expert.agent.md b/.github/agents/coreex-expert.agent.md index 01752e50..3223205c 100644 --- a/.github/agents/coreex-expert.agent.md +++ b/.github/agents/coreex-expert.agent.md @@ -1,29 +1,29 @@ --- name: CoreEx Expert -description: "Use when you need to explain, understand, or decide how CoreEx works. 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: [vscode/getProjectSetupInfo, vscode/installExtension, vscode/memory, vscode/newWorkspace, vscode/resolveMemoryFileUri, vscode/runCommand, vscode/vscodeAPI, vscode/extensions, vscode/askQuestions, execute/runNotebookCell, execute/getTerminalOutput, execute/killTerminal, execute/sendToTerminal, execute/createAndRunTask, execute/runInTerminal, execute/runTests, read/getNotebookSummary, read/problems, read/readFile, read/viewImage, read/terminalSelection, read/terminalLastCommand, agent/runSubagent, edit/createDirectory, edit/createFile, edit/createJupyterNotebook, edit/editFiles, edit/editNotebook, edit/rename, search/changes, search/codebase, search/fileSearch, search/listDirectory, search/textSearch, search/usages, web/fetch, web/githubRepo, web/githubTextSearch, browser/openBrowserPage, browser/readPage, browser/screenshotPage, browser/navigatePage, browser/clickElement, browser/dragElement, browser/hoverElement, browser/typeInPage, browser/runPlaywrightCode, browser/handleDialog, todo] +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 for this repository. +You are the CoreEx Expert. Your mission: -- Provide authoritative, repo-grounded guidance on CoreEx architecture, patterns, and practices. +- Provide authoritative guidance on CoreEx architecture, patterns, and practices. - Prefer CoreEx-native primitives and conventions over generic .NET advice. -- Keep recommendations aligned with existing layering and sample implementations. +- 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 -### Repo-wide conventions -- `.github/copilot-instructions.md` — project-wide guidelines, repository shape, key conventions, and house rules. -- `.github/INSTRUCTION_AUTHORING.md` — standards for authoring scoped instruction files and skills. -- `.github/SKILL_AUTHORING.md` — standards for authoring skills (`SKILL.md` files). +### Locally present + +These files are present when the CoreEx AI workflow set has been copied into the project: -### Scoped instruction files (auto-applied by file glob, read these for area-specific rules) +- `.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` — `AbstractValidator`, rule chains, `CommonValidator`, `ValidateAndThrowAsync`. +- `.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. @@ -31,35 +31,66 @@ Your mission: - `.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. -### Sample architecture docs (real-world usage patterns) -- `samples/docs/layers.md` — full layer dependency diagram, design-time tooling overview, dependency rules. -- `samples/docs/patterns.md` — canonical pattern catalogue: error handling, railway-oriented flows, outbox, adapters, policies, testing. -- `samples/docs/contracts-layer.md` — contracts in practice: generated contracts, interfaces, reference data code properties. -- `samples/docs/domain-layer.md` — aggregates, mutation guards, integration-event accumulation, `Result` pipelines. -- `samples/docs/application-layer.md` — service orchestration, `TransactionAsync`, `IUnitOfWork.Events`, validators, policies, adapters. -- `samples/docs/infrastructure-layer.md` — EF Core repositories, `IBiDirectionMapper`, outbox table wiring, relay publisher. -- `samples/docs/hosts-layer.md` — API, Subscribe, and Outbox.Relay `Program.cs` shapes, middleware ordering, Service Bus wiring. -- `samples/docs/testing.md` — unit, integration, API, Subscribe, and Relay test patterns with concrete examples. -- `samples/docs/tooling.md` — `*.CodeGen` and `*.Database` project run order, generated-file ownership, schema generation. -- `samples/docs/aspire.md` — Aspire orchestration for local distributed development and E2E testing. - -### Per-package AI usage guides (consumer-facing, packed with each NuGet) -- `src/CoreEx/AGENTS.md` — exceptions, `ExecutionContext`, `Result`, entity contracts, `Runtime.UtcNow`, DI attributes. -- `src/CoreEx.AspNetCore/AGENTS.md` — `WebApi`, middleware, health checks, idempotency. -- `src/CoreEx.AspNetCore.NSwag/AGENTS.md` — NSwag/OpenAPI integration. -- `src/CoreEx.Azure.Messaging.ServiceBus/AGENTS.md` — Service Bus publisher, subscribers, error handling. -- `src/CoreEx.Caching.FusionCache/AGENTS.md` — `IHybridCache`, Redis backplane, idempotency provider. -- `src/CoreEx.CodeGen/AGENTS.md` — `CodeGenConsole`, `ref-data.yaml`, generated-file ownership. -- `src/CoreEx.Data/AGENTS.md` — `IUnitOfWork`, `TransactionAsync`, `QueryArgsConfig`, `DataResult`. -- `src/CoreEx.Database/AGENTS.md` — `IDatabase`, `DatabaseCommand`, outbox relay base types. -- `src/CoreEx.Database.Postgres/AGENTS.md` — PostgreSQL `IDatabase`, outbox, error-code conventions. -- `src/CoreEx.Database.SqlServer/AGENTS.md` — SQL Server `IDatabase`, session context, outbox, error-code conventions. -- `src/CoreEx.DomainDriven/AGENTS.md` — `Entity`, `Aggregate`, `PersistenceState`. -- `src/CoreEx.EntityFrameworkCore/AGENTS.md` — `EfDb`, `EfDbModel`, dynamic query, `ValueConverterBridge`. -- `src/CoreEx.Events/AGENTS.md` — `EventData`, `IEventFormatter`, `IEventPublisher`, `SubscribedManager`. -- `src/CoreEx.RefData/AGENTS.md` — `ReferenceData`, `ReferenceDataHybridCache`, `ReferenceDataOrchestrator`. -- `src/CoreEx.UnitTesting/AGENTS.md` — outbox/event expectations, `JsonDataReader`, `AwesomeAssertions`. -- `src/CoreEx.Validation/AGENTS.md` — `AbstractValidator`, rule catalogue, `ValidateAndThrowAsync`. +### Per-package AI usage guides + +Check `.github/docs/coreex/agents/` for locally cached guides first (see [Local doc cache](#local-doc-cache)). `/sync-coreex-docs` 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: + +- [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 Outbox.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 + +`/sync-coreex-docs` 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 `/sync-coreex-docs`. + - `coreex-version`: scan `*.csproj`, `Directory.Packages.props`, and `Directory.Build.props` for the `CoreEx` package version; if it differs from the manifest, recommend running `/sync-coreex-docs`. +3. If no local cache exists and you are about to fetch a GitHub URL, offer first: *"I can run `/sync-coreex-docs` 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 @@ -69,14 +100,15 @@ Your mission: - 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. +- 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 scaffolding → advise using `/generate-domain`. -- Deterministic template scaffolding → advise using `/scaffold-domain-from-templates`. - Retrofit capability on an existing domain → advise using `/add-capability`. -- Repo mapping or onboarding documentation → advise using `acquire-codebase-knowledge`. +- Repo mapping or onboarding documentation → advise using `/acquire-codebase-knowledge`. ## Response format @@ -85,4 +117,3 @@ Your mission: 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/.github/skills/sync-coreex-docs/SKILL.md b/.github/skills/sync-coreex-docs/SKILL.md new file mode 100644 index 00000000..948f03be --- /dev/null +++ b/.github/skills/sync-coreex-docs/SKILL.md @@ -0,0 +1,82 @@ +--- +name: sync-coreex-docs +description: "Fetch the CoreEx sample architecture docs and all per-package AI usage guides from GitHub and cache them locally under .github/docs/coreex/. Guides for all CoreEx packages are synced regardless of which ones the project currently references, enabling the CoreEx Expert to guide on and recommend any package." +argument-hint: No arguments required. Run to set up or refresh the local doc cache. +tags: ["docs", "cache", "sync", "coreex-expert", "offline"] +--- + +# Sync CoreEx Docs + +Fetches the CoreEx sample architecture docs and AI usage guides for all CoreEx packages from GitHub, writing them to `.github/docs/coreex/`. All 16 package guides are synced unconditionally — this enables the CoreEx Expert to give authoritative guidance on any package, including ones not yet adopted by the project. Writes a manifest recording which packages the project currently references so the expert can distinguish "already in use" from "you'd need to add this." + +## When to Use + +- Setting up the local doc cache for the first time in a consuming project. +- After bumping a CoreEx NuGet package version. +- When the CoreEx Expert recommends a refresh (cache older than 30 days or version mismatch). +- Any time you want to ensure local docs reflect the latest CoreEx `main` branch. + +## When Not to Use + +- In the CoreEx repository itself — the docs are already present at `samples/docs/` and `src/*/AGENTS.md`. +- When you only need one specific doc — fetch the GitHub URL directly instead. + +## What It Does + +1. **Detects** the current `CoreEx` NuGet version from `Directory.Packages.props`, `*.csproj`, or `Directory.Build.props`. +2. **Detects** all `CoreEx.*` package references currently in the project — recorded in the manifest for the expert's awareness, not used to limit what is synced. +3. **Creates** `.github/docs/coreex/` and `.github/docs/coreex/agents/` if they do not exist. +4. **Fetches** the 10 sample architecture docs and writes them to `.github/docs/coreex/`. +5. **Fetches** all 16 per-package `AGENTS.md` guides and writes them to `.github/docs/coreex/agents/`. +6. **Writes** `.github/docs/coreex/.manifest` with sync date, CoreEx version, and the list of packages currently referenced in the project. +7. **Reports** success, any fetch failures, and a reminder of when to re-run. + +## Cache Layout + +``` +.github/docs/coreex/ + .manifest # synced date, coreex-version, referenced-packages + layers.md + patterns.md + contracts-layer.md + domain-layer.md + application-layer.md + infrastructure-layer.md + hosts-layer.md + testing.md + tooling.md + aspire.md + agents/ + CoreEx.md # always present after sync + CoreEx.AspNetCore.md # always present after sync + CoreEx.AspNetCore.NSwag.md + CoreEx.Azure.Messaging.ServiceBus.md + CoreEx.Caching.FusionCache.md + CoreEx.CodeGen.md + CoreEx.Data.md + CoreEx.Database.md + CoreEx.Database.Postgres.md + CoreEx.Database.SqlServer.md + CoreEx.DomainDriven.md + CoreEx.EntityFrameworkCore.md + CoreEx.Events.md + CoreEx.RefData.md + CoreEx.UnitTesting.md + CoreEx.Validation.md +``` + +## Manifest Format + +``` +synced: YYYY-MM-DD +coreex-version: +referenced-packages: CoreEx, CoreEx.Validation, CoreEx.EntityFrameworkCore, ... +``` + +The `referenced-packages` field lets the CoreEx Expert distinguish between packages already in the project and packages it might recommend adding. + +## Re-run Triggers + +- CoreEx NuGet version bumped in the project. +- CoreEx Expert reports cache is older than 30 days. +- CoreEx Expert reports a version mismatch between the manifest and the project's current package version. From 9d21c6cabaeeba26a22bfcf6ec40eac31f458125 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Fri, 29 May 2026 07:11:40 -0700 Subject: [PATCH 15/17] Update expert skill and related. --- ...ync-coreex-docs.md => coreex-docs-sync.md} | 3 +- .editorconfig | 194 +++++++++++++++- .github/agents/coreex-expert.agent.md | 11 +- .github/copilot-instructions.md | 26 ++- .../coreex-conventions.instructions.md | 118 ++++++++++ .../SKILL.md | 5 +- samples/docs/local-dev.md | 210 ++++++++++++++++++ .../Repositories/DatabaseConsts.cs | 12 - 8 files changed, 553 insertions(+), 26 deletions(-) rename .claude/commands/{sync-coreex-docs.md => coreex-docs-sync.md} (97%) create mode 100644 .github/instructions/coreex-conventions.instructions.md rename .github/skills/{sync-coreex-docs => coreex-docs-sync}/SKILL.md (97%) create mode 100644 samples/docs/local-dev.md delete mode 100644 samples/src/Contoso.Products.Infrastructure/Repositories/DatabaseConsts.cs diff --git a/.claude/commands/sync-coreex-docs.md b/.claude/commands/coreex-docs-sync.md similarity index 97% rename from .claude/commands/sync-coreex-docs.md rename to .claude/commands/coreex-docs-sync.md index b6208394..43f0709d 100644 --- a/.claude/commands/sync-coreex-docs.md +++ b/.claude/commands/coreex-docs-sync.md @@ -30,6 +30,7 @@ Fetch each URL below and write to the corresponding local path. Report each as i | URL | Local path | |---|---| +| `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/local-dev.md` | `.github/docs/coreex/local-dev.md` | | `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/layers.md` | `.github/docs/coreex/layers.md` | | `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/patterns.md` | `.github/docs/coreex/patterns.md` | | `https://raw.githubusercontent.com/Avanade/CoreEx/main/samples/docs/contracts-layer.md` | `.github/docs/coreex/contracts-layer.md` | @@ -83,4 +84,4 @@ Summarise: - How many package guides were written successfully (out of 16). - Any files that failed to fetch (with the error). - The CoreEx version and referenced packages recorded in the manifest. -- A reminder: *"Re-run `/sync-coreex-docs` after bumping the CoreEx NuGet version or when the CoreEx Expert suggests the cache is stale."* +- A reminder: *"Re-run `/coreex-docs-sync` after bumping the CoreEx NuGet version or when the CoreEx Expert suggests the cache is stale."* diff --git a/.editorconfig b/.editorconfig index 6ed50a36..4d99ceb7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,13 +1,197 @@ +root = true + +# ── Universal ──────────────────────────────────────────────────────────────── + [*] -indent_style = space -indent_size = 4 +charset = utf-8 +indent_style = space +indent_size = 4 trim_trailing_whitespace = true -insert_final_newline = false +insert_final_newline = true + +# ── C# source files ────────────────────────────────────────────────────────── [*.cs] indent_style = space -indent_size = 4 +indent_size = 4 + +# ── Formatting ─────────────────────────────────────────────────────────────── + +# Allman-style braces — opening brace always on its own line. +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +# Spacing +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after + +# Wrapping +csharp_preserve_single_line_statements = false +csharp_preserve_single_line_blocks = true + +# Using directives — System.* first; no blank lines between groups. +dotnet_sort_system_directives_first = false +dotnet_separate_import_directive_groups = false + +# ── Language rules — standards (IDE warning; not a build error without EnforceCodeStyleInBuild) ── + +# File-scoped namespaces required. +csharp_style_namespace_declarations = file_scoped:warning + +# Null-forgiving operator: prefer is-null checks over reference equality. +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning + +# ── Language rules — modernisation (IDE suggestion) ────────────────────────── + +# Braces: omit on single-line if/else/for/while bodies; require when multi-line. +csharp_prefer_braces = when_multiline:suggestion + +# Expression-bodied members: prefer => when the entire body is a single expression. +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_accessors = when_on_single_line:suggestion +csharp_style_expression_bodied_indexers = when_on_single_line:suggestion +csharp_style_expression_bodied_operators = when_on_single_line:suggestion +csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion +csharp_style_expression_bodied_local_functions = when_on_single_line:suggestion +# Constructors typically contain guard clauses — keep block bodies. +csharp_style_expression_bodied_constructors = false:suggestion + +# var: prefer explicit type for built-ins; prefer var when type is obvious from RHS. +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Null handling and pattern matching. +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_is_not_expression = true:suggestion +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion + +# Object and collection initialisers. +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion + +# Modern using declarations (using var x = ... instead of using (var x = ...)). +csharp_prefer_simple_using_statement = true:suggestion + +# Auto-properties over explicit backing fields where no extra logic exists. +dotnet_style_prefer_auto_properties = true:suggestion + +# Throw expressions (x ?? throw ...). +csharp_style_throw_expression = true:suggestion + +# Conditional delegate invocation (handler?.Invoke() vs if (handler != null) handler()). +csharp_style_conditional_delegate_call = true:suggestion + +# Tuple/anonymous type member names. +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion + +# Accessibility modifiers — always explicit, even when the default would apply. +dotnet_style_require_accessibility_modifiers = always:suggestion + +# Preferred modifier order. +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion + +# ── Naming conventions ─────────────────────────────────────────────────────── +# Rules are evaluated top-to-bottom; the first match wins. + +## Symbol groups + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private,private_protected + +dotnet_naming_symbols.non_private_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_fields.applicable_accessibilities = public,internal,protected,protected_internal + +dotnet_naming_symbols.constants.applicable_kinds = field,local +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_symbols.interfaces.applicable_kinds = interface + +dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter + +dotnet_naming_symbols.async_methods.applicable_kinds = method +dotnet_naming_symbols.async_methods.required_modifiers = async + +## Naming styles + +# _camelCase +dotnet_naming_style.prefix_underscore.capitalization = camel_case +dotnet_naming_style.prefix_underscore.required_prefix = _ + +# PascalCase +dotnet_naming_style.pascal_case.capitalization = pascal_case + +# IPascalCase +dotnet_naming_style.i_prefix_pascal_case.capitalization = pascal_case +dotnet_naming_style.i_prefix_pascal_case.required_prefix = I + +# TPascalCase +dotnet_naming_style.t_prefix_pascal_case.capitalization = pascal_case +dotnet_naming_style.t_prefix_pascal_case.required_prefix = T + +# PascalCaseAsync +dotnet_naming_style.pascal_case_async.capitalization = pascal_case +dotnet_naming_style.pascal_case_async.required_suffix = Async + +## Rules + +# Private fields → _camelCase (warning — agreed standard) +dotnet_naming_rule.private_fields_underscore.symbols = private_fields +dotnet_naming_rule.private_fields_underscore.style = prefix_underscore +dotnet_naming_rule.private_fields_underscore.severity = warning + +# Non-private fields → PascalCase (suggestion — rare in this codebase) +dotnet_naming_rule.non_private_fields_pascal.symbols = non_private_fields +dotnet_naming_rule.non_private_fields_pascal.style = pascal_case +dotnet_naming_rule.non_private_fields_pascal.severity = suggestion + +# Constants → PascalCase (suggestion) +dotnet_naming_rule.constants_pascal.symbols = constants +dotnet_naming_rule.constants_pascal.style = pascal_case +dotnet_naming_rule.constants_pascal.severity = suggestion + +# Interfaces → IPascalCase (warning — agreed standard) +dotnet_naming_rule.interfaces_i_prefix.symbols = interfaces +dotnet_naming_rule.interfaces_i_prefix.style = i_prefix_pascal_case +dotnet_naming_rule.interfaces_i_prefix.severity = warning + +# Type parameters → TPascalCase (suggestion) +dotnet_naming_rule.type_parameters_t_prefix.symbols = type_parameters +dotnet_naming_rule.type_parameters_t_prefix.style = t_prefix_pascal_case +dotnet_naming_rule.type_parameters_t_prefix.severity = suggestion + +# Async methods → PascalCaseAsync (warning — agreed standard) +dotnet_naming_rule.async_methods_async_suffix.symbols = async_methods +dotnet_naming_rule.async_methods_async_suffix.style = pascal_case_async +dotnet_naming_rule.async_methods_async_suffix.severity = warning + +# ── Structured data files ───────────────────────────────────────────────────── [*.{json,jsn,xml,yaml,yml,props,csproj,sln,sql}] indent_style = space -indent_size = 2 \ No newline at end of file +indent_size = 2 + +# ── Markdown ────────────────────────────────────────────────────────────────── + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/agents/coreex-expert.agent.md b/.github/agents/coreex-expert.agent.md index 3223205c..7dbdeeae 100644 --- a/.github/agents/coreex-expert.agent.md +++ b/.github/agents/coreex-expert.agent.md @@ -33,7 +33,7 @@ These files are present when the CoreEx AI workflow set has been copied into the ### Per-package AI usage guides -Check `.github/docs/coreex/agents/` for locally cached guides first (see [Local doc cache](#local-doc-cache)). `/sync-coreex-docs` 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. +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: @@ -58,6 +58,7 @@ If a guide is not cached locally, fetch from GitHub: 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. @@ -71,7 +72,7 @@ Check `.github/docs/coreex/` for a local cache first (see [Local doc cache](#loc ## Local doc cache -`/sync-coreex-docs` populates two local folders. Prefer local copies over GitHub URLs or fetches whenever they are present. +`/coreex-docs-sync` populates two local folders. Prefer local copies over GitHub URLs or fetches whenever they are present. | Folder | Contents | |---|---| @@ -84,9 +85,9 @@ A manifest at `.github/docs/coreex/.manifest` records the sync date, CoreEx vers 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 `/sync-coreex-docs`. - - `coreex-version`: scan `*.csproj`, `Directory.Packages.props`, and `Directory.Build.props` for the `CoreEx` package version; if it differs from the manifest, recommend running `/sync-coreex-docs`. -3. If no local cache exists and you are about to fetch a GitHub URL, offer first: *"I can run `/sync-coreex-docs` to cache the CoreEx docs and all package guides locally — this avoids repeated GitHub fetches. Want me to do that first?"* + - `synced` date: if older than 30 days, recommend running `/coreex-docs-sync`. + - `coreex-version`: 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. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6672c282..191a78d1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -26,6 +26,25 @@ CoreEx is a modular .NET framework for enterprise APIs and distributed services. - **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 [`samples/docs/local-dev.md`](../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`. @@ -117,8 +136,13 @@ CoreEx is a modular .NET framework for enterprise APIs and distributed services. ### House Rules - Code comments end with a period/full stop. -- Use `GlobalUsing.cs` per project; do not scatter `using` directives. - 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: diff --git a/.github/instructions/coreex-conventions.instructions.md b/.github/instructions/coreex-conventions.instructions.md new file mode 100644 index 00000000..4b77a4e5 --- /dev/null +++ b/.github/instructions/coreex-conventions.instructions.md @@ -0,0 +1,118 @@ +--- +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 +global using System; +global using System.Collections.Generic; +global using System.Threading; +global using System.Threading.Tasks; +global using Microsoft.Extensions.Logging; +global using CoreEx; +global using Contoso.Products.Application.Interfaces; +``` + +**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. + +## 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 when the entire method, property, or constructor body is a single expression. Do not use `=>` when there are multiple statements. + +```csharp +// Single-statement method delegation — use => +public Task GetAsync(string id) => _repository.GetAsync(id); + +// Multi-line delegation that fits on one logical line — use => +public Task> QueryAsync(QueryArgs? query, PagingArgs? paging) + => _repository.QueryAsync(query, paging); + +// Computed property — use => +public string DisplayName => $"{First} {Last}"; + +// Multiple statements — use block body +public async Task UpdateAsync(Product product) +{ + product.ThrowIfNull(); + await ProductValidator.Default.ValidateAndThrowAsync(product).ConfigureAwait(false); + return await _repository.UpdateAsync(product).ConfigureAwait(false); +} +``` + +## 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 add `using` statements to individual `.cs` files — declare all imports in `GlobalUsing.cs`. +- 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. diff --git a/.github/skills/sync-coreex-docs/SKILL.md b/.github/skills/coreex-docs-sync/SKILL.md similarity index 97% rename from .github/skills/sync-coreex-docs/SKILL.md rename to .github/skills/coreex-docs-sync/SKILL.md index 948f03be..fc3ff23b 100644 --- a/.github/skills/sync-coreex-docs/SKILL.md +++ b/.github/skills/coreex-docs-sync/SKILL.md @@ -1,5 +1,5 @@ --- -name: sync-coreex-docs +name: coreex-docs-sync description: "Fetch the CoreEx sample architecture docs and all per-package AI usage guides from GitHub and cache them locally under .github/docs/coreex/. Guides for all CoreEx packages are synced regardless of which ones the project currently references, enabling the CoreEx Expert to guide on and recommend any package." argument-hint: No arguments required. Run to set up or refresh the local doc cache. tags: ["docs", "cache", "sync", "coreex-expert", "offline"] @@ -26,7 +26,7 @@ Fetches the CoreEx sample architecture docs and AI usage guides for all CoreEx p 1. **Detects** the current `CoreEx` NuGet version from `Directory.Packages.props`, `*.csproj`, or `Directory.Build.props`. 2. **Detects** all `CoreEx.*` package references currently in the project — recorded in the manifest for the expert's awareness, not used to limit what is synced. 3. **Creates** `.github/docs/coreex/` and `.github/docs/coreex/agents/` if they do not exist. -4. **Fetches** the 10 sample architecture docs and writes them to `.github/docs/coreex/`. +4. **Fetches** the 11 sample architecture docs and writes them to `.github/docs/coreex/`. 5. **Fetches** all 16 per-package `AGENTS.md` guides and writes them to `.github/docs/coreex/agents/`. 6. **Writes** `.github/docs/coreex/.manifest` with sync date, CoreEx version, and the list of packages currently referenced in the project. 7. **Reports** success, any fetch failures, and a reminder of when to re-run. @@ -36,6 +36,7 @@ Fetches the CoreEx sample architecture docs and AI usage guides for all CoreEx p ``` .github/docs/coreex/ .manifest # synced date, coreex-version, referenced-packages + local-dev.md layers.md patterns.md contracts-layer.md diff --git a/samples/docs/local-dev.md b/samples/docs/local-dev.md new file mode 100644 index 00000000..9590af14 --- /dev/null +++ b/samples/docs/local-dev.md @@ -0,0 +1,210 @@ +# Local Development Setup + +This guide covers everything needed to run the Contoso sample hosts locally: the containerised infrastructure layer, connection string patterns, per-host startup, and how to graduate to the full Aspire-orchestrated environment for cross-domain work. + +--- + +## Prerequisites + +| Requirement | Notes | +|---|---| +| **Podman** (preferred) or **Docker** | Podman requires no daemon; `podman compose` is a drop-in replacement for `docker compose` | +| **.NET SDK** | Match the `TargetFramework` in `Directory.Build.props` | +| **Aspire workload** | `dotnet workload install aspire` — required only when running the full Aspire AppHost | + +--- + +## Infrastructure services + +All infrastructure is defined in `docker-compose.yml` at the repo root. Start everything with: + +```bash +# Podman (preferred) +podman compose -f docker-compose.yml up -d + +# Docker +docker compose -f docker-compose.yml up -d +``` + +Stop and remove containers: + +```bash +podman compose -f docker-compose.yml down +``` + +### Service inventory + +| Service name | Image | Port(s) | Used by | Notes | +|---|---|---|---|---| +| `db-sql-server` | `mssql/server:2022-latest` | 1433 | Shopping domain; Service Bus emulator | SA password: `yourStrong(!)Password` | +| `db-postgres` | `postgres` | 5432 | Products domain | Password: `yourStrong#!Password` | +| `redis-cache` | `redis:latest` | 6379 | All domains (FusionCache backplane) | No auth by default | +| `servicebus-emulator` | `azure-messaging/servicebus-emulator:latest` | 5672 (AMQP), 5300 (mgmt) | Products.Subscribe, Shopping.Subscribe, all Outbox.Relay hosts | Depends on `db-sql-server`; config mounted from `servicebus/Config.json` | +| `dts-emulator` | `dts/dts-emulator:latest` | 8080, 8082 | Orders.Workflow.Worker, Orders.Api | Task hubs: `default`, `order` | +| `aspire-dashboard` | `aspire-dashboard:latest` | 18888 (UI), 4317 (OTLP) | Optional — any host with OTLP configured | Usable standalone; no need to run the full Aspire AppHost just for traces | + +### Service Bus configuration + +The emulator is pre-configured by `servicebus/Config.json`. Key values the sample hosts depend on: + +| Setting | Value | +|---|---| +| Namespace | `sbemulatorns` | +| Topic | `contoso` | +| Subscription — Products | `products` (session-enabled) | +| Subscription — Shopping | `shopping` (session-enabled) | +| Unit test topics | `unit-test`, `unit-test-2` (used by integration tests) | + +The `contoso` topic is shared across both domains. Session-enabled subscriptions ensure ordered, per-entity processing of events. + +--- + +## Connection strings + +All sample hosts use the Aspire component configuration key hierarchy in `appsettings.Development.json`. The `Aspire:` prefix is consumed by Aspire component registrations in `Program.cs` — it is not a generic ASP.NET configuration section. + +### PostgreSQL (Products domain) + +```json +"Aspire": { + "Npgsql": { + "ConnectionString": "Server=127.0.0.1;Database=contoso;Username=postgres;Password=yourStrong#!Password" + } +} +``` + +### SQL Server (Shopping domain) + +```json +"Aspire": { + "Microsoft": { + "Data": { + "SqlClient": { + "ConnectionString": "Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true" + } + } + } +} +``` + +### Redis (all domains with FusionCache) + +```json +"Aspire": { + "StackExchange": { + "Redis": { + "ConnectionString": "localhost:6379", + "ConfigurationOptions": { + "ConnectTimeout": 3000, + "ConnectRetry": 2 + } + } + } +} +``` + +### Azure Service Bus emulator + +All hosts that publish or subscribe add the same base connection string. Subscribe hosts additionally set `QueueOrTopicName` and `SubscriptionName`: + +```json +"Aspire": { + "Azure": { + "Messaging": { + "ServiceBus": { + "ConnectionString": "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;", + "QueueOrTopicName": "contoso", + "SubscriptionName": "products" // or "shopping" for Shopping.Subscribe + } + } + } +} +``` + +The `UseDevelopmentEmulator=true` flag routes the SDK to the local emulator instead of Azure. `SAS_KEY_VALUE` is a placeholder accepted by the emulator — any non-empty string works. + +--- + +## Running sample databases (required once, and after schema changes) + +Before starting any host for the first time, migrate and seed each domain's database: + +```bash +dotnet run --project samples/src/Contoso.Products.Database -- all +dotnet run --project samples/src/Contoso.Shopping.Database -- all +``` + +For Orders (WIP): + +```bash +dotnet run --project samples/src/Contoso.Orders.Database -- all +``` + +Re-run after any migration scripts are added. See [tooling.md](tooling.md) for full detail on the `*.Database` project run order and generated-file ownership. + +--- + +## Running hosts individually (without Aspire) + +Use this when working on a single domain or running intra-domain integration tests: + +```bash +# Products +dotnet run --project samples/src/Contoso.Products.Api +dotnet run --project samples/src/Contoso.Products.Outbox.Relay +dotnet run --project samples/src/Contoso.Products.Subscribe + +# Shopping +dotnet run --project samples/src/Contoso.Shopping.Api +dotnet run --project samples/src/Contoso.Shopping.Outbox.Relay +dotnet run --project samples/src/Contoso.Shopping.Subscribe + +# Orders (WIP) +dotnet run --project samples/src/Contoso.Order.Workflow.Worker +dotnet run --project samples/src/Contoso.Orders.Api +``` + +Intra-domain host tests (`*.Test.Api`, `*.Test.Subscribe`, `*.Test.Outbox.Relay`) start their own in-process test host — they do not require any host process to be running. Infrastructure containers must still be up. + +--- + +## Running with Aspire (cross-domain E2E) + +When you need all domains running together — for cross-domain request flows, distributed traces, or E2E testing — use the Aspire AppHost instead of starting hosts individually: + +```bash +# Ensure infrastructure is running first (see above) +podman compose -f docker-compose.yml up -d + +# Then start the AppHost (starts all 8 hosts as child processes) +aspire run +# or +dotnet run --project samples/aspire/Contoso.Aspire +``` + +The Aspire Dashboard opens automatically at `http://localhost:15174` and provides live logs, distributed traces, metrics, and health views across all running hosts. + +See [aspire.md](aspire.md) for the full guide: orchestrated host inventory, E2E Runner scenarios, hosted-service pause/resume controls, and the recommended first-run order. + +--- + +## Standalone Aspire Dashboard (telemetry only) + +The `aspire-dashboard` container in `docker-compose.yml` runs the dashboard independently of the Aspire AppHost. Any host that exports OTLP to `http://localhost:4317` will appear in it, even when running individual hosts via `dotnet run`. + +Configure OTLP export in `appsettings.Development.json`: + +```json +"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317" +``` + +Or set as an environment variable before running the host. + +--- + +## Podman-specific notes + +- `podman compose` is a drop-in replacement for `docker compose` — all `docker compose` commands in this repo work with `podman compose`. +- Podman runs rootless by default; no daemon required. +- On first use, Podman may need to pull `mcr.microsoft.com` images through its registry configuration. If pulls fail, add `docker.io` and `mcr.microsoft.com` to `/etc/containers/registries.conf`. +- The Service Bus emulator and SQL Server images from `mcr.microsoft.com` require accepting the EULA via the `ACCEPT_EULA: Y` environment variable — already set in `docker-compose.yml`. diff --git a/samples/src/Contoso.Products.Infrastructure/Repositories/DatabaseConsts.cs b/samples/src/Contoso.Products.Infrastructure/Repositories/DatabaseConsts.cs deleted file mode 100644 index b94fefaa..00000000 --- a/samples/src/Contoso.Products.Infrastructure/Repositories/DatabaseConsts.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Contoso.Products.Infrastructure.Repositories -{ - internal class DatabaseConsts - { - } -} From 84cc2bfde8d4f46d287d78150bc787bb9c458ffb Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Fri, 29 May 2026 07:30:01 -0700 Subject: [PATCH 16/17] Quiet recent .editorconfig changes --- .editorconfig | 57 +++++++++---------- .../coreex-conventions.instructions.md | 12 ++-- src/CoreEx.EntityFrameworkCore/EfDbModel.cs | 5 +- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/.editorconfig b/.editorconfig index 4d99ceb7..4e9ea941 100644 --- a/.editorconfig +++ b/.editorconfig @@ -59,57 +59,56 @@ dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning # ── Language rules — modernisation (IDE suggestion) ────────────────────────── # Braces: omit on single-line if/else/for/while bodies; require when multi-line. -csharp_prefer_braces = when_multiline:suggestion +csharp_prefer_braces = when_multiline:none # Expression-bodied members: prefer => when the entire body is a single expression. -csharp_style_expression_bodied_methods = when_on_single_line:suggestion -csharp_style_expression_bodied_properties = true:suggestion -csharp_style_expression_bodied_accessors = when_on_single_line:suggestion -csharp_style_expression_bodied_indexers = when_on_single_line:suggestion -csharp_style_expression_bodied_operators = when_on_single_line:suggestion -csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion -csharp_style_expression_bodied_local_functions = when_on_single_line:suggestion -# Constructors typically contain guard clauses — keep block bodies. -csharp_style_expression_bodied_constructors = false:suggestion +csharp_style_expression_bodied_methods = when_on_single_line:none +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_accessors = when_on_single_line:none +csharp_style_expression_bodied_indexers = when_on_single_line:none +csharp_style_expression_bodied_operators = when_on_single_line:none +csharp_style_expression_bodied_lambdas = when_on_single_line:none +csharp_style_expression_bodied_local_functions = when_on_single_line:none +csharp_style_expression_bodied_constructors = when_on_single_line:none # var: prefer explicit type for built-ins; prefer var when type is obvious from RHS. -csharp_style_var_for_built_in_types = false:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = false:none +csharp_style_var_when_type_is_apparent = true:none +csharp_style_var_elsewhere = true:none # Null handling and pattern matching. -dotnet_style_null_propagation = true:suggestion -dotnet_style_coalesce_expression = true:suggestion -csharp_style_prefer_null_check_over_type_check = true:suggestion -csharp_style_prefer_is_not_expression = true:suggestion -csharp_style_prefer_pattern_matching = true:suggestion -csharp_style_prefer_switch_expression = true:suggestion +dotnet_style_null_propagation = true:none +dotnet_style_coalesce_expression = true:none +csharp_style_prefer_null_check_over_type_check = true:none +csharp_style_prefer_is_not_expression = true:none +csharp_style_prefer_pattern_matching = true:none +csharp_style_prefer_switch_expression = true:none # Object and collection initialisers. -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion +dotnet_style_object_initializer = true:none +dotnet_style_collection_initializer = true:none # Modern using declarations (using var x = ... instead of using (var x = ...)). -csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_simple_using_statement = true:none # Auto-properties over explicit backing fields where no extra logic exists. -dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_auto_properties = true:none # Throw expressions (x ?? throw ...). -csharp_style_throw_expression = true:suggestion +csharp_style_throw_expression = true:none # Conditional delegate invocation (handler?.Invoke() vs if (handler != null) handler()). -csharp_style_conditional_delegate_call = true:suggestion +csharp_style_conditional_delegate_call = true:none # Tuple/anonymous type member names. -dotnet_style_prefer_inferred_tuple_names = true:suggestion -dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:none +dotnet_style_prefer_inferred_anonymous_type_member_names = true:none # Accessibility modifiers — always explicit, even when the default would apply. -dotnet_style_require_accessibility_modifiers = always:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:none # Preferred modifier order. -csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:none # ── Naming conventions ─────────────────────────────────────────────────────── # Rules are evaluated top-to-bottom; the first match wins. diff --git a/.github/instructions/coreex-conventions.instructions.md b/.github/instructions/coreex-conventions.instructions.md index 4b77a4e5..807cb015 100644 --- a/.github/instructions/coreex-conventions.instructions.md +++ b/.github/instructions/coreex-conventions.instructions.md @@ -77,20 +77,23 @@ if (condition) ## Expression-Bodied Members -Use `=>` syntax when the entire method, property, or constructor body is a single expression. Do not use `=>` when there are multiple statements. +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 -// Single-statement method delegation — use => +// Method delegation — use => public Task GetAsync(string id) => _repository.GetAsync(id); -// Multi-line delegation that fits on one logical line — use => +// Multi-line single expression — use => public Task> QueryAsync(QueryArgs? query, PagingArgs? paging) => _repository.QueryAsync(query, paging); +// Constructor with single expression — use => +public DataResult(bool wasMutated) => WasMutated = wasMutated; + // Computed property — use => public string DisplayName => $"{First} {Last}"; -// Multiple statements — use block body +// Multiple statements — block body required public async Task UpdateAsync(Product product) { product.ThrowIfNull(); @@ -116,3 +119,4 @@ private readonly ILogger _logger; - 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 replace a private backing field with an auto-property simply because it could be one — backing fields are a valid developer choice. diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModel.cs b/src/CoreEx.EntityFrameworkCore/EfDbModel.cs index 4c6bf059..c08a4df4 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbModel.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbModel.cs @@ -1,6 +1,3 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage; - namespace CoreEx.EntityFrameworkCore; /// @@ -94,4 +91,4 @@ private async Task> RefreshPostMutationAsync(EfDbArgs args, TMode /// The . /// The . public EfDbMappedModel ToMappedModel(TBiDirectionMapper mapper) where T : class where TBiDirectionMapper : IBiDirectionMapper => new(this, mapper); -} \ No newline at end of file +} From 87f80108582b66d7db25b0adb303b4c5b0d8dc18 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Fri, 29 May 2026 10:49:48 -0700 Subject: [PATCH 17/17] More AI-related tweakings. --- .github/README.md | 59 +++++ .github/agents/README.md | 131 +++++++++++ .../scaffold-domain-from-templates.prompt.md | 218 ++++++++++++------ .../acquire-codebase-knowledge/README.md | 45 ++++ .github/skills/add-capability/README.md | 56 +++++ .github/skills/add-capability/SKILL.md | 18 +- .../messaging-retrofit-checklist.md | 66 ++++-- .../messaging-retrofit-checkpoints.md | 99 ++++---- .../add-capability/references/workflow.md | 100 +++++--- .github/skills/aspire/README.md | 42 ++++ .github/skills/coreex-docs-sync/README.md | 67 ++++++ .github/skills/generate-domain/README.md | 48 ++++ .../EntityReadController.cs.template | 5 + .../ReferenceDataController.cs.template | 12 - .../domain/Api/GlobalUsing.cs.template | 7 + .../Api/postgres/Domain.Api.csproj.template | 19 ++ .../domain/Api/postgres/Program.cs.template | 71 ++++++ .../Domain.Api.csproj.template | 18 +- .../Api/{ => sqlserver}/Program.cs.template | 11 +- .../Domain.Application.csproj.template | 13 +- .../Application/EntityReadService.cs.template | 2 + .../Application/EntityService.cs.template | 6 +- .../Interfaces/IEntityReadService.cs.template | 2 + .../ReferenceDataService.cs.template | 18 -- .../IEntityRepository.cs.template | 2 + .../IReferenceDataRepository.cs.template | 6 - .../rop/EntityReadService.cs.template | 14 ++ .../Application/rop/EntityService.cs.template | 66 ++++++ .../Interfaces/IEntityReadService.cs.template | 10 + .../rop/Interfaces/IEntityService.cs.template | 12 + .../IEntityRepository.cs.template | 16 ++ .../CodeGen/Domain.CodeGen.csproj.template | 15 ++ .../domain/CodeGen/Program.cs.template | 1 + .../domain/CodeGen/ref-data.yaml.template | 18 ++ .../domain/Contracts/EntityStatus.cs.template | 6 - .../000101-create-entitystatus.sql.template | 18 -- .../000201-create-entity.sql.template | 16 -- .../000202-create-childentity.sql.template | 17 -- .../000301-create-outbox-tables.sql.template | 29 --- .../spOutboxBatchCancel.g.sql.template | 46 ---- .../spOutboxBatchClaim.g.sql.template | 96 -------- .../spOutboxBatchComplete.g.sql.template | 50 ---- .../spOutboxEnqueue.g.sql.template | 18 -- .../spOutboxLeaseAcquire.g.sql.template | 49 ---- .../spOutboxLeaseRelease.g.sql.template | 29 --- .../{ => _shared}/Data/ref-data.yaml.template | 0 .../domain/Database/dbex.yaml.template | 8 - .../postgres/Domain.Database.csproj.template | 17 ++ ...create-{domainKebab}-schema.pgsql.template | 1 + ...-{domainKebab}-entitystatus.pgsql.template | 18 ++ ...create-{domainKebab}-entity.pgsql.template | 15 ++ ...e-{domainKebab}-childentity.pgsql.template | 16 ++ ...create-{domainKebab}-outbox.pgsql.template | 37 +++ .../Database/postgres/Program.cs.template | 27 +++ .../Database/postgres/dbex.yaml.template | 12 + .../Domain.Database.csproj.template | 6 +- ...-create-{domainKebab}-schema.sql.template} | 0 ...te-{domainKebab}-entitystatus.sql.template | 19 ++ ...3-create-{domainKebab}-entity.sql.template | 16 ++ ...ate-{domainKebab}-childentity.sql.template | 16 ++ ...5-create-{domainKebab}-outbox.sql.template | 40 ++++ .../{ => sqlserver}/Program.cs.template | 3 +- .../Database/sqlserver/dbex.yaml.template | 11 + .../Domain/Domain.Domain.csproj.template | 14 ++ .../domain/Domain/GlobalUsing.cs.template | 6 + .../ExampleValueObject.cs.template | 20 ++ .../domain/Domain/{Entity}.cs.template | 46 ++++ .../domain/DomainScaffold.checklist.md | 124 ++++++---- .../Mapping/EntityStatusMapper.cs.template | 16 -- .../Persistence/EntityStatus.cs.template | 3 - .../Repositories/DomainDbContext.cs.template | 64 ----- .../Repositories/DomainEfDb.cs.template | 9 - .../DomainOutboxPublisher.cs.template | 7 - .../ReferenceDataRepository.cs.template | 10 - .../Mapping/EntityMapper.cs.template | 0 .../Persistence/ChildEntity.cs.template | 0 .../Persistence/Entity.cs.template | 0 .../Repositories/DomainEfDb.cs.template | 13 ++ .../Repositories/EntityRepository.cs.template | 3 +- .../_shared/rop/EntityRepository.cs.template | 48 ++++ .../Domain.Infrastructure.csproj.template | 15 ++ .../postgres/GlobalUsing.cs.template | 19 ++ .../Repositories/DomainDbContext.cs.template | 35 +++ .../Domain.Infrastructure.csproj.template | 15 +- .../{ => sqlserver}/GlobalUsing.cs.template | 0 .../Repositories/DomainDbContext.cs.template | 35 +++ .../Domain.Outbox.Relay.csproj.template | 28 +++ .../Outbox.Relay/postgres/Program.cs.template | 38 +++ .../postgres/appsettings.json.template | 25 ++ .../Domain.Outbox.Relay.csproj.template | 28 +++ .../sqlserver/Program.cs.template | 38 +++ .../sqlserver/appsettings.json.template | 25 ++ .github/templates/domain/README.md | 68 ++++++ .../Subscribe/_shared/GlobalUsing.cs.template | 8 + .../{Entity}EventSubscriber.cs.template | 19 ++ .../postgres/Domain.Subscribe.csproj.template | 37 +++ .../Subscribe/postgres/Program.cs.template | 53 +++++ .../postgres/appsettings.json.template | 15 ++ .../Domain.Subscribe.csproj.template | 37 +++ .../Subscribe/sqlserver/Program.cs.template | 53 +++++ .../sqlserver/appsettings.json.template | 15 ++ CoreEx.sln | 1 + README.md | 29 ++- 103 files changed, 2146 insertions(+), 802 deletions(-) create mode 100644 .github/README.md create mode 100644 .github/agents/README.md create mode 100644 .github/skills/acquire-codebase-knowledge/README.md create mode 100644 .github/skills/add-capability/README.md create mode 100644 .github/skills/aspire/README.md create mode 100644 .github/skills/coreex-docs-sync/README.md create mode 100644 .github/skills/generate-domain/README.md delete mode 100644 .github/templates/domain/Api/Controllers/ReferenceDataController.cs.template create mode 100644 .github/templates/domain/Api/postgres/Domain.Api.csproj.template create mode 100644 .github/templates/domain/Api/postgres/Program.cs.template rename .github/templates/domain/Api/{ => sqlserver}/Domain.Api.csproj.template (71%) rename .github/templates/domain/Api/{ => sqlserver}/Program.cs.template (86%) delete mode 100644 .github/templates/domain/Application/ReferenceDataService.cs.template delete mode 100644 .github/templates/domain/Application/Repositories/IReferenceDataRepository.cs.template create mode 100644 .github/templates/domain/Application/rop/EntityReadService.cs.template create mode 100644 .github/templates/domain/Application/rop/EntityService.cs.template create mode 100644 .github/templates/domain/Application/rop/Interfaces/IEntityReadService.cs.template create mode 100644 .github/templates/domain/Application/rop/Interfaces/IEntityService.cs.template create mode 100644 .github/templates/domain/Application/rop/Repositories/IEntityRepository.cs.template create mode 100644 .github/templates/domain/CodeGen/Domain.CodeGen.csproj.template create mode 100644 .github/templates/domain/CodeGen/Program.cs.template create mode 100644 .github/templates/domain/CodeGen/ref-data.yaml.template delete mode 100644 .github/templates/domain/Contracts/EntityStatus.cs.template delete mode 100644 .github/templates/domain/Database/Migrations/000101-create-entitystatus.sql.template delete mode 100644 .github/templates/domain/Database/Migrations/000201-create-entity.sql.template delete mode 100644 .github/templates/domain/Database/Migrations/000202-create-childentity.sql.template delete mode 100644 .github/templates/domain/Database/Migrations/000301-create-outbox-tables.sql.template delete mode 100644 .github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql.template delete mode 100644 .github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql.template delete mode 100644 .github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql.template delete mode 100644 .github/templates/domain/Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql.template delete mode 100644 .github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql.template delete mode 100644 .github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql.template rename .github/templates/domain/Database/{ => _shared}/Data/ref-data.yaml.template (100%) delete mode 100644 .github/templates/domain/Database/dbex.yaml.template create mode 100644 .github/templates/domain/Database/postgres/Domain.Database.csproj.template create mode 100644 .github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000001-create-{domainKebab}-schema.pgsql.template create mode 100644 .github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000002-create-{domainKebab}-entitystatus.pgsql.template create mode 100644 .github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000003-create-{domainKebab}-entity.pgsql.template create mode 100644 .github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000004-create-{domainKebab}-childentity.pgsql.template create mode 100644 .github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000005-create-{domainKebab}-outbox.pgsql.template create mode 100644 .github/templates/domain/Database/postgres/Program.cs.template create mode 100644 .github/templates/domain/Database/postgres/dbex.yaml.template rename .github/templates/domain/Database/{ => sqlserver}/Domain.Database.csproj.template (66%) rename .github/templates/domain/Database/{Migrations/000001-create-schema.sql.template => sqlserver/Migrations/{MigrationTimestamp}-000001-create-{domainKebab}-schema.sql.template} (100%) create mode 100644 .github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000002-create-{domainKebab}-entitystatus.sql.template create mode 100644 .github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000003-create-{domainKebab}-entity.sql.template create mode 100644 .github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000004-create-{domainKebab}-childentity.sql.template create mode 100644 .github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000005-create-{domainKebab}-outbox.sql.template rename .github/templates/domain/Database/{ => sqlserver}/Program.cs.template (85%) create mode 100644 .github/templates/domain/Database/sqlserver/dbex.yaml.template create mode 100644 .github/templates/domain/Domain/Domain.Domain.csproj.template create mode 100644 .github/templates/domain/Domain/GlobalUsing.cs.template create mode 100644 .github/templates/domain/Domain/ValueObjects/ExampleValueObject.cs.template create mode 100644 .github/templates/domain/Domain/{Entity}.cs.template delete mode 100644 .github/templates/domain/Infrastructure/Mapping/EntityStatusMapper.cs.template delete mode 100644 .github/templates/domain/Infrastructure/Persistence/EntityStatus.cs.template delete mode 100644 .github/templates/domain/Infrastructure/Repositories/DomainDbContext.cs.template delete mode 100644 .github/templates/domain/Infrastructure/Repositories/DomainEfDb.cs.template delete mode 100644 .github/templates/domain/Infrastructure/Repositories/DomainOutboxPublisher.cs.template delete mode 100644 .github/templates/domain/Infrastructure/Repositories/ReferenceDataRepository.cs.template rename .github/templates/domain/Infrastructure/{ => _shared}/Mapping/EntityMapper.cs.template (100%) rename .github/templates/domain/Infrastructure/{ => _shared}/Persistence/ChildEntity.cs.template (100%) rename .github/templates/domain/Infrastructure/{ => _shared}/Persistence/Entity.cs.template (100%) create mode 100644 .github/templates/domain/Infrastructure/_shared/Repositories/DomainEfDb.cs.template rename .github/templates/domain/Infrastructure/{ => _shared}/Repositories/EntityRepository.cs.template (95%) create mode 100644 .github/templates/domain/Infrastructure/_shared/rop/EntityRepository.cs.template create mode 100644 .github/templates/domain/Infrastructure/postgres/Domain.Infrastructure.csproj.template create mode 100644 .github/templates/domain/Infrastructure/postgres/GlobalUsing.cs.template create mode 100644 .github/templates/domain/Infrastructure/postgres/Repositories/DomainDbContext.cs.template rename .github/templates/domain/Infrastructure/{ => sqlserver}/Domain.Infrastructure.csproj.template (57%) rename .github/templates/domain/Infrastructure/{ => sqlserver}/GlobalUsing.cs.template (100%) create mode 100644 .github/templates/domain/Infrastructure/sqlserver/Repositories/DomainDbContext.cs.template create mode 100644 .github/templates/domain/Outbox.Relay/postgres/Domain.Outbox.Relay.csproj.template create mode 100644 .github/templates/domain/Outbox.Relay/postgres/Program.cs.template create mode 100644 .github/templates/domain/Outbox.Relay/postgres/appsettings.json.template create mode 100644 .github/templates/domain/Outbox.Relay/sqlserver/Domain.Outbox.Relay.csproj.template create mode 100644 .github/templates/domain/Outbox.Relay/sqlserver/Program.cs.template create mode 100644 .github/templates/domain/Outbox.Relay/sqlserver/appsettings.json.template create mode 100644 .github/templates/domain/README.md create mode 100644 .github/templates/domain/Subscribe/_shared/GlobalUsing.cs.template create mode 100644 .github/templates/domain/Subscribe/_shared/Subscribers/{Entity}EventSubscriber.cs.template create mode 100644 .github/templates/domain/Subscribe/postgres/Domain.Subscribe.csproj.template create mode 100644 .github/templates/domain/Subscribe/postgres/Program.cs.template create mode 100644 .github/templates/domain/Subscribe/postgres/appsettings.json.template create mode 100644 .github/templates/domain/Subscribe/sqlserver/Domain.Subscribe.csproj.template create mode 100644 .github/templates/domain/Subscribe/sqlserver/Program.cs.template create mode 100644 .github/templates/domain/Subscribe/sqlserver/appsettings.json.template diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 00000000..440ae17f --- /dev/null +++ b/.github/README.md @@ -0,0 +1,59 @@ +# AI Workflow Set + +This folder contains the AI artefacts that give GitHub Copilot and Claude Code authoritative knowledge of CoreEx patterns, conventions, and architecture. They can be used directly in the CoreEx repository or copied into a consuming project. + +## What's here + +| Artefact | Path | Purpose | +|----------|------|---------| +| Global instructions | `copilot-instructions.md` | Auto-injected project-wide context: repo shape, conventions, house rules, generated-file ownership. Applied to every chat interaction automatically. | +| Area instructions | `instructions/*.instructions.md` | Scoped context injected automatically when editing a matching file type (contracts, services, repositories, controllers, tests, etc.). | +| Agent | `agents/coreex-expert.agent.md` | Dedicated expert for CoreEx architecture and pattern guidance — explains conventions, reviews designs, and routes to the right command. | +| Prompts | `prompts/*.prompt.md` | Deterministic, file-driven commands invoked with `/` in chat. | +| Skills | `skills/*/SKILL.md` | Reasoning-based commands for open-ended tasks. Invoked with `/` in Claude Code; attach the `SKILL.md` via `#file:` in Copilot. | +| Domain templates | `templates/domain/` | 77 ready-made source-file templates covering all domain layers and both database engines. See the [templates README](./templates/domain/README.md). | +| Authoring guides | `INSTRUCTION_AUTHORING.md`, `SKILL_AUTHORING.md` | Standards for writing new instruction files and skills. | + +## Agent + +**`coreex-expert`** — invoke when you need to explain a CoreEx concept, choose between patterns, review a design, or get architecture guidance aligned to the sample implementations. + +- Claude Code: `@coreex-expert` +- Copilot Chat: switch to **Agent** mode and select **CoreEx Expert** + +The agent uses a local doc cache (populated by `/coreex-docs-sync`) to avoid live GitHub fetches on every question. It covers all 16 CoreEx packages, distinguishing those already in the project from ones the project could adopt. See the [agent README](./agents/README.md) for the resolution flowchart, cache structure, and adoption guide. + +## Instructions + +Instructions are passive — no action is needed to activate them. The global file applies to every session; area files are injected automatically based on what you are editing. + +| File | Injected when editing | +|------|-----------------------| +| `coreex-conventions.instructions.md` | All `.cs` files — naming, nullability, expression bodies, `ConfigureAwait`, house rules | +| `coreex-contracts.instructions.md` | Contract files — `[Contract]`, `[ReferenceData]`, source generation | +| `coreex-application-services.instructions.md` | Application services — `TransactionAsync`, validation, event enqueuing | +| `coreex-validators.instructions.md` | Validator files — `Validator`, rule chains | +| `coreex-repositories.instructions.md` | Repository files — `EfDbModel`, mappers, `QueryArgsConfig`, paging | +| `coreex-api-controllers.instructions.md` | Controller files — `WebApi` helpers, `[IdempotencyKey]`, PATCH | +| `coreex-event-subscribers.instructions.md` | Subscriber files — `[Subscribe]`, `SubscribedManager`, error handling | +| `coreex-host-setup.instructions.md` | `Program.cs` files — middleware order, service registration, outbox relay | +| `coreex-tooling.instructions.md` | CodeGen and Database projects — `ref-data.yaml`, DbEx, generated-file ownership | +| `coreex-tests.instructions.md` | Test files — UnitTestEx, NUnit, AwesomeAssertions, outbox/event assertions | +| `coreex-domain.instructions.md` | Domain files — aggregates, mutation guards, `Result` pipelines | + +## Prompts and Skills + +| Command | Type | What it does | +|---------|------|-------------| +| [`/scaffold-domain-from-templates`](./templates/domain/README.md) | Prompt | Fast, deterministic domain scaffolding — clones the canonical templates with placeholder substitution. Use when your entity fits the standard shape and you want exact output. | +| [`/generate-domain`](./skills/generate-domain/README.md) | Skill | Guided, reasoning-based domain scaffolding — use when your entity has custom fields, business rules, or you want the agent to apply conventions for you. | +| [`/add-capability`](./skills/add-capability/README.md) | Skill | Retrofits an existing domain with messaging/integration capabilities (Outbox Relay, Subscribe, Service Bus wiring). | +| [`/acquire-codebase-knowledge`](./skills/acquire-codebase-knowledge/README.md) | Skill | Maps an unfamiliar codebase and produces seven structured onboarding documents. | +| [`/coreex-docs-sync`](./skills/coreex-docs-sync/README.md) | Skill | Fetches and caches CoreEx architecture docs and all per-package AI guides locally under `.github/docs/coreex/`. | +| [`/aspire`](./skills/aspire/README.md) | Skill | Orchestrates Aspire distributed apps locally: start, stop, logs, debug. | +| `/init` | Prompt | Initializes a new CoreEx solution or workspace. | +| `/setup` | Prompt | Configures an existing CoreEx solution with standard tooling and settings. | + +## Domain templates + +See [templates/domain/README.md](./templates/domain/README.md) for the full option set, directory layout, and invocation instructions for both Claude Code and GitHub Copilot. diff --git a/.github/agents/README.md b/.github/agents/README.md new file mode 100644 index 00000000..b8bd1d1c --- /dev/null +++ b/.github/agents/README.md @@ -0,0 +1,131 @@ +# CoreEx Expert Agent + +The `coreex-expert` agent gives GitHub Copilot and Claude Code authoritative, CoreEx-idiomatic guidance — architecture decisions, pattern selection, layer design, and implementation advice — all aligned to the Contoso sample implementations. + +It is defined once in `coreex-expert.agent.md` and works in both tools from the same file. + +| Tool | How to invoke | +|------|--------------| +| GitHub Copilot Chat | Switch to **Agent** mode and select **CoreEx Expert** | +| Claude Code | `@coreex-expert` | + +--- + +## How the agent resolves guidance + +The agent relies on a local doc cache rather than making live GitHub fetches on every question. The cache is populated by `/coreex-docs-sync` and stored under `.github/docs/coreex/`. + +``` +Developer asks a CoreEx question + │ + ▼ + Read .github/docs/coreex/.manifest + │ + ┌─────┴─────┐ + │ │ + manifest no manifest + present │ + │ └──► offer to run /coreex-docs-sync first + │ (never runs silently — always asks first) + │ + check staleness + │ + ┌────┴────┐ + │ │ + fresh stale + │ (>30 days OR coreex-version in manifest + │ differs from version in project files) + │ │ + │ └──► recommend running /coreex-docs-sync + │ + ▼ +Use local cache (always preferred over live GitHub fetches) + │ + ├── .github/docs/coreex/*.md ← 10 architecture docs + │ + └── .github/docs/coreex/agents/*.md ← 16 per-package AI guides + │ + read manifest referenced-packages + to distinguish: + • package already in project → guide on current usage + • package not yet in project → recommend adopting it +``` + +The `referenced-packages` field in the manifest lets the agent distinguish between a package the project already uses and one it would need to add — without restricting which guides get synced. + +--- + +## The local doc cache + +`/coreex-docs-sync` fetches from the CoreEx GitHub repository and writes two folders: + +**`.github/docs/coreex/`** — 10 architecture docs: + +| File | Content | +|------|---------| +| `layers.md` | Full layer dependency diagram and design-time tooling overview | +| `patterns.md` | Pattern catalog — every architectural, application, messaging, and testing pattern | +| `contracts-layer.md` | Generated contracts, `[Contract]`, `[ReferenceData]`, source generation | +| `domain-layer.md` | Aggregates, mutation guards, integration-event accumulation, `Result` pipelines | +| `application-layer.md` | Service orchestration, `TransactionAsync`, validators, policies, adapters | +| `infrastructure-layer.md` | EF Core repositories, mappers, outbox wiring, relay publisher | +| `hosts-layer.md` | API, Subscribe, and Outbox Relay `Program.cs` shapes, middleware ordering | +| `testing.md` | Unit, integration, API, Subscribe, and Relay test patterns | +| `tooling.md` | CodeGen and Database project run order, generated-file ownership | +| `aspire.md` | Aspire orchestration for local distributed development and E2E testing | + +**`.github/docs/coreex/agents/`** — 16 per-package AI usage guides, one per CoreEx NuGet package. All 16 are synced unconditionally so the agent can guide on any package — including ones the project hasn't adopted yet. + +**`.github/docs/coreex/.manifest`** — records `synced` date, `coreex-version`, and `referenced-packages`. + +### Staleness triggers + +| Trigger | Agent action | +|---------|-------------| +| Cache absent | Offers to run `/coreex-docs-sync` before the first GitHub fetch | +| `synced` date > 30 days | Recommends a refresh | +| `coreex-version` in manifest ≠ version in project files | Recommends a refresh | + +--- + +## Why sync all 16 package guides unconditionally + +An earlier design synced only the packages the project already references. This was changed because: + +- The agent cannot recommend adopting a package (e.g. `CoreEx.Caching.FusionCache`) if it has no knowledge of what that package offers. +- All 16 guides are small markdown files — the total download is negligible. +- Syncing all unconditionally removes the need to re-run after adding a new package. +- The `referenced-packages` manifest field preserves the "in project vs. not yet" distinction without making it a gate on what gets synced. + +--- + +## Adopting the agent in a consuming project + +Copy the following from this repository into any project that references CoreEx NuGet packages: + +``` +.github/ + copilot-instructions.md + agents/ + coreex-expert.agent.md + instructions/ + coreex-conventions.instructions.md + coreex-contracts.instructions.md + coreex-application-services.instructions.md + coreex-validators.instructions.md + coreex-repositories.instructions.md + coreex-api-controllers.instructions.md + coreex-event-subscribers.instructions.md + coreex-host-setup.instructions.md + coreex-tooling.instructions.md + coreex-tests.instructions.md + coreex-domain.instructions.md + skills/ + coreex-docs-sync/ + SKILL.md + generate-domain/ # optional — new domain scaffolding + add-capability/ # optional — retrofit messaging/integration + acquire-codebase-knowledge/ # optional — repo onboarding docs +``` + +On first use, run `/coreex-docs-sync` to populate the local cache. Re-run whenever the CoreEx NuGet version is bumped. diff --git a/.github/prompts/scaffold-domain-from-templates.prompt.md b/.github/prompts/scaffold-domain-from-templates.prompt.md index ea75eb4a..c8ce1e2d 100644 --- a/.github/prompts/scaffold-domain-from-templates.prompt.md +++ b/.github/prompts/scaffold-domain-from-templates.prompt.md @@ -18,123 +18,191 @@ Use the `/generate-domain` skill instead when: - You want the agent to **reason about your domain model** and apply conventions (validation rules, event naming, query config) appropriately. - You are unsure which operations or patterns to include and want guided scaffolding. -## Inputs Required - -If not supplied, ask for: +--- -1. `Solution` (e.g. `Contoso`). -2. `Domain` (e.g. `Orders`). -3. `Entity` (e.g. `Order`). -4. `ChildEntity` (e.g. `OrderItem`). -5. `targetRoot` (default: `samples/src`). -6. `testsRoot` (default: `samples/tests`). +## Scaffolding Questions + +Ask all unanswered questions before materializing any files: + +| # | Question | Options | Default | +|---|----------|---------|---------| +| 1 | **Solution** (e.g. `Contoso`) | free text | — | +| 2 | **Domain** (e.g. `Orders`) | free text | — | +| 3 | **Entity** (e.g. `Order`) | free text | — | +| 4 | **Database engine** | `SQL Server` / `PostgreSQL` | `SQL Server` | +| 5 | **Reference Data** — generate a CodeGen project for reference data (e.g. `{Entity}Status`)? | `Yes` / `No` | `No` | +| 6 | **Child Entity** — does `{Entity}` own a child entity (e.g. `{Entity}Item`)? If Yes, provide the child entity name. | `Yes ` / `No` | `No` | +| 7 | **Domain project** — include a DDD domain project (`{Solution}.{Domain}.Domain`) with aggregate roots and value objects? | `Yes` / `No` | `No` | +| 8 | **ROP** — use Railway Oriented Programming (`Result`) in service and repository layers? | `Yes` / `No` | `No` | +| 9 | **Outbox Relay** — include an `{Solution}.{Domain}.Outbox.Relay` hosted-service project? | `Yes` / `No` | `Yes` | +| 10 | **Subscribe** — include an `{Solution}.{Domain}.Subscribe` event-subscriber hosted-service project? | `Yes` / `No` | `Yes` | +| 11 | `targetRoot` (root folder for domain projects) | path | `samples/src` | +| 12 | `testsRoot` (root folder for test projects) | path | `samples/tests` | -## Naming Helper (Auto-Derive) +--- -Derive naming values from `Entity` unless the user explicitly overrides them: +## Naming Derivations (Auto-Derive) -- `EntityPlural` = English plural form of `Entity`. - - Default rule: append `s`. - - If ends with `y` preceded by a consonant: replace `y` with `ies`. - - If ends with `s`, `x`, `z`, `ch`, `sh`: append `es`. - - Preserve casing (e.g. `Order` -> `Orders`, `Category` -> `Categories`). -- `entityKebab` = kebab-case of `Entity`. -- `entityPluralKebab` = kebab-case of `EntityPlural`. -- `EntityPluralVar` = `EntityPlural` unless overridden. +Derive all naming variants from the supplied values unless explicitly overridden: -Example: +| Placeholder | Rule | Example | +|-------------|------|---------| +| `{EntityPlural}` | English plural of `{Entity}`. Append `s`; `y`→`ies` after consonant; `s/x/z/ch/sh`→`es`. | `Order` → `Orders`, `Category` → `Categories` | +| `{entityKebab}` | kebab-case of `{Entity}` | `Order` → `order`, `SalesOrder` → `sales-order` | +| `{entityPluralKebab}` | kebab-case of `{EntityPlural}` | `Orders` → `orders` | +| `{domainKebab}` | kebab-case of `{Domain}` | `Orders` → `orders` | +| `{solution-kebab}` | kebab-case of `{Solution}` | `Contoso` → `contoso` | +| `{entity_kebab}` | snake_case of `{entityKebab}` (replace `-` with `_`) | `sales-order` → `sales_order` | +| `{entity_status_kebab}` | snake_case of `{Entity}Status` kebab | `OrderStatus` → `order_status` | +| `{child_entity_kebab}` | snake_case of `{ChildEntity}` kebab | `OrderItem` → `order_item` | +| `{MigrationTimestamp}` | UTC timestamp at scaffold time, format `yyyymmdd-hhmmss` | `20260529-143000` | -- `Entity = Order` -> `EntityPlural = Orders`, `entityKebab = order`, `entityPluralKebab = orders`. -- `Entity = Category` -> `EntityPlural = Categories`, `entityKebab = category`, `entityPluralKebab = categories`. +--- ## Placeholders to Replace -For every template file, replace all placeholders: +Replace every occurrence in every materialized file: + +- `{Solution}`, `{Domain}`, `{Entity}`, `{ChildEntity}`, `{EntityPlural}` +- `{entityKebab}`, `{entityPluralKebab}`, `{domainKebab}`, `{solution-kebab}` +- `{entity_kebab}`, `{entity_status_kebab}`, `{child_entity_kebab}` +- `{MigrationTimestamp}` + +> **Never create or edit `*.g.cs`, `*.g.sql`, or `*.g.pgsql` files.** These are produced exclusively by `CoreEx.CodeGen` (`dotnet run` in the CodeGen project) or DbEx migrations; hand-authoring them defeats the generator. + +--- + +## Conditional File-Inclusion Rules + +### Database Engine + +| Condition | Include | Exclude | +|-----------|---------|---------| +| SQL Server | `*/sqlserver/**` | `*/postgres/**` | +| PostgreSQL | `*/postgres/**` | `*/sqlserver/**` | + +`*/_shared/**` is always included regardless of engine. + +### Reference Data (Q5) + +| Answer | Action | +|--------|--------| +| **Yes** | Include `CodeGen/` project. Include ref-data patterns in Application and Infrastructure (service registration, `ReferenceDataService`, `AddReferenceDataOrchestrator` calls). | +| **No** | Omit `CodeGen/` project entirely. Remove all ref-data patterns (`ReferenceDataService`, status table migration, status mapper, `{Entity}Status` contract usages). | + +### Child Entity (Q6) + +| Answer | Action | +|--------|--------| +| **Yes ``** | Include `Database/*/Migrations/*-childentity*` migration. Add child entity to `dbex.yaml`. Include child entity contract, mapper, persistence model, and EfDb relationship. | +| **No** | Skip all child entity files. | + +### Domain Project (Q7) + +| Answer | Action | +|--------|--------| +| **Yes** | Include `Domain/` project (`{Solution}.{Domain}.Domain`). Add `ProjectReference` to Domain from Application. | +| **No** | Skip `Domain/` project entirely. | -- `{Solution}` -- `{Domain}` -- `{Entity}` -- `{ChildEntity}` -- `{EntityPlural}` -- `{EntityPluralKebab}` where present -- `{entityKebab}` -- `{entityPluralKebab}` -- `{EntityPlural}` in class/type names -- `{EntityPlural}` / `{EntityPluralVar}` in repository/EfDb property names +### ROP — Railway Oriented Programming (Q8) -If `EntityPluralVar` is not supplied, default to `{EntityPlural}`. +| Answer | Action | +|--------|--------| +| **Yes** | Use `Application/rop/` templates for service and interfaces; use `Infrastructure/_shared/rop/` for repository. Skip the non-ROP equivalents. | +| **No** | Use default `Application/` service and interfaces; use `Infrastructure/_shared/Repositories/EntityRepository.cs.template`. Skip `*/rop/` folders. | + +### Outbox Relay (Q9) + +| Answer | Action | +|--------|--------| +| **Yes** | Include `Outbox.Relay//` project (`csproj`, `Program.cs`, `appsettings.json`). | +| **No** | Skip `Outbox.Relay/` entirely. | + +### Subscribe (Q10) + +| Answer | Action | +|--------|--------| +| **Yes** | Include `Subscribe//` (`csproj`, `Program.cs`, `appsettings.json`) and `Subscribe/_shared/` (`GlobalUsing.cs`, `Subscribers/{Entity}EventSubscriber.cs`). | +| **No** | Skip `Subscribe/` entirely. | + +--- ## Output Projects -Create these projects under `{targetRoot}`: +Create the following projects under `{targetRoot}`: +**Always:** - `{Solution}.{Domain}.Contracts` - `{Solution}.{Domain}.Application` - `{Solution}.{Domain}.Infrastructure` - `{Solution}.{Domain}.Api` - `{Solution}.{Domain}.Database` -Create these test projects under `{testsRoot}`: +**Conditional:** +- `{Solution}.{Domain}.CodeGen` — Reference Data = Yes +- `{Solution}.{Domain}.Domain` — Domain = Yes +- `{Solution}.{Domain}.Outbox.Relay` — Outbox Relay = Yes +- `{Solution}.{Domain}.Subscribe` — Subscribe = Yes +**Test projects** under `{testsRoot}`: - `{Solution}.{Domain}.Test.Unit` - `{Solution}.{Domain}.Test.Api` +--- + ## Materialization Rules 1. Copy each `.template` file into the corresponding project location. -2. Remove `.template` suffix from output files. -3. Rename `Domain.*.csproj.template` to `{Solution}.{Domain}.*.csproj`. -4. Rename `Entity*` files to use concrete entity names. -5. Keep folder structure identical to template tree. -6. Preserve line endings and indentation. +2. Remove the `.template` suffix from output files. +3. Rename `Domain.*.csproj.template` → `{Solution}.{Domain}.*.csproj`. +4. Rename `Entity*` files to use the concrete entity name (e.g. `{Entity}Service.cs`). +5. Keep folder structure identical to the template tree (after stripping `_shared/`, `sqlserver/`, `postgres/`, `rop/` routing segments). +6. Preserve line endings and indentation exactly. +7. For `Subscribe/_shared/` files: output into the root of the Subscribe project (not a `_shared/` subfolder). +8. For `Subscribers/` subfolder: output into `Subscribers/` within the Subscribe project. + +--- ## Required Post-Generation Adjustments After template materialization: -1. In API controllers: -- Ensure routes use concrete kebab-case paths. -- Verify OpenApi tags use `{EntityPlural}`. - -2. In Database seed data: -- If status model is used, ensure `Pending`, `Confirmed`, `Cancelled` values are present unless the caller supplied alternatives. - -3. In Infrastructure repository: -- Ensure EfDb mapped model property uses concrete plural entity name. - -4. In Program files: -- Ensure namespaces match generated project names. +1. **API controllers**: Confirm routes use concrete kebab-case paths; OpenApi tags use `{EntityPlural}`. +2. **Database seed data**: If Reference Data = Yes, ensure `{Entity}Status` seed values (e.g. `Pending`, `Confirmed`, `Cancelled`) are present unless alternatives were supplied. +3. **Infrastructure EfDb**: Confirm the mapped model property uses the concrete plural entity name. +4. **Subscribe Program.cs**: If Reference Data = No, remove the `AddReferenceDataOrchestrator()` line. +5. **Program files**: Confirm all namespaces match the generated project names. +6. **Test projects**: Confirm namespaces match `{Solution}.{Domain}.Test.Unit` / `{Solution}.{Domain}.Test.Api`; unit tests follow `WithGenericTester`; API tests follow `WithApiTester<{Solution}.{Domain}.Api.Program>`; assertions use AwesomeAssertions (not FluentAssertions). +7. **Solution structure**: Add all generated domain and test projects to the Visual Studio solution, grouped under a solution folder named `{Domain}`. -5. In test projects: -- Ensure test namespaces and project names match `{Solution}.{Domain}.Test.Unit` and `{Solution}.{Domain}.Test.Api`. -- Ensure Unit tests follow `WithGenericTester` patterns. -- Ensure Api tests follow `WithApiTester<{Solution}.{Domain}.Api.Program>` patterns. -- Ensure assertions use AwesomeAssertions (not FluentAssertions). - -6. In solution structure: -- Add all generated domain and test projects to the Visual Studio solution. -- Group all generated domain and test projects under a solution folder named `{Domain}`. +--- ## Validation -Run `dotnet build` for all generated projects to check for compilation errors: +Run `dotnet build` for all generated projects: -- `{targetRoot}/{Solution}.{Domain}.Contracts` -- `{targetRoot}/{Solution}.{Domain}.Application` -- `{targetRoot}/{Solution}.{Domain}.Infrastructure` -- `{targetRoot}/{Solution}.{Domain}.Api` -- `{targetRoot}/{Solution}.{Domain}.Database` -- `{testsRoot}/{Solution}.{Domain}.Test.Unit` -- `{testsRoot}/{Solution}.{Domain}.Test.Api` +``` +dotnet build {targetRoot}/{Solution}.{Domain}.Contracts +dotnet build {targetRoot}/{Solution}.{Domain}.Application +dotnet build {targetRoot}/{Solution}.{Domain}.Infrastructure +dotnet build {targetRoot}/{Solution}.{Domain}.Api +dotnet build {targetRoot}/{Solution}.{Domain}.Database +dotnet build {testsRoot}/{Solution}.{Domain}.Test.Unit +dotnet build {testsRoot}/{Solution}.{Domain}.Test.Api +``` -Run tests and ensure they pass: +Also build any conditional projects that were included. -- `dotnet test {testsRoot}/{Solution}.{Domain}.Test.Unit` -- `dotnet test {testsRoot}/{Solution}.{Domain}.Test.Api` +Run tests: -If errors are found, fix them before completing. +``` +dotnet test {testsRoot}/{Solution}.{Domain}.Test.Unit +dotnet test {testsRoot}/{Solution}.{Domain}.Test.Api +``` -If tests fail, fix the generated code/tests and rerun until both Unit and Api test projects pass. +Fix all compilation errors and test failures before reporting completion. + +--- ## Completion Gate diff --git a/.github/skills/acquire-codebase-knowledge/README.md b/.github/skills/acquire-codebase-knowledge/README.md new file mode 100644 index 00000000..5160b7d7 --- /dev/null +++ b/.github/skills/acquire-codebase-knowledge/README.md @@ -0,0 +1,45 @@ +# Acquire Codebase Knowledge + +Maps an unfamiliar codebase and produces seven structured onboarding documents — everything a new team member needs to work effectively on the project, grounded entirely in what the files and tooling actually show. + +## When to use + +- Onboarding onto a codebase you haven't worked in before. +- Producing architecture documentation for a team or reviewer. +- Auditing a project's structure, conventions, and concerns before a large change. + +Not for routine feature work, bug fixes, or narrow code edits. + +## How to invoke + +**Claude Code:** +``` +/acquire-codebase-knowledge +``` + +**GitHub Copilot Chat:** +``` +#file:.github/skills/acquire-codebase-knowledge/SKILL.md map this codebase +``` + +Optionally supply a focus area — e.g. `architecture only` or `testing and concerns` — to prioritise those documents first while still producing the full set. + +## What gets produced + +Seven documents written to `docs/codebase/`: + +| Document | Content | +|----------|---------| +| `STACK.md` | Languages, frameworks, runtimes, and key dependencies | +| `STRUCTURE.md` | Directory layout and project organisation | +| `ARCHITECTURE.md` | Architectural style, layers, component relationships | +| `CONVENTIONS.md` | Coding standards, naming, patterns in use | +| `INTEGRATIONS.md` | External services, APIs, and infrastructure dependencies | +| `TESTING.md` | Test strategy, frameworks, and coverage approach | +| `CONCERNS.md` | Known issues, gaps, tech debt, and open questions | + +Every claim is traceable to a source file or terminal output. Unknowns are marked `[TODO]`; intent-dependent decisions are marked `[ASK USER]` and surfaced at the end of the run. + +## Reference + +- [SKILL.md](./SKILL.md) — full 4-phase workflow, output contract, and focus-area mode detail. diff --git a/.github/skills/add-capability/README.md b/.github/skills/add-capability/README.md new file mode 100644 index 00000000..1fe6506b --- /dev/null +++ b/.github/skills/add-capability/README.md @@ -0,0 +1,56 @@ +# Add Capability + +Retrofits an existing CoreEx domain with messaging and integration support — adding only what's missing rather than regenerating the domain from scratch. + +## When to use + +| Scenario | Use this | Not this | +|----------|----------|----------| +| Domain exists, needs reliable event publishing | `/add-capability` | — | +| Domain exists, needs to consume events from other services | `/add-capability` | — | +| Creating a new domain from nothing | — | `/scaffold-domain-from-templates` or `/generate-domain` | +| New domain with messaging included from the start | — | `/scaffold-domain-from-templates` (Subscribe + Outbox Relay are options there) | + +## How to invoke + +**Claude Code:** +``` +/add-capability +``` + +**GitHub Copilot Chat:** +``` +#file:.github/skills/add-capability/SKILL.md add relay and subscribers to Contoso Orders +``` + +The skill will inspect the existing domain, ask only what it cannot infer, then apply targeted edits. + +## Retrofit modes + +| Mode | When | What gets added | +|------|------|----------------| +| **A — Outbox Relay** | Domain writes data but has no reliable event publishing | `*.Outbox.Relay` project; relay + Service Bus publisher wiring; outbox migration + `dbex.yaml` alignment | +| **B — Subscribe** | Domain needs to consume events from other services | `*.Subscribe` project; Service Bus receiver; `SubscribedBase` subscriber classes; hosted service wiring | +| **C — Both** | Domain needs to publish and consume | Modes A and B combined | +| **D — Subscribers only** | Subscribe host exists but subscriber classes or registration are incomplete | New subscriber classes and registration only — no host changes | + +## What the skill inspects before asking + +- Which `*.Api`, `*.Outbox.Relay`, and `*.Subscribe` projects already exist. +- Database engine in use — SQL Server or PostgreSQL — from package references and `Program.cs` wiring. +- Whether outbox infrastructure is already present (migration file + `dbex.yaml outbox: true`). +- Whether reference data is present (`ReferenceDataService` / `*.CodeGen` project) — affects Subscribe wiring. +- Existing Service Bus, telemetry, and caching wiring — to avoid duplicating what's already there. + +The skill asks only for what it cannot safely infer. + +## Database engine support + +Both SQL Server and PostgreSQL are supported. The skill detects the engine from the existing codebase and applies the matching wiring throughout. If the engine is ambiguous it will ask before making changes. + +## Reference + +- [SKILL.md](./SKILL.md) — entry point, assumptions, and 6-step workflow summary. +- [references/workflow.md](./references/workflow.md) — detailed per-mode wiring instructions for both engines. +- [references/messaging-retrofit-checklist.md](./references/messaging-retrofit-checklist.md) — completion gate checklist. +- [references/messaging-retrofit-checkpoints.md](./references/messaging-retrofit-checkpoints.md) — inspection heuristics and detection signals. diff --git a/.github/skills/add-capability/SKILL.md b/.github/skills/add-capability/SKILL.md index f48f4119..5402664d 100644 --- a/.github/skills/add-capability/SKILL.md +++ b/.github/skills/add-capability/SKILL.md @@ -11,30 +11,30 @@ Retrofitting an existing domain with messaging and integration support. Choose o ## When to Use -- Add `Outbox.Relay` to publish integration events reliably. +- Add `Outbox.Relay` to publish integration events reliably via the transactional outbox pattern. - Add `Subscribe` to consume integration events from other services. - Add or align Azure Service Bus wiring. - Add initial subscriber classes and registration. ## When Not to Use -- Creating a new domain from scratch — use `/generate-domain`. +- Creating a new domain from scratch — use `/generate-domain` or `/scaffold-domain-from-templates`. - Bootstrapping a new solution — use the starter bootstrap workflow. - Non-CoreEx brownfield migrations. -## MVP Assumptions +## Assumptions - Existing CoreEx-style domain shape (Contracts, Application, Infrastructure, Api, Database). -- SQL Server for outbox support. +- **Database engine**: SQL Server (default) or PostgreSQL — ask if not determinable from the existing codebase. - Azure Service Bus for publish/subscribe. If different backends are needed, ask before making changes. ## Workflow -1. **Load context**: Read host-setup, event-subscribers, application-services, database-project instructions + sample hosts. -2. **Inspect domain state**: Detect existing hosts, database support, messaging packages, event subjects. -3. **Clarify**: Ask only what cannot be inferred (which domain, which capability, topics/payloads if needed). +1. **Load context**: Read host-setup, event-subscribers, application-services, tooling instructions + sample hosts for both SQL Server (Shopping) and PostgreSQL (Products). +2. **Inspect domain state**: Detect existing hosts, database engine, outbox wiring, messaging packages, event subjects. +3. **Clarify**: Ask only what cannot be inferred — which domain, which capability, DB engine if ambiguous, topics/payloads if needed, reference data presence. 4. **Choose mode**: A (relay), B (subscribe), C (both), or D (subscribers only). 5. **Apply changes**: Targeted edits only — reuse patterns, don't regenerate. 6. **Validate**: Run checklist, confirm clean build. @@ -47,4 +47,6 @@ For detailed step-by-step workflow, see [`references/workflow.md`](references/wo - [Event Subscriber Conventions](/.github/instructions/coreex-event-subscribers.instructions.md) - [Application Service Conventions](/.github/instructions/coreex-application-services.instructions.md) - [Developer Tooling Conventions](/.github/instructions/coreex-tooling.instructions.md) -- Sample hosts: `samples/src/Contoso.Products.Api/Program.cs`, `samples/src/Contoso.Products.Subscribe/Program.cs`, `samples/src/Contoso.Products.Outbox.Relay/Program.cs` +- SQL Server sample hosts: `samples/src/Contoso.Shopping.Api/Program.cs`, `samples/src/Contoso.Shopping.Subscribe/Program.cs`, `samples/src/Contoso.Shopping.Outbox.Relay/Program.cs` +- PostgreSQL sample hosts: `samples/src/Contoso.Products.Api/Program.cs`, `samples/src/Contoso.Products.Subscribe/Program.cs`, `samples/src/Contoso.Products.Outbox.Relay/Program.cs` +- Domain templates: `/.github/templates/domain/` diff --git a/.github/skills/add-capability/references/messaging-retrofit-checklist.md b/.github/skills/add-capability/references/messaging-retrofit-checklist.md index bb11fbc6..4b83c8c1 100644 --- a/.github/skills/add-capability/references/messaging-retrofit-checklist.md +++ b/.github/skills/add-capability/references/messaging-retrofit-checklist.md @@ -5,42 +5,62 @@ Use this checklist as the completion gate for `/add-capability` messaging and in ## Discovery - [ ] Identified the target domain and its existing project/host shape. -- [ ] Determined whether API, Database, Outbox.Relay, and Subscribe projects already exist. -- [ ] Determined whether SQL Server/outbox and Azure Service Bus are already present, missing, or intentionally not used. -- [ ] Confirmed any user choices that could not be inferred safely. +- [ ] Determined which of Api, Database, Outbox.Relay, and Subscribe projects already exist. +- [ ] Identified the database engine in use: **SQL Server** or **PostgreSQL**. +- [ ] Determined whether outbox infrastructure (migration file + `dbex.yaml outbox: true`) is already present. +- [ ] Determined whether Azure Service Bus wiring is already present or missing. +- [ ] Determined whether reference data is present (`ReferenceDataService` / `*.CodeGen` project). +- [ ] Confirmed any choices that could not be inferred safely (engine, subjects, payloads). ## Project and Package Alignment - [ ] Added only the missing projects required by the requested retrofit. -- [ ] Added only the missing package and project references required by the affected hosts. -- [ ] Preserved the existing layered references and naming conventions. +- [ ] Added only the missing package and project references for the affected hosts. +- [ ] `` present in csproj for every new Relay and Subscribe project. +- [ ] Preserved existing layered references and naming conventions. +- [ ] No second database engine introduced — all new wiring uses the engine already in the domain. -## Relay Retrofit +## Relay Retrofit (Mode A) -- [ ] Relay host was added or aligned when requested. -- [ ] Relay `Program.cs` uses the expected CoreEx host setup, SQL Server relay wiring, Service Bus publisher wiring, health checks, and telemetry. -- [ ] API host has event formatter and outbox publisher wiring when the domain is expected to publish integration events. -- [ ] Database project contains required outbox tables and stored procedures when relay support is added. +- [ ] `*.Outbox.Relay` project created or aligned. +- [ ] Relay `Program.cs` database wiring is engine-correct: + - SQL Server: `AddSqlServerClient` → `.AddSqlServerDatabase().AddSqlServerUnitOfWork().AddSqlServerOutboxRelay()` → `AddSqlServerOutboxRelayHostedService()` + - PostgreSQL: `AddAzureNpgsqlDataSource` → `.AddPostgresDatabase().AddPostgresUnitOfWork().AddPostgresOutboxRelay()` → `AddPostgresOutboxRelayHostedService()` +- [ ] Service Bus publisher wired: `AddAzureServiceBusClient` + `AddAzureServiceBusPublisher(o => o.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId)` +- [ ] `AddHostedServiceManager()` registered in services. +- [ ] `app.MapHostedServices()` called in middleware pipeline. +- [ ] Telemetry uses split names: `WithCoreExSqlServerTelemetry()` or `WithCoreExPostgresTelemetry()` **and** `WithCoreExServiceBusTelemetry()`. +- [ ] API host has outbox publisher wiring (no generic type parameter): + - SQL Server: `AddSqlServerOutboxPublisher()` + - PostgreSQL: `AddPostgresOutboxPublisher()` +- [ ] Database project has outbox migration file and `dbex.yaml` contains `outbox: true` and `outboxName: outbox`. -## Subscribe Retrofit +## Subscribe Retrofit (Mode B) -- [ ] Subscribe host was added or aligned when requested. -- [ ] Subscribe `Program.cs` uses hosted service manager, subscribed manager, Service Bus receiver, hosted service mapping, health checks, and telemetry. -- [ ] Subscriber classes inherit from `SubscribedBase`. -- [ ] Subscriber classes use `[ScopedService]` and `[Subscribe("...")]`. -- [ ] Subscriber logic delegates to Application services rather than embedding business logic. -- [ ] Shared subscriber error handling is added where needed. +- [ ] `*.Subscribe` project created or aligned. +- [ ] Subscribe `Program.cs` database wiring is engine-correct: + - SQL Server: `AddSqlServerClient` → `.AddSqlServerDatabase().AddSqlServerUnitOfWork().AddSqlServerOutboxPublisher().AddSqlServerEfDb<{Domain}EfDb>()` + - PostgreSQL: `AddAzureNpgsqlDataSource` → `.AddPostgresDatabase().AddPostgresUnitOfWork().AddPostgresOutboxPublisher().AddPostgresEfDb<{Domain}EfDb>()` +- [ ] Redis + FusionCache wiring present: `AddRedisDistributedCache` + `AddFusionCache().WithDistributedCache().WithStackExchangeRedisBackplane()`. +- [ ] If reference data present: `AddReferenceDataOrchestrator()` included. +- [ ] Service Bus receiver wired: `AddAzureServiceBusClient`, `AddSubscribedManager(...)`, `AzureServiceBusReceiving().WithSessionReceiver(...).WithSubscribedSubscriber().WithHostedService().Build()`. +- [ ] `AddHostedServiceManager()` registered in services. +- [ ] `app.MapHostedServices()` called in middleware pipeline. +- [ ] Telemetry uses split names: `WithCoreExSqlServerTelemetry()` or `WithCoreExPostgresTelemetry()` **and** `WithCoreExServiceBusTelemetry()`. +- [ ] Subscriber classes inherit `SubscribedBase` (generic). +- [ ] Subscriber classes carry `[ScopedService]` and `[Subscribe("...")]` attributes. +- [ ] Subscriber logic delegates to Application services — no business logic embedded in subscriber classes. ## Host and Convention Alignment -- [ ] Middleware order follows repo conventions. -- [ ] Dynamic service registration is used where expected. -- [ ] OpenTelemetry-compatible wiring is preserved or aligned for the affected hosts. -- [ ] Health endpoints and hosted service mapping are present where applicable. +- [ ] Middleware order follows repo conventions for each host type. +- [ ] Dynamic service registration (`AddDynamicServicesUsing`) used where already established in the domain. +- [ ] Health endpoints (`MapHealthChecks("/health")`) present in all new host `Program.cs` files. +- [ ] OpenTelemetry wiring preserves or aligns existing telemetry across the affected hosts. ## Validation -- [ ] Affected projects build or pass diagnostics. +- [ ] All affected projects build cleanly with no compiler errors or nullable warnings. - [ ] Any related tests were added or updated where practical. -- [ ] The final summary distinguishes completed retrofits from any blocked or intentionally deferred items. +- [ ] The final summary distinguishes completed retrofits from any blocked or deferred items. - [ ] Any remaining user decisions are listed explicitly as follow-up items. diff --git a/.github/skills/add-capability/references/messaging-retrofit-checkpoints.md b/.github/skills/add-capability/references/messaging-retrofit-checkpoints.md index e97110c9..32dbc3b1 100644 --- a/.github/skills/add-capability/references/messaging-retrofit-checkpoints.md +++ b/.github/skills/add-capability/references/messaging-retrofit-checkpoints.md @@ -6,79 +6,88 @@ Use these checkpoints when inspecting an existing domain before adding messaging Look for these project patterns first: -- `{Solution}.{Domain}.Api` +- `{Solution}.{Domain}.Contracts` - `{Solution}.{Domain}.Application` - `{Solution}.{Domain}.Infrastructure` - `{Solution}.{Domain}.Database` +- `{Solution}.{Domain}.Api` - `{Solution}.{Domain}.Outbox.Relay` - `{Solution}.{Domain}.Subscribe` If the domain does not follow a recognizable CoreEx-style layered shape, treat the retrofit as ambiguous and ask before proceeding. -## 2. Host Detection Signals +## 2. Database Engine Detection + +Determine the engine before choosing any wiring. Look for package references in `*.csproj` files and `Program.cs` registration calls: + +| Signal | Engine | +|--------|--------| +| `Aspire.Microsoft.Data.SqlClient`, `CoreEx.Database.SqlServer`, `Microsoft.EntityFrameworkCore.SqlServer` | SQL Server | +| `Aspire.Npgsql.*`, `CoreEx.Database.Postgres`, `Npgsql.EntityFrameworkCore.PostgreSQL` | PostgreSQL | +| `AddSqlServerClient(...)`, `AddSqlServerDatabase()`, `UseSqlServer(...)` | SQL Server | +| `AddAzureNpgsqlDataSource(...)`, `AddPostgresDatabase()`, `UseNpgsql(...)` | PostgreSQL | + +If both signals appear, ask before proceeding — do not assume. + +## 3. Host Detection Signals -| Capability or host | Evidence to inspect | Positive signal | +| Host / capability | Evidence to inspect | Positive signal | |---|---|---| | API host | `Program.cs`, controllers, `*.Api.csproj` | `AddMvcWebApi`, `AddHttpWebApi`, controllers, OpenAPI setup | -| Relay host | `*.Outbox.Relay\\Program.cs`, relay csproj | `AddSqlServerOutboxRelay`, `AddSqlServerOutboxRelayHostedService`, `AddAzureServiceBusPublisher` | -| Subscribe host | `*.Subscribe\\Program.cs`, `Subscribe\\**\\*.cs` | `AddSubscribedManager`, `AzureServiceBusReceiving`, `MapHostedServices`, subscriber classes | -| Outbox publisher in API | API `Program.cs`, infrastructure repository/publisher files | `AddEventFormatter`, `AddSqlServerOutboxPublisher` | -| Service Bus support | affected host `Program.cs`, csproj references | `AddAzureServiceBusClient("ServiceBus")`, `CoreEx.Azure.Messaging.ServiceBus` | -| Telemetry alignment | `Program.cs` | `WithCoreExTelemetry`, `WithCoreExSqlServerTelemetry`, `WithCoreExServiceBusTelemetry`, `UseOtlpExporter` | +| Outbox publisher in API | API `Program.cs` | `AddSqlServerOutboxPublisher()` or `AddPostgresOutboxPublisher()` (no generic type parameter) | +| Relay host | `*.Outbox.Relay/Program.cs`, relay csproj | Engine relay registration + `AddSqlServerOutboxRelayHostedService()` / `AddPostgresOutboxRelayHostedService()` | +| Subscribe host | `*.Subscribe/Program.cs`, `Subscribe/**/*.cs` | `AddSubscribedManager`, `AzureServiceBusReceiving`, `AddHostedServiceManager`, `MapHostedServices`, subscriber classes | +| Service Bus support | `Program.cs`, csproj references | `AddAzureServiceBusClient("ServiceBus")`, `CoreEx.Azure.Messaging.ServiceBus` | +| Telemetry alignment | `Program.cs` | `WithCoreExSqlServerTelemetry` / `WithCoreExPostgresTelemetry` **and** `WithCoreExServiceBusTelemetry` | +| Reference data | Application layer, `Program.cs` | `ReferenceDataService`, `AddReferenceDataOrchestrator`, `*.CodeGen` project | -## 3. Database and Outbox Detection +## 4. Database and Outbox Detection When adding a relay or reliable publication support, inspect for: -- `*.Database` project. -- outbox migrations. -- outbox stored procedures: - - `spOutboxEnqueue.g.sql` - - `spOutboxLeaseAcquire.g.sql` - - `spOutboxLeaseRelease.g.sql` - - `spOutboxBatchClaim.g.sql` - - `spOutboxBatchComplete.g.sql` - - `spOutboxBatchCancel.g.sql` -- database `Program.cs` and `dbex.yaml`. +- `*.Database` project with a `dbex.yaml` file. +- `dbex.yaml` contains `outbox: true` and `outboxName: outbox`. +- An outbox migration file (e.g. `*-000005-create-{domainKebab}-outbox.sql` or `.pgsql`). -If relay is requested and these assets are missing, plan to add them or stop and ask if the domain is intentionally non-SQL/outbox-based. +If the relay is requested and these assets are missing, plan to add the outbox migration and update `dbex.yaml`. Do not hand-write stored procedures — DbEx generates the outbox infrastructure from `dbex.yaml`. -## 4. Subscriber Detection +## 5. Subscriber Detection Inspect subscriber code for: -- `[ScopedService]` -- `[Subscribe("...")]` -- inheritance from `SubscribedBase` -- `OnReceiveAsync` -- optional shared `ErrorHandler` -- delegation to Application services rather than embedded business logic +- Inheritance from `SubscribedBase` (generic — the type parameter is the payload contract) +- `[ScopedService]` and `[Subscribe("...")]` attributes on the class +- `OnReceiveAsync` implementation +- Delegation to Application service methods — no business logic in the subscriber body +- Registration via `AddSubscribersUsing()` inside `AddSubscribedManager` -## 5. Recommended MVP Retrofit Modes +## 6. Recommended Retrofit Modes | Current state | Requested need | Recommended retrofit | |---|---|---| -| API + Database, no relay | reliable integration-event publishing | Add `Outbox.Relay`, align API outbox publisher wiring | -| API + Database, no subscribe | consume external events | Add `Subscribe` host and initial subscribers | -| API + Database, no relay, no subscribe | publish and consume | Add both relay and subscribe | -| Subscribe host exists | new subjects or handlers | Add subscriber classes and registration only | -| API exists, no recognizable database/outbox shape | relay | Ask before proceeding; MVP assumes SQL Server/outbox path | +| Api + Database, no relay | Reliable integration-event publishing | Mode A: add `Outbox.Relay`, align API outbox publisher wiring, ensure outbox migration + dbex.yaml | +| Api + Database, no subscribe | Consume external events | Mode B: add `Subscribe` host and initial subscribers | +| Api + Database, no relay, no subscribe | Publish and consume | Mode C: both | +| Subscribe host exists, subscribers incomplete | New subjects or handlers | Mode D: add subscriber classes and registration only | +| Api exists, no recognisable Database/outbox shape | Relay requested | Ask before proceeding — outbox infrastructure must exist first | -## 6. Ambiguity Triggers +## 7. Ambiguity Triggers Ask before changing anything when: -- multiple similarly named domains could match the request. -- there is already partial relay or subscribe wiring that does not match the sample conventions. -- the domain appears to use non-SQL Server persistence for write workflows. -- the domain appears to use a broker other than Azure Service Bus. -- event subjects, payload contracts, or application service entry points are unclear. +- Multiple similarly named domains could match the request. +- There is already partial relay or subscribe wiring that does not match the sample conventions. +- The database engine cannot be determined from the existing codebase. +- The domain appears to use a broker other than Azure Service Bus. +- Event subjects, payload contracts, or application service entry points are unclear. +- Relay is requested but there is no Database project or outbox migration. -## 7. Default Initial Assumptions +## 8. Default Assumptions -Unless the user says otherwise, the MVP retrofit assistant should assume: +Unless determinable from the codebase or stated by the user: -- SQL Server for outbox-backed write workflows. -- Azure Service Bus for publish/subscribe integration. -- OpenTelemetry-compatible host telemetry wiring should be preserved or aligned. -- relay and subscribe hosts should mirror the sample architecture, not invent a new host style. +- **Database engine**: SQL Server. +- **Broker**: Azure Service Bus with session receiver (`UsePartitionKeyConvertedToAnId` strategy). +- **Telemetry**: OpenTelemetry with both engine telemetry and Service Bus telemetry wired together. +- Relay and subscribe hosts mirror the sample architecture for the matched engine — do not invent a new host style. +- `RuntimeHostConfigurationOption Azure.Experimental.EnableActivitySource = true` is always included in Relay and Subscribe csproj files. diff --git a/.github/skills/add-capability/references/workflow.md b/.github/skills/add-capability/references/workflow.md index c766597a..ad83f519 100644 --- a/.github/skills/add-capability/references/workflow.md +++ b/.github/skills/add-capability/references/workflow.md @@ -10,84 +10,108 @@ Before making changes, load: - `coreex-application-services.instructions.md` - `coreex-tooling.instructions.md` -2. Sample host wiring from: - - `samples/src/Contoso.Products.Api/Program.cs` - - `samples/src/Contoso.Products.Subscribe/Program.cs` - - `samples/src/Contoso.Products.Outbox.Relay/Program.cs` +2. Sample host wiring — read both engines for comparison: + - SQL Server (Shopping): `samples/src/Contoso.Shopping.Api/Program.cs`, `samples/src/Contoso.Shopping.Subscribe/Program.cs`, `samples/src/Contoso.Shopping.Outbox.Relay/Program.cs` + - PostgreSQL (Products): `samples/src/Contoso.Products.Api/Program.cs`, `samples/src/Contoso.Products.Subscribe/Program.cs`, `samples/src/Contoso.Products.Outbox.Relay/Program.cs` -3. Domain templates under `/.github/templates/domain/**` +3. Domain templates under `/.github/templates/domain/` — use the engine-specific subdirectory (`sqlserver/` or `postgres/`) that matches the domain being retrofitted. ## Step 2: Inspect Domain State Determine current shape before proposing changes. Inspect for: -- Domain boundary and project names +- Domain boundary and project names (`{Solution}.{Domain}.*`) - Existing hosts: `*.Api`, `*.Outbox.Relay`, `*.Subscribe` -- Database support: `*.Database` project, outbox tables/procedures, SQL Server references -- Messaging support: `CoreEx.Events`, `CoreEx.Azure.Messaging.ServiceBus`, `AddEventFormatter`, `AddSqlServerOutboxPublisher`, `AddSubscribedManager`, `AzureServiceBusReceiving` -- Existing telemetry and health wiring +- **Database engine**: presence of `Microsoft.Data.SqlClient` / `CoreEx.Database.SqlServer` / `Aspire.Microsoft.Data.SqlClient` (SQL Server) vs `Npgsql` / `CoreEx.Database.Postgres` / `Aspire.Npgsql.*` (PostgreSQL) +- Outbox wiring in API host: `AddSqlServerOutboxPublisher()` or `AddPostgresOutboxPublisher()` +- Outbox infrastructure in Database project: outbox migration file + `dbex.yaml` with `outbox: true` +- Messaging support: `CoreEx.Azure.Messaging.ServiceBus`, `AddAzureServiceBusClient`, `AddAzureServiceBusPublisher`, `AddSubscribedManager`, `AzureServiceBusReceiving` +- Reference data presence: `ReferenceDataService`, `AddReferenceDataOrchestrator`, `*.CodeGen` project +- Existing telemetry: `WithCoreExSqlServerTelemetry` / `WithCoreExPostgresTelemetry`, `WithCoreExServiceBusTelemetry` - Integration-event semantics: subjects, subscriber classes, related service methods -Use conservative detection. Ask if ambiguous. +Use conservative detection. Ask if ambiguous. See [`messaging-retrofit-checkpoints.md`](messaging-retrofit-checkpoints.md) for detection signals. ## Step 3: Clarify User Intent -Ask only what cannot be inferred: +Ask only what cannot be inferred from inspection: + - Which domain to retrofit? -- Which capability: relay, subscribe, subscriber classes, or combined? -- Use SQL Server and Azure Service Bus as defaults? +- Which capability: relay only, subscribe only, both, or subscriber classes only? +- **Database engine** — SQL Server or PostgreSQL? (default SQL Server if not determinable) +- If adding subscribe: does the domain use reference data? (`ReferenceDataService` / `*.CodeGen` project present) - If adding subscribers: what subjects and payload contracts? - Infrastructure/host wiring only, or also application-facing handlers? ## Step 4: Choose Retrofit Mode ### Mode A — Add Outbox.Relay -Use when domain already writes data and should publish integration events reliably. + +Use when the domain writes data and should publish integration events reliably. Expected work: -- Create `*.Outbox.Relay` project if missing -- Add packages and project references -- Add relay `Program.cs` wiring per host-setup conventions -- Ensure database has outbox tables and procedures -- Ensure API host has event formatter + outbox publisher wiring +- Create `*.Outbox.Relay` project if missing (use `/.github/templates/domain/Outbox.Relay//` as reference) +- Add packages: `CoreEx.AspNetCore`, `CoreEx.Azure.Messaging.ServiceBus`, engine database package (`CoreEx.Database.SqlServer` or `CoreEx.Database.Postgres`), Aspire client package, OpenTelemetry packages +- Add `` to csproj +- Wire relay `Program.cs`: + - **SQL Server**: `AddSqlServerClient` → `.AddSqlServerDatabase().AddSqlServerUnitOfWork().AddSqlServerOutboxRelay()` → `AddSqlServerOutboxRelayHostedService()`; telemetry: `WithCoreExSqlServerTelemetry()` + - **PostgreSQL**: `AddAzureNpgsqlDataSource` → `.AddPostgresDatabase().AddPostgresUnitOfWork().AddPostgresOutboxRelay()` → `AddPostgresOutboxRelayHostedService()`; telemetry: `WithCoreExPostgresTelemetry()` + - Both engines: `AddAzureServiceBusClient` + `AddAzureServiceBusPublisher(o => o.SessionIdStrategy = ...)`, `AddHostedServiceManager()`, `app.MapHostedServices()`; telemetry also includes `WithCoreExServiceBusTelemetry()` +- Ensure API host has `AddSqlServerOutboxPublisher()` / `AddPostgresOutboxPublisher()` (no generic type parameter) +- Ensure Database project has outbox migration file and `dbex.yaml` with `outbox: true` and `outboxName: outbox` ### Mode B — Add Subscribe -Use when domain must consume integration events/commands from other services. + +Use when the domain must consume integration events from other services. Expected work: -- Create `*.Subscribe` project if missing -- Add Service Bus client and receiver wiring -- Add hosted service manager and mapping -- Add subscriber classes and registration -- Reuse reference data, cache, infrastructure, telemetry patterns +- Create `*.Subscribe` project if missing (use `/.github/templates/domain/Subscribe//` and `Subscribe/_shared/` as reference) +- Add packages: `CoreEx.AspNetCore`, `CoreEx.Azure.Messaging.ServiceBus`, engine EF package, Aspire client + Redis packages, OpenTelemetry packages +- Add `` to csproj +- Wire subscribe `Program.cs`: + - **SQL Server**: `AddSqlServerClient` → `.AddSqlServerDatabase().AddSqlServerUnitOfWork().AddSqlServerOutboxPublisher().AddSqlServerEfDb<{Domain}EfDb>()`; telemetry: `WithCoreExSqlServerTelemetry()` + - **PostgreSQL**: `AddAzureNpgsqlDataSource` → `.AddPostgresDatabase().AddPostgresUnitOfWork().AddPostgresOutboxPublisher().AddPostgresEfDb<{Domain}EfDb>()`; telemetry: `WithCoreExPostgresTelemetry()` + - Both engines: Redis + FusionCache wiring, `AddAzureServiceBusClient`, `AddSubscribedManager(...)`, `AzureServiceBusReceiving().WithSessionReceiver(...).WithSubscribedSubscriber().WithHostedService().Build()`, `AddHostedServiceManager()`, `app.MapHostedServices()`; telemetry includes `WithCoreExServiceBusTelemetry()` + - **If reference data present**: include `AddReferenceDataOrchestrator()` in service registration +- Add subscriber classes inheriting `SubscribedBase` with `[ScopedService]` and `[Subscribe("...")]` +- Subscriber logic delegates to Application services — no business logic in subscriber classes ### Mode C — Add Both Relay and Subscribe -Service publishes its own events AND consumes events from others. + +Apply Mode A then Mode B. Confirm both are needed before creating two new host projects. ### Mode D — Add Subscribers to Existing Subscribe Host -Host exists but subscriber classes, registration, or error handling incomplete. + +Use when the Subscribe host exists but subscriber classes, registration, or error handling are incomplete. + +Expected work: +- Add missing subscriber classes (inherit `SubscribedBase`, `[ScopedService]`, `[Subscribe("...")]`) +- Register with `AddSubscribersUsing()` in `AddSubscribedManager` +- Delegate to existing Application service methods +- Do not re-create host wiring that already exists ## Step 5: Apply Incremental Changes Prefer targeted edits over regeneration. Rules: -1. Reuse existing project naming and layering -2. Do not duplicate wiring that exists +1. Reuse existing project naming and layering — never invent new conventions +2. Do not duplicate wiring that already exists 3. Keep subscriber logic thin; delegate to Application services 4. Preserve host middleware order and telemetry conventions -5. Reuse domain templates only for missing pieces -6. If domain shape inconsistent, stop and explain blockers +5. Match the engine already in use — do not introduce a second database engine +6. Use domain templates only for missing pieces; apply only the engine-specific subdirectory that matches +7. If the domain shape is inconsistent with CoreEx conventions, stop and explain blockers before changing anything ## Step 6: Validate -Run messaging-retrofit-checklist.md completion gate. +Run [`messaging-retrofit-checklist.md`](messaging-retrofit-checklist.md) as the completion gate. Minimum criteria: -- `Program.cs` files follow host setup conventions -- Required package/project references present -- Relay outbox database assets exist when relay added -- Subscribers registered with `SubscribedBase` patterns -- Files fit existing naming/layering conventions -- Clean build/diagnostics +- `Program.cs` files follow host setup conventions for the correct engine +- Required package/project references present including `RuntimeHostConfigurationOption` +- Relay: outbox migration and `dbex.yaml outbox: true` exist; relay hosted service registered +- Subscribe: subscribers inherit `SubscribedBase`; `AddHostedServiceManager()` and `MapHostedServices()` present +- Telemetry uses correct engine + Service Bus split names +- Clean build; no compiler errors or nullable warnings diff --git a/.github/skills/aspire/README.md b/.github/skills/aspire/README.md new file mode 100644 index 00000000..4783b971 --- /dev/null +++ b/.github/skills/aspire/README.md @@ -0,0 +1,42 @@ +# Aspire + +Orchestrates the Aspire distributed application using the Aspire CLI — starting, stopping, inspecting resources, tailing logs, and managing the AppHost without needing to remember every command. + +## When to use + +Use for anything Aspire-specific: starting the app, waiting for a resource to be healthy, viewing logs or traces, adding an integration, or running environment diagnostics. + +Use `dotnet` CLI directly for non-Aspire .NET operations (build, test, run a single project). + +## How to invoke + +**Claude Code:** +``` +/aspire +``` + +**GitHub Copilot Chat:** +``` +#file:.github/skills/aspire/SKILL.md start the app and wait for the API to be healthy +``` + +Optionally supply a resource name, command, or context — e.g. `start isolated`, `logs products-api`, `debug startup failure`. + +## Common operations + +| What you want | Command | +|---------------|---------| +| Start the app | `aspire start` | +| Start isolated (no shared state with other instances) | `aspire start --isolated` | +| Wait until a resource is healthy | `aspire wait ` | +| Stop the app | `aspire stop` | +| List all resources and their status | `aspire describe` | +| Stream console logs | `aspire logs [resource]` | +| View structured logs / traces | `aspire otel logs [resource]` · `aspire otel traces [resource]` | +| Rebuild a changed .NET project | `aspire resource rebuild` | +| Add an Aspire integration | `aspire add` | +| Environment diagnostics | `aspire doctor` | + +## Reference + +- [SKILL.md](./SKILL.md) — full CLI command reference, key workflows, and agent environment guidance. diff --git a/.github/skills/coreex-docs-sync/README.md b/.github/skills/coreex-docs-sync/README.md new file mode 100644 index 00000000..e513d62f --- /dev/null +++ b/.github/skills/coreex-docs-sync/README.md @@ -0,0 +1,67 @@ +# CoreEx Docs Sync + +Fetches the CoreEx architecture docs and per-package AI guides from GitHub and caches them locally. Once cached, the `coreex-expert` agent uses the local copies instead of making live GitHub fetches on every question. + +## When to run + +- **First time** setting up a consuming project — populates the cache from scratch. +- **After bumping a CoreEx NuGet version** — keeps the guides in sync with the version in use. +- **When the CoreEx Expert recommends it** — the agent checks the manifest on every session and flags if the cache is older than 30 days or the recorded version doesn't match the project's current packages. + +Do not run this inside the CoreEx repository itself — the docs are already present locally at `samples/docs/` and `src/*/AGENTS.md`. + +## How to invoke + +**Claude Code:** +``` +/coreex-docs-sync +``` + +**GitHub Copilot Chat:** +``` +#file:.github/skills/coreex-docs-sync/SKILL.md sync the CoreEx docs +``` + +No arguments required. + +## What gets written + +``` +.github/docs/coreex/ + .manifest ← sync date, CoreEx version, referenced packages + local-dev.md + layers.md + patterns.md + contracts-layer.md + domain-layer.md + application-layer.md + infrastructure-layer.md + hosts-layer.md + testing.md + tooling.md + aspire.md + agents/ + CoreEx.md ← one guide per package, always synced + CoreEx.AspNetCore.md + CoreEx.AspNetCore.NSwag.md + CoreEx.Azure.Messaging.ServiceBus.md + CoreEx.Caching.FusionCache.md + CoreEx.CodeGen.md + CoreEx.Data.md + CoreEx.Database.md + CoreEx.Database.Postgres.md + CoreEx.Database.SqlServer.md + CoreEx.DomainDriven.md + CoreEx.EntityFrameworkCore.md + CoreEx.Events.md + CoreEx.RefData.md + CoreEx.UnitTesting.md + CoreEx.Validation.md +``` + +All 16 package guides are synced unconditionally — not just the packages the project currently references. This lets the expert recommend adopting a new package with full knowledge of what it offers. The `referenced-packages` field in the manifest records which packages are actually in the project so the expert can distinguish the two. + +## Reference + +- [SKILL.md](./SKILL.md) — full detail on detection logic, manifest format, and re-run triggers. +- [CoreEx Expert agent README](../agents/README.md) — how the agent uses the cache and when it suggests a refresh. diff --git a/.github/skills/generate-domain/README.md b/.github/skills/generate-domain/README.md new file mode 100644 index 00000000..b01e7600 --- /dev/null +++ b/.github/skills/generate-domain/README.md @@ -0,0 +1,48 @@ +# Generate Domain + +Scaffolds all layers of a new CoreEx domain from scratch using reasoning and convention — asking for your entity shape and generating code tailored to it, aligned to the Contoso sample architecture. + +## When to use + +| Scenario | Use this | Not this | +|----------|----------|----------| +| Entity has custom fields, types, or business rules | `/generate-domain` | — | +| You want the agent to reason about validation, event naming, and query config | `/generate-domain` | — | +| Entity fits the standard template shape exactly and you want fast, exact output | — | `/scaffold-domain-from-templates` | +| Adding capabilities to an existing domain | — | `/add-capability` | + +## How to invoke + +**Claude Code:** +``` +/generate-domain +``` + +**GitHub Copilot Chat:** +``` +#file:.github/skills/generate-domain/SKILL.md scaffold a new Orders domain with an Order entity +``` + +Optionally supply solution, domain, and entity up front — e.g. `Contoso Orders Order`. The skill will confirm all inputs before generating anything. + +## What gets generated + +All layers in order, with baseline test projects: + +| Layer | Project | +|-------|---------| +| Contracts | `{Solution}.{Domain}.Contracts` — entity contracts, reference data types | +| Application | `{Solution}.{Domain}.Application` — services, interfaces, validators | +| Infrastructure | `{Solution}.{Domain}.Infrastructure` — EF Core repositories, mappers, persistence models | +| API | `{Solution}.{Domain}.Api` — controllers, `Program.cs`, OpenAPI | +| Database | `{Solution}.{Domain}.Database` — migrations, `dbex.yaml`, seed data | +| Unit tests | `{Solution}.{Domain}.Test.Unit` | +| API tests | `{Solution}.{Domain}.Test.Api` | + +The generated code follows CoreEx conventions throughout: `[Contract]`, `IETag`, `IChangeLog`, `[IdempotencyKey]`, `TransactionAsync`, `QueryArgsConfig`, outbox events, FusionCache, OpenTelemetry. + +## Reference + +- [SKILL.md](./SKILL.md) — entry point, required inputs, and workflow overview. +- [references/workflow.md](./references/workflow.md) — detailed 8-phase generation workflow with naming conventions and quality gates. +- [Domain scaffold templates](../../templates/domain/README.md) — use instead when the entity fits the standard shape and you want deterministic output. diff --git a/.github/templates/domain/Api/Controllers/EntityReadController.cs.template b/.github/templates/domain/Api/Controllers/EntityReadController.cs.template index 73c37325..223928f3 100644 --- a/.github/templates/domain/Api/Controllers/EntityReadController.cs.template +++ b/.github/templates/domain/Api/Controllers/EntityReadController.cs.template @@ -17,4 +17,9 @@ public class {Entity}ReadController(WebApi webApi, I{Entity}ReadService service) [Query(supportsOrderBy: true), Paging(supportsCount: true)] public Task QueryAsync() => _webApi.GetAsync(Request, (ro, _) => _service.QueryAsync(ro.QueryArgs, ro.PagingArgs)); + + [HttpGet("$schema")] + [ProducesResponseType(typeof(JsonElement), StatusCodes.Status200OK)] + public Task QuerySchemaAsync() => _webApi.GetAsync(Request, (_, _) + => _service.QuerySchemaAsync()); } diff --git a/.github/templates/domain/Api/Controllers/ReferenceDataController.cs.template b/.github/templates/domain/Api/Controllers/ReferenceDataController.cs.template deleted file mode 100644 index ddebd64a..00000000 --- a/.github/templates/domain/Api/Controllers/ReferenceDataController.cs.template +++ /dev/null @@ -1,12 +0,0 @@ -namespace {Solution}.{Domain}.Api.Controllers; - -[ApiController, Route("/api/refdata")] -public class ReferenceDataController(WebApi webApi) : ControllerBase -{ - private readonly WebApi _webApi = webApi.ThrowIfNull(); - - [HttpGet("{entityKebab}-statuses"), HttpHead("{entityKebab}-statuses")] - [ProducesResponseType(typeof({Entity}Status[]), StatusCodes.Status200OK)] - public Task Get{Entity}StatusesAsync([FromQuery] IEnumerable? codes = default, string? text = default) - => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetWithFilterAsync<{Entity}Status>(codes, text, ro.IsIncludeInactive, ct)); -} diff --git a/.github/templates/domain/Api/GlobalUsing.cs.template b/.github/templates/domain/Api/GlobalUsing.cs.template index cbf89e82..99eeef58 100644 --- a/.github/templates/domain/Api/GlobalUsing.cs.template +++ b/.github/templates/domain/Api/GlobalUsing.cs.template @@ -1,6 +1,7 @@ global using {Solution}.{Domain}.Application; global using {Solution}.{Domain}.Application.Interfaces; global using {Solution}.{Domain}.Contracts; +global using {Solution}.{Domain}.Infrastructure.Repositories; global using CoreEx; global using CoreEx.AspNetCore.Mvc; global using CoreEx.Entities; @@ -9,6 +10,12 @@ global using CoreEx.Json; global using CoreEx.RefData; global using CoreEx.Validation; global using Microsoft.AspNetCore.Mvc; +global using Microsoft.Extensions.Options; global using NSwag.Annotations; +global using OpenTelemetry; +global using OpenTelemetry.Trace; +global using StackExchange.Redis; global using System.Net; global using System.Text.Json; +global using ZiggyCreatures.Caching.Fusion; +global using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; diff --git a/.github/templates/domain/Api/postgres/Domain.Api.csproj.template b/.github/templates/domain/Api/postgres/Domain.Api.csproj.template new file mode 100644 index 00000000..c4b93d3a --- /dev/null +++ b/.github/templates/domain/Api/postgres/Domain.Api.csproj.template @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/.github/templates/domain/Api/postgres/Program.cs.template b/.github/templates/domain/Api/postgres/Program.cs.template new file mode 100644 index 00000000..b6cce6df --- /dev/null +++ b/.github/templates/domain/Api/postgres/Program.cs.template @@ -0,0 +1,71 @@ +namespace {Solution}.{Domain}.Api; + +public class Program +{ + private static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.AddHostSettings(); + + builder.Services + .AddPrecisionTimeProvider() + .AddExecutionContext() + .AddReferenceDataOrchestrator() + .AddMvcWebApi() + .AddHttpWebApi(); + + builder.Services.AddDynamicServicesUsing(); + + builder.Services.AddMemoryCache(); + builder.AddRedisDistributedCache("redis"); + + builder.Services.AddFusionCache() + .WithRegisteredMemoryCache() + .WithRegisteredDistributedCache() + .WithBackplane(sp => new RedisBackplane(new RedisBackplaneOptions { Configuration = sp.GetRequiredService>().Value.ToString() })) + .WithSystemTextJsonSerializer(JsonDefaults.SerializerOptions); + + builder.Services + .AddFusionHybridCache() + .AddDefaultCacheKeyProvider() + .AddHybridCacheIdempotencyProvider(); + + builder.AddAzureNpgsqlDataSource("Postgres"); + builder.Services + .AddPostgresDatabase() + .AddPostgresUnitOfWork() + .AddEventFormatter() + .AddPostgresOutboxPublisher() + .AddDbContext<{Domain}DbContext>() + .AddEfDb<{Domain}EfDb>(); + + builder.Services.PostConfigureAllHealthChecks(); + builder.Services.AddControllers(); + + builder.Services.AddOpenApiDocument(s => + { + s.Title = builder.Environment.ApplicationName; + s.AddCoreExConfiguration(); + }); + + builder.WithCoreExTelemetry() + .WithCoreExPostgresTelemetry() + .UseOtlpExporter(); + + var app = builder.Build(); + + app.UseCoreExExceptionHandler(); + app.UseHttpsRedirection(); + app.UseAuthorization(); + app.UseExecutionContext(); + app.UseIdempotencyKey(); + app.MapControllers(); + + app.UseOpenApi(); + app.UseSwaggerUi(); + app.MapHealthChecks(); + + app.Run(); + } +} diff --git a/.github/templates/domain/Api/Domain.Api.csproj.template b/.github/templates/domain/Api/sqlserver/Domain.Api.csproj.template similarity index 71% rename from .github/templates/domain/Api/Domain.Api.csproj.template rename to .github/templates/domain/Api/sqlserver/Domain.Api.csproj.template index cdeab65e..b5da8100 100644 --- a/.github/templates/domain/Api/Domain.Api.csproj.template +++ b/.github/templates/domain/Api/sqlserver/Domain.Api.csproj.template @@ -1,19 +1,19 @@ - - - - - - - - - + + + + + + + + + diff --git a/.github/templates/domain/Api/Program.cs.template b/.github/templates/domain/Api/sqlserver/Program.cs.template similarity index 86% rename from .github/templates/domain/Api/Program.cs.template rename to .github/templates/domain/Api/sqlserver/Program.cs.template index f5150584..59d3cbcc 100644 --- a/.github/templates/domain/Api/Program.cs.template +++ b/.github/templates/domain/Api/sqlserver/Program.cs.template @@ -1,11 +1,3 @@ -using {Solution}.{Domain}.Infrastructure.Repositories; -using Microsoft.Extensions.Options; -using OpenTelemetry; -using OpenTelemetry.Trace; -using StackExchange.Redis; -using ZiggyCreatures.Caching.Fusion; -using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; - namespace {Solution}.{Domain}.Api; public class Program @@ -17,6 +9,7 @@ public class Program builder.AddHostSettings(); builder.Services + .AddPrecisionTimeProvider() .AddExecutionContext() .AddReferenceDataOrchestrator() .AddMvcWebApi() @@ -43,7 +36,7 @@ public class Program .AddSqlServerDatabase() .AddSqlServerUnitOfWork() .AddEventFormatter() - .AddSqlServerOutboxPublisher<{Domain}OutboxPublisher>() + .AddSqlServerOutboxPublisher() .AddDbContext<{Domain}DbContext>() .AddEfDb<{Domain}EfDb>(); diff --git a/.github/templates/domain/Application/Domain.Application.csproj.template b/.github/templates/domain/Application/Domain.Application.csproj.template index 0241b47e..bfa92f4e 100644 --- a/.github/templates/domain/Application/Domain.Application.csproj.template +++ b/.github/templates/domain/Application/Domain.Application.csproj.template @@ -1,9 +1,14 @@ + + + + + + + + - - - - + diff --git a/.github/templates/domain/Application/EntityReadService.cs.template b/.github/templates/domain/Application/EntityReadService.cs.template index 29977161..d17b15e8 100644 --- a/.github/templates/domain/Application/EntityReadService.cs.template +++ b/.github/templates/domain/Application/EntityReadService.cs.template @@ -9,4 +9,6 @@ public class {Entity}ReadService(I{Entity}Repository repository) : I{Entity}Read public Task> QueryAsync(QueryArgs? query, PagingArgs? paging) => _repository.QueryAsync(query, paging); + + public Task QuerySchemaAsync() => _repository.QuerySchemaAsync(); } diff --git a/.github/templates/domain/Application/EntityService.cs.template b/.github/templates/domain/Application/EntityService.cs.template index 7914da45..7b8a770c 100644 --- a/.github/templates/domain/Application/EntityService.cs.template +++ b/.github/templates/domain/Application/EntityService.cs.template @@ -17,7 +17,7 @@ public class {Entity}Service(IUnitOfWork unitOfWork, I{Entity}Repository reposit entity.Id = Runtime.NewId(); entity.StatusCode ??= "P"; - return await _unitOfWork.ExecuteAsync(async () => + return await _unitOfWork.TransactionAsync(async () => { var dr = await _repository.CreateAsync(entity).ConfigureAwait(false); return dr.WhereMutated(v => _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Created))); @@ -34,7 +34,7 @@ public class {Entity}Service(IUnitOfWork unitOfWork, I{Entity}Repository reposit var current = await _repository.GetAsync(entity.Id).ConfigureAwait(false); NotFoundException.ThrowIfDefault(current); - return await _unitOfWork.ExecuteAsync(async () => + return await _unitOfWork.TransactionAsync(async () => { var dr = await _repository.UpdateAsync(entity).ConfigureAwait(false); return dr.WhereMutated(v => _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Updated))); @@ -47,7 +47,7 @@ public class {Entity}Service(IUnitOfWork unitOfWork, I{Entity}Repository reposit if (entity is null) return; - await _unitOfWork.ExecuteAsync(async () => + await _unitOfWork.TransactionAsync(async () => { var dr = await _repository.DeleteAsync(id).ConfigureAwait(false); dr.WhereMutated(() => _unitOfWork.Events.Add(EventData.CreateEventWith<{Entity}>(default, EventAction.Deleted).WithKey(id))); diff --git a/.github/templates/domain/Application/Interfaces/IEntityReadService.cs.template b/.github/templates/domain/Application/Interfaces/IEntityReadService.cs.template index f828ce70..0c291dba 100644 --- a/.github/templates/domain/Application/Interfaces/IEntityReadService.cs.template +++ b/.github/templates/domain/Application/Interfaces/IEntityReadService.cs.template @@ -5,4 +5,6 @@ public interface I{Entity}ReadService Task GetAsync(string id); Task> QueryAsync(QueryArgs? query, PagingArgs? paging); + + Task QuerySchemaAsync(); } diff --git a/.github/templates/domain/Application/ReferenceDataService.cs.template b/.github/templates/domain/Application/ReferenceDataService.cs.template deleted file mode 100644 index e55a00a6..00000000 --- a/.github/templates/domain/Application/ReferenceDataService.cs.template +++ /dev/null @@ -1,18 +0,0 @@ -namespace {Solution}.{Domain}.Application; - -[ScopedService] -public class ReferenceDataService(IReferenceDataRepository repository) : IReferenceDataProvider -{ - private readonly IReferenceDataRepository _repository = repository.ThrowIfNull(); - - public IEnumerable<(Type, Type)> Types => - [ - (typeof({Entity}Status), typeof({Entity}StatusCollection)), - ]; - - public async Task GetAsync(Type type, CancellationToken cancellationToken = default) => type switch - { - _ when type == typeof({Entity}Status) => await _repository.GetAll{Entity}StatusesAsync().ConfigureAwait(false), - _ => throw new InvalidOperationException($"Type {type.FullName} is not a known {nameof(IReferenceData)}.") - }; -} diff --git a/.github/templates/domain/Application/Repositories/IEntityRepository.cs.template b/.github/templates/domain/Application/Repositories/IEntityRepository.cs.template index 2041a994..86299ab6 100644 --- a/.github/templates/domain/Application/Repositories/IEntityRepository.cs.template +++ b/.github/templates/domain/Application/Repositories/IEntityRepository.cs.template @@ -11,4 +11,6 @@ public interface I{Entity}Repository Task DeleteAsync(string id); Task> QueryAsync(QueryArgs? query, PagingArgs? paging); + + Task QuerySchemaAsync(); } diff --git a/.github/templates/domain/Application/Repositories/IReferenceDataRepository.cs.template b/.github/templates/domain/Application/Repositories/IReferenceDataRepository.cs.template deleted file mode 100644 index 981f8e4a..00000000 --- a/.github/templates/domain/Application/Repositories/IReferenceDataRepository.cs.template +++ /dev/null @@ -1,6 +0,0 @@ -namespace {Solution}.{Domain}.Application.Repositories; - -public interface IReferenceDataRepository -{ - Task<{Entity}StatusCollection> GetAll{Entity}StatusesAsync(); -} diff --git a/.github/templates/domain/Application/rop/EntityReadService.cs.template b/.github/templates/domain/Application/rop/EntityReadService.cs.template new file mode 100644 index 00000000..c6833820 --- /dev/null +++ b/.github/templates/domain/Application/rop/EntityReadService.cs.template @@ -0,0 +1,14 @@ +namespace {Solution}.{Domain}.Application; + +[ScopedService] +public class {Entity}ReadService(I{Entity}Repository repository) : I{Entity}ReadService +{ + private readonly I{Entity}Repository _repository = repository.ThrowIfNull(); + + public Task> GetAsync(string id) => _repository.GetAsync(id); + + public Task>> QueryAsync(QueryArgs? query, PagingArgs? paging) + => _repository.QueryAsync(query, paging); + + public Task QuerySchemaAsync() => _repository.QuerySchemaAsync(); +} diff --git a/.github/templates/domain/Application/rop/EntityService.cs.template b/.github/templates/domain/Application/rop/EntityService.cs.template new file mode 100644 index 00000000..1aab2e95 --- /dev/null +++ b/.github/templates/domain/Application/rop/EntityService.cs.template @@ -0,0 +1,66 @@ +namespace {Solution}.{Domain}.Application; + +[ScopedService] +public class {Entity}Service(IUnitOfWork unitOfWork, I{Entity}Repository repository) : I{Entity}Service +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork.ThrowIfNull(); + private readonly I{Entity}Repository _repository = repository.ThrowIfNull(); + + public Task> GetAsync(string id) => + _repository.GetAsync(id); + + public Task> CreateAsync(Contracts.{Entity} entity) + { + entity.ThrowIfNull(); + entity.Id = Runtime.NewId(); + entity.StatusCode ??= "P"; + + return {Entity}Validator.Default.ValidateWithResultAsync(entity) + .ThenAsAsync(() => _unitOfWork.TransactionAsync(async () => + { + var dr = await _repository.CreateAsync(entity).ConfigureAwait(false); + return dr.ThenAs(v => + { + _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Created)); + return v; + }); + })); + } + + public Task> UpdateAsync(Contracts.{Entity} entity) + { + entity.ThrowIfNull(); + entity.Id.ThrowIfNullOrEmpty(); + + return {Entity}Validator.Default.ValidateWithResultAsync(entity) + .ThenAsAsync(() => _repository.GetAsync(entity.Id)) + .Then(current => current is null + ? Result.Fail(new NotFoundException()) + : Result.Ok(entity)) + .ThenAsAsync(_ => _unitOfWork.TransactionAsync(async () => + { + var dr = await _repository.UpdateAsync(entity).ConfigureAwait(false); + return dr.ThenAs(v => + { + _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Updated)); + return v; + }); + })); + } + + public Task DeleteAsync(string id) => + _repository.GetAsync(id) + .ThenAsAsync(current => + { + if (current is null) + return Task.FromResult(Result.Success); + + return _unitOfWork.TransactionAsync(async () => + { + var dr = await _repository.DeleteAsync(id).ConfigureAwait(false); + return dr.ThenAs(() => + _unitOfWork.Events.Add( + EventData.CreateEventWith(default, EventAction.Deleted).WithKey(id))); + }); + }); +} diff --git a/.github/templates/domain/Application/rop/Interfaces/IEntityReadService.cs.template b/.github/templates/domain/Application/rop/Interfaces/IEntityReadService.cs.template new file mode 100644 index 00000000..6f0e044c --- /dev/null +++ b/.github/templates/domain/Application/rop/Interfaces/IEntityReadService.cs.template @@ -0,0 +1,10 @@ +namespace {Solution}.{Domain}.Application.Interfaces; + +public interface I{Entity}ReadService +{ + Task> GetAsync(string id); + + Task>> QueryAsync(QueryArgs? query, PagingArgs? paging); + + Task QuerySchemaAsync(); +} diff --git a/.github/templates/domain/Application/rop/Interfaces/IEntityService.cs.template b/.github/templates/domain/Application/rop/Interfaces/IEntityService.cs.template new file mode 100644 index 00000000..4ce4cc18 --- /dev/null +++ b/.github/templates/domain/Application/rop/Interfaces/IEntityService.cs.template @@ -0,0 +1,12 @@ +namespace {Solution}.{Domain}.Application.Interfaces; + +public interface I{Entity}Service +{ + Task> GetAsync(string id); + + Task> CreateAsync(Contracts.{Entity} entity); + + Task> UpdateAsync(Contracts.{Entity} entity); + + Task DeleteAsync(string id); +} diff --git a/.github/templates/domain/Application/rop/Repositories/IEntityRepository.cs.template b/.github/templates/domain/Application/rop/Repositories/IEntityRepository.cs.template new file mode 100644 index 00000000..9a429b31 --- /dev/null +++ b/.github/templates/domain/Application/rop/Repositories/IEntityRepository.cs.template @@ -0,0 +1,16 @@ +namespace {Solution}.{Domain}.Application.Repositories; + +public interface I{Entity}Repository +{ + Task> GetAsync(string id); + + Task> CreateAsync(Contracts.{Entity} entity); + + Task> UpdateAsync(Contracts.{Entity} entity); + + Task DeleteAsync(string id); + + Task>> QueryAsync(QueryArgs? query, PagingArgs? paging); + + Task QuerySchemaAsync(); +} diff --git a/.github/templates/domain/CodeGen/Domain.CodeGen.csproj.template b/.github/templates/domain/CodeGen/Domain.CodeGen.csproj.template new file mode 100644 index 00000000..9b4f424f --- /dev/null +++ b/.github/templates/domain/CodeGen/Domain.CodeGen.csproj.template @@ -0,0 +1,15 @@ + + + + Exe + net9.0 + enable + enable + {Solution}.{Domain}.CodeGen + + + + + + + diff --git a/.github/templates/domain/CodeGen/Program.cs.template b/.github/templates/domain/CodeGen/Program.cs.template new file mode 100644 index 00000000..50a35003 --- /dev/null +++ b/.github/templates/domain/CodeGen/Program.cs.template @@ -0,0 +1 @@ +await CoreEx.CodeGen.CodeGenConsole.Create().RunAsync(args); diff --git a/.github/templates/domain/CodeGen/ref-data.yaml.template b/.github/templates/domain/CodeGen/ref-data.yaml.template new file mode 100644 index 00000000..c0219a38 --- /dev/null +++ b/.github/templates/domain/CodeGen/ref-data.yaml.template @@ -0,0 +1,18 @@ +# CoreEx CodeGen — Reference Data configuration. +# Each entry generates contracts, persistence models, mappers, controller endpoints, service entries, and repository interface entries. +# Run: dotnet run -- refdata + +collectionSortOrder: Code +repository: EntityFramework + +entities: + # -- Add one entry per reference-data type used in this domain -- + - name: {Entity}Status + # Example with custom plural: + # - name: UnitOfMeasure + # plural: UnitsOfMeasure + # Example with extra property: + # - name: Priority + # properties: + # - name: SortKey + # type: int diff --git a/.github/templates/domain/Contracts/EntityStatus.cs.template b/.github/templates/domain/Contracts/EntityStatus.cs.template deleted file mode 100644 index c93f189c..00000000 --- a/.github/templates/domain/Contracts/EntityStatus.cs.template +++ /dev/null @@ -1,6 +0,0 @@ -namespace {Solution}.{Domain}.Contracts; - -[ReferenceData] -public partial class {Entity}Status : ReferenceData<{Entity}Status> { } - -public class {Entity}StatusCollection() : ReferenceDataCollection<{Entity}Status>(ReferenceDataSortOrder.Code) { } diff --git a/.github/templates/domain/Database/Migrations/000101-create-entitystatus.sql.template b/.github/templates/domain/Database/Migrations/000101-create-entitystatus.sql.template deleted file mode 100644 index c989a971..00000000 --- a/.github/templates/domain/Database/Migrations/000101-create-entitystatus.sql.template +++ /dev/null @@ -1,18 +0,0 @@ --- Migration Script - -BEGIN TRANSACTION - -CREATE TABLE [{Domain}].[{Entity}Status] ( - [{Entity}StatusId] 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, - [CreatedBy] NVARCHAR(250) NULL, - [CreatedOn] DATETIMEOFFSET NULL, - [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedOn] DATETIMEOFFSET NULL -); - -COMMIT TRANSACTION diff --git a/.github/templates/domain/Database/Migrations/000201-create-entity.sql.template b/.github/templates/domain/Database/Migrations/000201-create-entity.sql.template deleted file mode 100644 index 8d5d4c81..00000000 --- a/.github/templates/domain/Database/Migrations/000201-create-entity.sql.template +++ /dev/null @@ -1,16 +0,0 @@ --- Migration Script - -BEGIN TRANSACTION - -CREATE TABLE [{Domain}].[{Entity}] ( - [{Entity}Id] NVARCHAR(50) NOT NULL PRIMARY KEY, - [CustomerId] NVARCHAR(100) NOT NULL, - [StatusCode] NVARCHAR(50) NOT NULL, - [CreatedBy] NVARCHAR(250) NULL, - [CreatedOn] DATETIMEOFFSET NULL, - [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedOn] DATETIMEOFFSET NULL, - [RowVersion] TIMESTAMP NOT NULL -); - -COMMIT TRANSACTION diff --git a/.github/templates/domain/Database/Migrations/000202-create-childentity.sql.template b/.github/templates/domain/Database/Migrations/000202-create-childentity.sql.template deleted file mode 100644 index e887cf3b..00000000 --- a/.github/templates/domain/Database/Migrations/000202-create-childentity.sql.template +++ /dev/null @@ -1,17 +0,0 @@ --- Migration Script - -BEGIN TRANSACTION - -CREATE TABLE [{Domain}].[{ChildEntity}] ( - [{ChildEntity}Id] NVARCHAR(50) NOT NULL PRIMARY KEY, - [{Entity}Id] NVARCHAR(50) NOT NULL FOREIGN KEY REFERENCES [{Domain}].[{Entity}]([{Entity}Id]), - [ProductId] NVARCHAR(100) NOT NULL, - [Quantity] DECIMAL(18, 4) NOT NULL DEFAULT 0, - [UnitPrice] DECIMAL(18, 4) NOT NULL DEFAULT 0, - [CreatedBy] NVARCHAR(250) NULL, - [CreatedOn] DATETIMEOFFSET NULL, - [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedOn] DATETIMEOFFSET NULL -); - -COMMIT TRANSACTION diff --git a/.github/templates/domain/Database/Migrations/000301-create-outbox-tables.sql.template b/.github/templates/domain/Database/Migrations/000301-create-outbox-tables.sql.template deleted file mode 100644 index f120a547..00000000 --- a/.github/templates/domain/Database/Migrations/000301-create-outbox-tables.sql.template +++ /dev/null @@ -1,29 +0,0 @@ -BEGIN TRANSACTION - -CREATE TABLE [{Domain}].[Outbox] ( - [OutboxId] BIGINT IDENTITY (1, 1) NOT NULL PRIMARY KEY, - [TenantId] NVARCHAR(255) NOT NULL, - [PartitionId] INT NOT NULL, - [Status] TINYINT NOT NULL DEFAULT 0, - [EnqueuedUtc] DATETIME2 NOT NULL, - [AvailableUtc] DATETIME2 NOT NULL, - [DequeuedUtc] DATETIME2 NULL, - [Attempts] INT NOT NULL DEFAULT 0, - [Destination] NVARCHAR(255) NULL, - [Event] NVARCHAR(MAX) NOT NULL, - [LeaseId] UNIQUEIDENTIFIER NULL, - [LeaseUntilUtc] DATETIME2 NULL -); - -CREATE INDEX [IX_{Domain}_Outbox_Claim] ON [{Domain}].[Outbox] ([TenantId], [PartitionId], [Status], [OutboxId], [AvailableUtc], [LeaseUntilUtc]); - -CREATE TABLE [{Domain}].[OutboxLease] ( - [TenantId] NVARCHAR(255) NOT NULL, - [PartitionId] INT NOT NULL, - [LeaseId] UNIQUEIDENTIFIER NULL, - [LeaseUntilUtc] DATETIME2 NULL, - - CONSTRAINT PK_{Domain}_OutboxLease PRIMARY KEY (TenantId, PartitionId) -); - -COMMIT TRANSACTION diff --git a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql.template b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql.template deleted file mode 100644 index 6c41280b..00000000 --- a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql.template +++ /dev/null @@ -1,46 +0,0 @@ -CREATE OR ALTER PROCEDURE [{Domain}].[spOutboxBatchCancel] - @LeaseId UNIQUEIDENTIFIER, - @BackoffSeconds INT -AS -BEGIN - SET NOCOUNT ON; - SET XACT_ABORT ON; - SET LOCK_TIMEOUT 5000; - SET TRANSACTION ISOLATION LEVEL READ COMMITTED; - - DECLARE @Now DATETIME2 = SYSUTCDATETIME(); - - BEGIN TRY - BEGIN TRAN; - - UPDATE o - SET o.[Status] = 0, - o.[Attempts] = o.[Attempts] + 1, - o.[AvailableUtc] = DATEADD(SECOND, @BackoffSeconds, @Now), - o.[LeaseId] = NULL, - o.[LeaseUntilUtc] = NULL - FROM [{Domain}].[Outbox] AS o WITH (UPDLOCK, ROWLOCK) - WHERE o.[LeaseId] = @LeaseId - AND o.[Status] = 1; - - IF (@@ROWCOUNT = 0) - BEGIN - COMMIT; - RETURN -1; - END - - COMMIT; - - BEGIN TRY - EXEC [{Domain}].[spOutboxLeaseRelease] @LeaseId; - END TRY - BEGIN CATCH - END CATCH - - RETURN 0; - END TRY - BEGIN CATCH - IF (XACT_STATE() <> 0) ROLLBACK; - THROW; - END CATCH -END diff --git a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql.template b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql.template deleted file mode 100644 index b02f953b..00000000 --- a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql.template +++ /dev/null @@ -1,96 +0,0 @@ -CREATE OR ALTER PROCEDURE [{Domain}].[spOutboxBatchClaim] - @TenantId NVARCHAR(255) = NULL, - @PartitionId INT, - @BatchSize INT, - @LeaseId UNIQUEIDENTIFIER, - @LeaseSeconds INT -AS -BEGIN - SET NOCOUNT ON; - SET XACT_ABORT ON; - - DECLARE @Now DATETIME2 = SYSUTCDATETIME(); - DECLARE @LeaseUntilUtc DATETIME2; - DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); - - SET TRANSACTION ISOLATION LEVEL READ COMMITTED; - SET LOCK_TIMEOUT 5000; - - DECLARE @RC INT; - EXEC @RC = [{Domain}].[spOutboxLeaseAcquire] @EffectiveTenantId, @PartitionId, @LeaseId, @LeaseSeconds, @LeaseUntilUtc OUTPUT; - IF (@RC < 0) RETURN -3; - - BEGIN TRY - BEGIN TRAN; - - DECLARE @HeadId BIGINT; - DECLARE @BlockerId BIGINT; - - SELECT @HeadId = MIN(o.OutboxId) - FROM [{Domain}].[Outbox] o WITH (UPDLOCK) - WHERE o.[TenantId] = @EffectiveTenantId - AND o.[PartitionId] = @PartitionId - AND o.[Status] IN (0, 1) - OPTION (RECOMPILE); - - IF @HeadId IS NULL - BEGIN - COMMIT; - EXEC [{Domain}].[spOutboxLeaseRelease] @LeaseId; - RETURN -2; - END - - SELECT @BlockerId = MIN(o.OutboxId) - FROM [{Domain}].[Outbox] o WITH (READPAST, UPDLOCK) - WHERE o.[TenantId] = @EffectiveTenantId - AND o.[PartitionId] = @PartitionId - AND o.[OutboxId] >= @HeadId - AND ((o.Status = 1 AND o.[LeaseUntilUtc] IS NOT NULL AND o.[LeaseUntilUtc] > @Now) - OR (o.Status = 0 AND o.[AvailableUtc] > @Now)) - OPTION (RECOMPILE); - - ;WITH claim AS - ( - SELECT TOP (@BatchSize) - o.[OutboxId], o.[TenantId], o.[Status], o.[PartitionId], o.[Destination], o.[Event], - o.[Attempts], o.[EnqueuedUtc], o.[AvailableUtc], o.[LeaseId], o.[LeaseUntilUtc] - FROM [{Domain}].[Outbox] o WITH (READPAST, UPDLOCK, ROWLOCK) - WHERE o.[TenantId] = @EffectiveTenantId - AND o.[PartitionId] = @PartitionId - AND o.[OutboxId] >= @HeadId - AND (@BlockerId IS NULL OR o.[OutboxId] < @BlockerId) - AND ((o.[Status] = 0 AND o.[AvailableUtc] <= @Now) - OR (o.[Status] = 1 AND (o.[LeaseUntilUtc] IS NULL OR o.[LeaseUntilUtc] <= @Now))) - ORDER BY o.OutboxId - ) - UPDATE claim - SET [Status] = 1, - [LeaseId] = @LeaseId, - [LeaseUntilUtc] = @LeaseUntilUtc - OUTPUT - inserted.[OutboxId], - inserted.[TenantId], - inserted.[Status], - inserted.[PartitionId], - inserted.[Destination], - inserted.[Event], - inserted.[Attempts], - inserted.[EnqueuedUtc], - inserted.[AvailableUtc], - inserted.[LeaseUntilUtc]; - - IF (@@ROWCOUNT = 0) - BEGIN - COMMIT; - EXEC [{Domain}].[spOutboxLeaseRelease] @LeaseId; - RETURN -1; - END - - COMMIT; - RETURN 0; - END TRY - BEGIN CATCH - IF (XACT_STATE() <> 0) ROLLBACK; - THROW; - END CATCH -END diff --git a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql.template b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql.template deleted file mode 100644 index aca350ab..00000000 --- a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql.template +++ /dev/null @@ -1,50 +0,0 @@ -CREATE OR ALTER PROCEDURE [{Domain}].[spOutboxBatchComplete] - @LeaseId UNIQUEIDENTIFIER, - @DequeuedUtc DATETIME2 NULL -AS -BEGIN - SET NOCOUNT ON; - SET XACT_ABORT ON; - SET LOCK_TIMEOUT 5000; - SET TRANSACTION ISOLATION LEVEL READ COMMITTED; - - DECLARE @Now DATETIME2 = SYSUTCDATETIME(); - DECLARE @Completed TABLE (TenantId NVARCHAR(255), PartitionId INT); - - BEGIN TRY - BEGIN TRAN; - - UPDATE o - SET o.[Status] = 2, - o.[LeaseId] = NULL, - o.[LeaseUntilUtc] = NULL, - o.[DequeuedUtc] = COALESCE(@DequeuedUtc, @Now) - OUTPUT - deleted.[TenantId], - deleted.[PartitionId] - INTO @Completed - FROM [{Domain}].[Outbox] AS o WITH (UPDLOCK, ROWLOCK) - WHERE o.[LeaseId] = @LeaseId - AND o.[Status] = 1; - - IF (@@ROWCOUNT = 0) - BEGIN - COMMIT; - RETURN -1; - END - - COMMIT; - - BEGIN TRY - EXEC [{Domain}].[spOutboxLeaseRelease] @LeaseId; - END TRY - BEGIN CATCH - END CATCH - - RETURN 0; - END TRY - BEGIN CATCH - IF (XACT_STATE() <> 0) ROLLBACK; - THROW; - END CATCH -END diff --git a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql.template b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql.template deleted file mode 100644 index 881dd973..00000000 --- a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql.template +++ /dev/null @@ -1,18 +0,0 @@ -CREATE OR ALTER PROCEDURE [{Domain}].[spOutboxEnqueue] - @TenantId AS NVARCHAR(255) = NULL, - @PartitionId AS INT, - @Destination AS NVARCHAR(255), - @Event AS NVARCHAR(MAX), - @EnqueuedUtc AS DATETIME2 = NULL, - @AvailableUtc AS DATETIME2 = NULL -AS -BEGIN - SET NOCOUNT ON; - SET XACT_ABORT ON; - - DECLARE @Now DATETIME2 = SYSUTCDATETIME(); - DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); - - INSERT INTO [{Domain}].[Outbox] ([TenantId], [PartitionId], [Destination], [Event], [EnqueuedUtc], [AvailableUtc]) - VALUES (@EffectiveTenantId, @PartitionId, @Destination, @Event, COALESCE(@EnqueuedUtc, @Now), COALESCE(@AvailableUtc, COALESCE(@EnqueuedUtc, @Now))); -END diff --git a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql.template b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql.template deleted file mode 100644 index d29d14ba..00000000 --- a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql.template +++ /dev/null @@ -1,49 +0,0 @@ -CREATE OR ALTER PROCEDURE [{Domain}].[spOutboxLeaseAcquire] - @TenantId NVARCHAR(255) = NULL, - @PartitionId INT, - @LeaseId UNIQUEIDENTIFIER, - @LeaseSeconds INT, - @LeaseUntilUtc DATETIME2 OUTPUT -AS -BEGIN - SET NOCOUNT ON; - SET XACT_ABORT ON; - SET LOCK_TIMEOUT 5000; - SET TRANSACTION ISOLATION LEVEL READ COMMITTED; - - DECLARE @Now DATETIME2 = SYSUTCDATETIME(); - DECLARE @Until DATETIME2 = DATEADD(SECOND, @LeaseSeconds, @Now); - DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); - - BEGIN TRY - BEGIN TRAN; - - IF NOT EXISTS (SELECT 1 FROM [{Domain}].[OutboxLease] WITH (UPDLOCK, HOLDLOCK) WHERE [TenantId] = @EffectiveTenantId AND [PartitionId] = @PartitionId) - INSERT INTO [{Domain}].[OutboxLease] ([TenantId], [PartitionId]) VALUES (@EffectiveTenantId, @PartitionId); - - UPDATE ol - SET ol.[LeaseId] = @LeaseId, - ol.[LeaseUntilUtc] = @Until - FROM [{Domain}].[OutboxLease] AS ol WITH (UPDLOCK, ROWLOCK) - WHERE ol.[PartitionId] = @PartitionId - AND ol.[TenantId] = @EffectiveTenantId - AND (ol.[LeaseUntilUtc] IS NULL OR ol.[LeaseUntilUtc] <= @Now) - OPTION (RECOMPILE); - - DECLARE @Rows INT = @@ROWCOUNT; - COMMIT; - - IF @Rows = 1 - BEGIN - SET @LeaseUntilUtc = @Until; - RETURN 0; - END - - SET @LeaseUntilUtc = NULL; - RETURN -1; - END TRY - BEGIN CATCH - IF (XACT_STATE() <> 0) ROLLBACK; - THROW; - END CATCH -END diff --git a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql.template b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql.template deleted file mode 100644 index 10fa2995..00000000 --- a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql.template +++ /dev/null @@ -1,29 +0,0 @@ -CREATE OR ALTER PROCEDURE [{Domain}].[spOutboxLeaseRelease] - @LeaseId UNIQUEIDENTIFIER -AS -BEGIN - SET NOCOUNT ON; - SET XACT_ABORT ON; - SET LOCK_TIMEOUT 5000; - SET TRANSACTION ISOLATION LEVEL READ COMMITTED; - - BEGIN TRY - BEGIN TRAN; - - UPDATE ol - SET ol.[LeaseId] = NULL, - ol.[LeaseUntilUtc] = NULL - FROM [{Domain}].[OutboxLease] AS ol WITH (UPDLOCK, ROWLOCK) - WHERE ol.[LeaseId] = @LeaseId; - - DECLARE @Rows INT = @@ROWCOUNT; - COMMIT; - - IF @Rows = 1 RETURN 0; - RETURN -1; - END TRY - BEGIN CATCH - IF (XACT_STATE() <> 0) ROLLBACK; - THROW; - END CATCH -END diff --git a/.github/templates/domain/Database/Data/ref-data.yaml.template b/.github/templates/domain/Database/_shared/Data/ref-data.yaml.template similarity index 100% rename from .github/templates/domain/Database/Data/ref-data.yaml.template rename to .github/templates/domain/Database/_shared/Data/ref-data.yaml.template diff --git a/.github/templates/domain/Database/dbex.yaml.template b/.github/templates/domain/Database/dbex.yaml.template deleted file mode 100644 index 7268ba82..00000000 --- a/.github/templates/domain/Database/dbex.yaml.template +++ /dev/null @@ -1,8 +0,0 @@ -outbox: true -tables: -# Reference-data -- name: {Entity}Status - -# Transactional-data -- name: {Entity} -- name: {ChildEntity} diff --git a/.github/templates/domain/Database/postgres/Domain.Database.csproj.template b/.github/templates/domain/Database/postgres/Domain.Database.csproj.template new file mode 100644 index 00000000..71fa2933 --- /dev/null +++ b/.github/templates/domain/Database/postgres/Domain.Database.csproj.template @@ -0,0 +1,17 @@ + + + + Exe + + + + + + + + + + + + + diff --git a/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000001-create-{domainKebab}-schema.pgsql.template b/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000001-create-{domainKebab}-schema.pgsql.template new file mode 100644 index 00000000..06769efa --- /dev/null +++ b/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000001-create-{domainKebab}-schema.pgsql.template @@ -0,0 +1 @@ +CREATE SCHEMA IF NOT EXISTS "{domainKebab}"; diff --git a/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000002-create-{domainKebab}-entitystatus.pgsql.template b/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000002-create-{domainKebab}-entitystatus.pgsql.template new file mode 100644 index 00000000..1e686ca6 --- /dev/null +++ b/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000002-create-{domainKebab}-entitystatus.pgsql.template @@ -0,0 +1,18 @@ +-- Migration Script — Reference Data: {entity_status_kebab} lookup table. +-- Conditionally included when Reference Data = Yes. + +BEGIN TRANSACTION; + +CREATE TABLE "{domainKebab}"."{entity_status_kebab}" ( + "{entity_status_kebab}_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 +); + +COMMIT TRANSACTION; diff --git a/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000003-create-{domainKebab}-entity.pgsql.template b/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000003-create-{domainKebab}-entity.pgsql.template new file mode 100644 index 00000000..d8b6d74a --- /dev/null +++ b/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000003-create-{domainKebab}-entity.pgsql.template @@ -0,0 +1,15 @@ +-- Migration Script — Primary entity table. + +BEGIN TRANSACTION; + +CREATE TABLE "{domainKebab}"."{entity_kebab}" ( + "{entity_kebab}_id" VARCHAR(50) NOT NULL PRIMARY KEY, + "status_code" VARCHAR(50) NOT NULL, + "created_by" VARCHAR(250) NULL, + "created_on" TIMESTAMPTZ NULL, + "updated_by" VARCHAR(250) NULL, + "updated_on" TIMESTAMPTZ NULL, + "is_deleted" BOOLEAN NOT NULL DEFAULT FALSE +); + +COMMIT TRANSACTION; diff --git a/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000004-create-{domainKebab}-childentity.pgsql.template b/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000004-create-{domainKebab}-childentity.pgsql.template new file mode 100644 index 00000000..4fcbebee --- /dev/null +++ b/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000004-create-{domainKebab}-childentity.pgsql.template @@ -0,0 +1,16 @@ +-- Migration Script — Child entity table. +-- Conditionally included when Child Entity = Yes. + +BEGIN TRANSACTION; + +CREATE TABLE "{domainKebab}"."{child_entity_kebab}" ( + "{child_entity_kebab}_id" VARCHAR(50) NOT NULL PRIMARY KEY, + "{entity_kebab}_id" VARCHAR(50) NOT NULL REFERENCES "{domainKebab}"."{entity_kebab}" ("{entity_kebab}_id") ON DELETE CASCADE, + "quantity" NUMERIC(18, 4) NOT NULL DEFAULT 0, + "created_by" VARCHAR(250) NULL, + "created_on" TIMESTAMPTZ NULL, + "updated_by" VARCHAR(250) NULL, + "updated_on" TIMESTAMPTZ NULL +); + +COMMIT TRANSACTION; diff --git a/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000005-create-{domainKebab}-outbox.pgsql.template b/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000005-create-{domainKebab}-outbox.pgsql.template new file mode 100644 index 00000000..5de41545 --- /dev/null +++ b/.github/templates/domain/Database/postgres/Migrations/{MigrationTimestamp}-000005-create-{domainKebab}-outbox.pgsql.template @@ -0,0 +1,37 @@ +-- Migration Script — Transactional outbox tables. + +BEGIN; + +CREATE TABLE "{domainKebab}"."outbox" ( + "outbox_id" BIGSERIAL NOT NULL PRIMARY KEY, + "tenant_id" VARCHAR(255) NOT NULL, -- '(none)' indicates no tenancy. + "partition_id" INTEGER NOT NULL, -- Partition number; computed in application from partition-key. + "status" SMALLINT NOT NULL DEFAULT 0, -- 0=Pending, 1=Processing, 2=Done. + "enqueued_utc" TIMESTAMPTZ NOT NULL, -- When the event was enqueued within application. + "available_utc" TIMESTAMPTZ NOT NULL, -- When the event is eligible for processing (retry delay). + "dequeued_utc" TIMESTAMPTZ NULL, -- When the event was successfully dequeued/relayed. + "attempts" INTEGER NOT NULL DEFAULT 0, -- Retry attempt count. + + -- Message: + "destination" VARCHAR(255) NULL, -- Message destination; i.e. queue/topic/etc. + "event" TEXT NOT NULL, -- CloudEvent as JSON. + + -- Claim/leasing: + "lease_id" UUID NULL, -- Unique identifier of the lease. + "lease_until_utc" TIMESTAMPTZ NULL -- Leased until UTC; after which assume released due to possible application crash. +); + +CREATE INDEX "ix_{domainKebab}_outbox_partition_order" ON "{domainKebab}"."outbox" ("tenant_id", "partition_id", "outbox_id", "status", "available_utc", "lease_until_utc", "destination", "attempts"); +CREATE INDEX "ix_{domainKebab}_outbox_worker_pull" ON "{domainKebab}"."outbox" ("tenant_id", "partition_id", "status", "outbox_id", "available_utc"); +CREATE INDEX "ix_{domainKebab}_outbox_clean_up" ON "{domainKebab}"."outbox" ("outbox_id", "dequeued_utc") WHERE "status" = 2; + +CREATE TABLE "{domainKebab}"."outbox_lease" ( + "tenant_id" VARCHAR(255) NOT NULL, -- '(none)' indicates no tenancy. + "partition_id" INTEGER NOT NULL, -- Partition number; computed in application from partition-key. + "lease_id" UUID NULL, -- Unique identifier of the lessee. + "lease_until_utc" TIMESTAMPTZ NULL, -- Leased until UTC; after which assume released due to possible application crash. + + CONSTRAINT "pk_{domainKebab}_outbox_lease" PRIMARY KEY ("tenant_id", "partition_id") +); + +COMMIT; diff --git a/.github/templates/domain/Database/postgres/Program.cs.template b/.github/templates/domain/Database/postgres/Program.cs.template new file mode 100644 index 00000000..7bd86f6c --- /dev/null +++ b/.github/templates/domain/Database/postgres/Program.cs.template @@ -0,0 +1,27 @@ +using CoreEx.Database; +using DbEx.Migration; +using DbEx.Postgres.Console; + +namespace {Solution}.{Domain}.Database; + +public class Program +{ + public static Task Main(string[] args) => PostgresMigrationConsole + .Create("Server=127.0.0.1;Database={domainKebab};Username=postgres;Password=yourStrong#!Password") + .Configure(c => ConfigureMigrationArgs(c.Args)) + .RunAsync(args); + + public static MigrationArgs ConfigureMigrationArgs(MigrationArgs args) + { + args.AddAssembly().AddAssembly() + .IncludeExtendedSchemaScripts() + .DataParserArgs + .RefDataColumnDefault("SortOrder", _ => 0) + .RefDataColumnDefault("Scale", _ => 0); + + // Only reset data for the {domainKebab} schema. + args.DataResetFilterPredicate = ts => ts.Schema == "{domainKebab}"; + + return args; + } +} diff --git a/.github/templates/domain/Database/postgres/dbex.yaml.template b/.github/templates/domain/Database/postgres/dbex.yaml.template new file mode 100644 index 00000000..1253daef --- /dev/null +++ b/.github/templates/domain/Database/postgres/dbex.yaml.template @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Avanade/DbEx/refs/heads/main/schema/dbex.json +schema: {domainKebab} +outbox: true +outboxName: outbox +tables: +# Reference-data (conditionally included when Reference Data = Yes) +- name: {entity_status_kebab} + +# Transactional-data +- name: {entity_kebab} +# Conditionally include when Child Entity = Yes: +# - name: {child_entity_kebab} diff --git a/.github/templates/domain/Database/Domain.Database.csproj.template b/.github/templates/domain/Database/sqlserver/Domain.Database.csproj.template similarity index 66% rename from .github/templates/domain/Database/Domain.Database.csproj.template rename to .github/templates/domain/Database/sqlserver/Domain.Database.csproj.template index 82f1cdf2..f96fa65b 100644 --- a/.github/templates/domain/Database/Domain.Database.csproj.template +++ b/.github/templates/domain/Database/sqlserver/Domain.Database.csproj.template @@ -5,17 +5,13 @@ + - - - - - diff --git a/.github/templates/domain/Database/Migrations/000001-create-schema.sql.template b/.github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000001-create-{domainKebab}-schema.sql.template similarity index 100% rename from .github/templates/domain/Database/Migrations/000001-create-schema.sql.template rename to .github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000001-create-{domainKebab}-schema.sql.template diff --git a/.github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000002-create-{domainKebab}-entitystatus.sql.template b/.github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000002-create-{domainKebab}-entitystatus.sql.template new file mode 100644 index 00000000..9eaf3f19 --- /dev/null +++ b/.github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000002-create-{domainKebab}-entitystatus.sql.template @@ -0,0 +1,19 @@ +-- Migration Script — Reference Data: {Entity}Status lookup table. +-- Conditionally included when Reference Data = Yes. + +BEGIN TRANSACTION + +CREATE TABLE [{Domain}].[{Entity}Status] ( + [{Entity}StatusId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [Code] NVARCHAR(50) NOT NULL UNIQUE, + [Text] NVARCHAR(250) NULL, + [IsActive] BIT NULL, + [SortOrder] INT NULL, + [RowVersion] ROWVERSION NOT NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL +); + +COMMIT TRANSACTION diff --git a/.github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000003-create-{domainKebab}-entity.sql.template b/.github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000003-create-{domainKebab}-entity.sql.template new file mode 100644 index 00000000..064866cc --- /dev/null +++ b/.github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000003-create-{domainKebab}-entity.sql.template @@ -0,0 +1,16 @@ +-- Migration Script — Primary entity table. + +BEGIN TRANSACTION + +CREATE TABLE [{Domain}].[{Entity}] ( + [{Entity}Id] NVARCHAR(50) NOT NULL PRIMARY KEY, + [StatusCode] NVARCHAR(50) NOT NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL, + [RowVersion] ROWVERSION NOT NULL, + [IsDeleted] BIT NOT NULL DEFAULT 0 +); + +COMMIT TRANSACTION diff --git a/.github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000004-create-{domainKebab}-childentity.sql.template b/.github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000004-create-{domainKebab}-childentity.sql.template new file mode 100644 index 00000000..74c71851 --- /dev/null +++ b/.github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000004-create-{domainKebab}-childentity.sql.template @@ -0,0 +1,16 @@ +-- Migration Script — Child entity table. +-- Conditionally included when Child Entity = Yes. + +BEGIN TRANSACTION + +CREATE TABLE [{Domain}].[{ChildEntity}] ( + [{ChildEntity}Id] NVARCHAR(50) NOT NULL PRIMARY KEY, + [{Entity}Id] NVARCHAR(50) NOT NULL FOREIGN KEY REFERENCES [{Domain}].[{Entity}]([{Entity}Id]) ON DELETE CASCADE, + [Quantity] DECIMAL(18, 4) NOT NULL DEFAULT 0, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL +); + +COMMIT TRANSACTION diff --git a/.github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000005-create-{domainKebab}-outbox.sql.template b/.github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000005-create-{domainKebab}-outbox.sql.template new file mode 100644 index 00000000..551d8f34 --- /dev/null +++ b/.github/templates/domain/Database/sqlserver/Migrations/{MigrationTimestamp}-000005-create-{domainKebab}-outbox.sql.template @@ -0,0 +1,40 @@ +-- Migration Script — Transactional outbox tables. + +BEGIN TRANSACTION + +CREATE TABLE [{Domain}].[Outbox] ( + [OutboxId] BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY, + [TenantId] NVARCHAR(255) NOT NULL, -- '(none)' indicates no tenancy. + [PartitionId] INT NOT NULL, -- Partition number; computed in application from partition-key. + [Status] TINYINT NOT NULL DEFAULT 0, -- 0=Pending, 1=Processing, 2=Done. + [EnqueuedUtc] DATETIME2 NOT NULL, -- When the event was enqueued within application. + [AvailableUtc] DATETIME2 NOT NULL, -- When the event is eligible for processing (retry delay). + [DequeuedUtc] DATETIME2 NULL, -- When the event was successfully dequeued/relayed. + [Attempts] INT NOT NULL DEFAULT 0, -- Retry attempt count. + + -- Message: + [Destination] NVARCHAR(255) NULL, -- Message destination; i.e. queue/topic/etc. + [Event] NVARCHAR(MAX) NOT NULL, -- CloudEvent as JSON. + + -- Claim/leasing: + [LeaseId] UNIQUEIDENTIFIER NULL, -- Unique identifier of the lease. + [LeaseUntilUtc] DATETIME2 NULL, -- Leased until UTC; after which assume released due to possible application crash. + + INDEX [IX_{Domain}_Outbox_PartitionOrder] ([TenantId], [PartitionId], [OutboxId]) + INCLUDE ([Status], [AvailableUtc], [LeaseUntilUtc], [Destination], [Event], [Attempts]), + INDEX [IX_{Domain}_Outbox_WorkerPull] ([TenantId], [PartitionId], [Status]) + INCLUDE ([OutboxId], [AvailableUtc]), + INDEX [IX_{Domain}_Outbox_CleanUp] ([OutboxId]) + INCLUDE ([DequeuedUtc]) WHERE [Status] = 2 +); + +CREATE TABLE [{Domain}].[OutboxLease] ( + [TenantId] NVARCHAR(255) NOT NULL, -- '(none)' indicates no tenancy. + [PartitionId] INT NOT NULL, -- Partition number; computed in application from partition-key. + [LeaseId] UNIQUEIDENTIFIER NULL, -- Unique identifier of the lessee. + [LeaseUntilUtc] DATETIME2 NULL -- Leased until UTC; after which assume released due to possible application crash. + + CONSTRAINT PK_{Domain}_OutboxLease PRIMARY KEY ([TenantId], [PartitionId]) +); + +COMMIT TRANSACTION diff --git a/.github/templates/domain/Database/Program.cs.template b/.github/templates/domain/Database/sqlserver/Program.cs.template similarity index 85% rename from .github/templates/domain/Database/Program.cs.template rename to .github/templates/domain/Database/sqlserver/Program.cs.template index 170168b9..5da0ceb4 100644 --- a/.github/templates/domain/Database/Program.cs.template +++ b/.github/templates/domain/Database/sqlserver/Program.cs.template @@ -7,7 +7,7 @@ namespace {Solution}.{Domain}.Database; public class Program { public static Task Main(string[] args) => SqlServerMigrationConsole - .Create("Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true") + .Create("Data Source=127.0.0.1,1433;Initial Catalog={Solution};User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true") .Configure(c => ConfigureMigrationArgs(c.Args)) .RunAsync(args); @@ -19,6 +19,7 @@ public class Program .RefDataColumnDefault("SortOrder", _ => 0) .RefDataColumnDefault("Scale", _ => 0); + // Only reset data for the {Domain} schema. args.DataResetFilterPredicate = ts => ts.Schema == "{Domain}"; return args; diff --git a/.github/templates/domain/Database/sqlserver/dbex.yaml.template b/.github/templates/domain/Database/sqlserver/dbex.yaml.template new file mode 100644 index 00000000..85f69c0e --- /dev/null +++ b/.github/templates/domain/Database/sqlserver/dbex.yaml.template @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Avanade/DbEx/refs/heads/main/schema/dbex.json +outbox: true +outboxName: outbox +tables: +# Reference-data (conditionally included when Reference Data = Yes) +- name: {Entity}Status + +# Transactional-data +- name: {Entity} +# Conditionally include when Child Entity = Yes: +# - name: {ChildEntity} diff --git a/.github/templates/domain/Domain/Domain.Domain.csproj.template b/.github/templates/domain/Domain/Domain.Domain.csproj.template new file mode 100644 index 00000000..5e9b6707 --- /dev/null +++ b/.github/templates/domain/Domain/Domain.Domain.csproj.template @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + {Solution}.{Domain}.Domain + + + + + + + diff --git a/.github/templates/domain/Domain/GlobalUsing.cs.template b/.github/templates/domain/Domain/GlobalUsing.cs.template new file mode 100644 index 00000000..9df9f113 --- /dev/null +++ b/.github/templates/domain/Domain/GlobalUsing.cs.template @@ -0,0 +1,6 @@ +global using CoreEx; +global using CoreEx.DomainDriven; +global using CoreEx.DomainDriven.Aggregates; +global using CoreEx.Results; +global using {Solution}.{Domain}.Domain; +global using {Solution}.{Domain}.Domain.ValueObjects; diff --git a/.github/templates/domain/Domain/ValueObjects/ExampleValueObject.cs.template b/.github/templates/domain/Domain/ValueObjects/ExampleValueObject.cs.template new file mode 100644 index 00000000..c3c1e36b --- /dev/null +++ b/.github/templates/domain/Domain/ValueObjects/ExampleValueObject.cs.template @@ -0,0 +1,20 @@ +namespace {Solution}.{Domain}.Domain.ValueObjects; + +/// +/// Example value object — rename/copy this file for each value object in the domain. +/// Value objects are immutable; equality is structural (all properties equal). +/// +public sealed record class ExampleValueObject +{ + /// Initializes a new instance of . + public ExampleValueObject(string value) + { + Value = value.ThrowIfNullOrEmpty(); + } + + /// Gets the underlying value. + public string Value { get; } + + /// + public override string ToString() => Value; +} diff --git a/.github/templates/domain/Domain/{Entity}.cs.template b/.github/templates/domain/Domain/{Entity}.cs.template new file mode 100644 index 00000000..9f3c0c27 --- /dev/null +++ b/.github/templates/domain/Domain/{Entity}.cs.template @@ -0,0 +1,46 @@ +namespace {Solution}.{Domain}.Domain; + +/// +/// Represents the aggregate root. +/// +public sealed class {Entity} : Aggregate +{ + // ------------------------------------------------------------------------- + // Factory methods + // ------------------------------------------------------------------------- + + /// Creates a new (state: ). + public static Result<{Entity}> CreateNew(/* required inputs */) + { + var aggregate = Create(); + // TODO: set properties, validate, record domain events + return aggregate; + } + + /// Reconstitutes a from persistence (state: ). + public static {Entity} CreateFrom(string id /* , ... */) + { + var aggregate = Restore(); + aggregate.Id = id; + // TODO: restore properties + return aggregate; + } + + // ------------------------------------------------------------------------- + // Properties + // ------------------------------------------------------------------------- + + // TODO: add domain properties here + + // ------------------------------------------------------------------------- + // Behaviour + // ------------------------------------------------------------------------- + + /// + protected override Result OnCheckCanMutate() => + PersistenceState == PersistenceState.Removed + ? Result.Fail($"{Entity} '{Id}' has been removed and cannot be mutated.") + : Result.Success; + + // TODO: add domain methods that enforce invariants and transition state +} diff --git a/.github/templates/domain/DomainScaffold.checklist.md b/.github/templates/domain/DomainScaffold.checklist.md index 4f292c83..6044b262 100644 --- a/.github/templates/domain/DomainScaffold.checklist.md +++ b/.github/templates/domain/DomainScaffold.checklist.md @@ -1,74 +1,104 @@ # Domain Scaffold Checklist -Use this checklist after scaffolding a new domain from templates/prompts. +Use this checklist after scaffolding a new domain from the templates. -## Inputs Confirmed +## Scaffolding Inputs Confirmed -- [ ] Solution prefix confirmed. -- [ ] Domain name confirmed. -- [ ] Root entity name confirmed. -- [ ] Child entity name confirmed (or explicitly omitted). -- [ ] CRUD operations confirmed. -- [ ] Reference data/status values confirmed. -- [ ] Event subjects confirmed. +- [ ] Solution prefix confirmed (e.g. `Contoso`). +- [ ] Domain name confirmed (e.g. `Orders`). +- [ ] Primary entity name confirmed (e.g. `Order`). +- [ ] **Database engine** answered: SQL Server (default) or PostgreSQL. +- [ ] **Reference Data** answered: Yes (default) or No — drives whether CodeGen project is included. +- [ ] **Domain project** answered: No (default) or Yes — drives whether a Domain aggregate layer is included. +- [ ] **Outbox.Relay** answered: Yes (default) or No — drives whether a relay host is scaffolded. +- [ ] **Subscribe** answered: Yes (default) or No — drives whether an event-consumer host is scaffolded. +- [ ] **Railway Oriented Programming** answered: No (default) or Yes — drives `Result` vs exception-based service/repository patterns. +- [ ] **Child entity** answered: No (default) or Yes — drives whether child-entity templates and migration are included. + +> **Generated files**: Never manually create or edit `*.g.cs`, `*.g.sql`, or `*.g.pgsql` files. +> These are produced by running the CodeGen and Database projects after scaffolding. ## Projects Created -- [ ] All domain projects are grouped under a Visual Studio solution folder named {Domain} (for example, Orders). +- [ ] All domain projects are grouped under a Visual Studio solution folder named `{Domain}`. - [ ] All new domain projects are added to the solution file. -- [ ] {Solution}.{Domain}.Contracts. -- [ ] {Solution}.{Domain}.Application. -- [ ] {Solution}.{Domain}.Infrastructure. -- [ ] {Solution}.{Domain}.Api. -- [ ] {Solution}.{Domain}.Database. -- [ ] {Solution}.{Domain}.Test.Unit. -- [ ] {Solution}.{Domain}.Test.Api. +- [ ] `{Solution}.{Domain}.Contracts` +- [ ] `{Solution}.{Domain}.Application` +- [ ] `{Solution}.{Domain}.Infrastructure` +- [ ] `{Solution}.{Domain}.Api` +- [ ] `{Solution}.{Domain}.Database` +- [ ] `{Solution}.{Domain}.CodeGen` — if Reference Data = Yes. +- [ ] `{Solution}.{Domain}.Domain` — if Domain project = Yes. +- [ ] `{Solution}.{Domain}.Outbox.Relay` — if Outbox.Relay = Yes. +- [ ] `{Solution}.{Domain}.Subscribe` — if Subscribe = Yes. +- [ ] `{Solution}.{Domain}.Test.Unit` +- [ ] `{Solution}.{Domain}.Test.Api` ## Contracts Layer -- [ ] [Contract] classes are partial. -- [ ] Id, ETag, ChangeLog are [ReadOnly(true)]. -- [ ] ReferenceData code properties are partial. -- [ ] Reference data classes and collections exist. +- [ ] `[Contract]` classes are `partial`. +- [ ] `Id`, `ETag`, `ChangeLog` properties carry `[ReadOnly(true)]`. +- [ ] Reference-data code properties use `[ReferenceData]`. +- [ ] Mutable contracts implement `IIdentifier`, `IETag`, and `IChangeLog`. ## Application Layer -- [ ] Interfaces for service/read-service/repository created. -- [ ] Validator created and invoked in mutate methods. -- [ ] All mutate methods wrapped in _unitOfWork.ExecuteAsync. -- [ ] Outbox events added in WhereMutated callbacks. -- [ ] All awaited calls use ConfigureAwait(false). +- [ ] Interfaces exist for service, read-service, and repository. +- [ ] Validator created and invoked in all mutate methods. +- [ ] All mutate methods wrapped in `_unitOfWork.TransactionAsync`. +- [ ] Outbox events added inside `WhereMutated` callbacks. +- [ ] All awaited calls use `.ConfigureAwait(false)`. +- [ ] `QuerySchemaAsync()` implemented on read-service, delegating to repository. ## Infrastructure Layer -- [ ] Persistence models created. -- [ ] Mapper(s) created and wired. -- [ ] EfDb + DbContext created and configured. -- [ ] Repository implementation includes QueryArgsConfig. -- [ ] Outbox publisher points to [{Domain}].[spOutboxEnqueue]. +- [ ] Persistence models created (hand-written, no `*.g.*`). +- [ ] Mapper(s) created and wired (`BiDirectionMapper`). +- [ ] `{Domain}DbContext` is `partial`; calls `AddGeneratedModels(modelBuilder)`. +- [ ] `{Domain}EfDb` uses `EfDbOptions` with `WithLogicalDeleteFilter()` for the primary entity. +- [ ] Repository implementation includes `QueryArgsConfig` for filtering and ordering. +- [ ] Repository implements `QuerySchemaAsync()` returning `_queryConfig.ToJsonSchema()`. +- [ ] Outbox publisher (`{Domain}OutboxPublisher`) registered — SQL Server only. ## API Layer -- [ ] Mutation and read controllers split. -- [ ] POST endpoints use [IdempotencyKey]. -- [ ] GET/HEAD dual route used for get-by-id. -- [ ] PATCH implemented with get + put delegates. -- [ ] Program.cs includes cache, SQL, outbox, OpenAPI, telemetry, health checks. +- [ ] Mutate and read controllers are split into separate classes. +- [ ] POST endpoints carry `[IdempotencyKey]`. +- [ ] Get-by-id uses dual `[HttpGet("{id}"), HttpHead("{id}")]` route. +- [ ] PATCH implemented with `get` + `put` delegates. +- [ ] `QuerySchemaAsync` endpoint exposed at `GET /api/{entityPluralKebab}/$schema`. +- [ ] `Program.cs` includes: `AddPrecisionTimeProvider`, cache, database, outbox, OpenAPI, telemetry, health checks. ## Database Layer -- [ ] dbex.yaml includes all required tables. -- [ ] Schema + table migrations created. +- [ ] `dbex.yaml` includes all required tables; `outboxName: outbox` set. +- [ ] Schema migration created (`{MigrationTimestamp}-000001-create-{domainKebab}-schema.sql`). +- [ ] Reference-data table migration created — if Reference Data = Yes. +- [ ] Primary entity migration created. +- [ ] Child entity migration created — if Child Entity = Yes. - [ ] Outbox tables migration created. -- [ ] All six outbox stored procedures created. -- [ ] Reference data seed file created. -- [ ] Program.cs DataResetFilterPredicate scoped to schema. +- [ ] Reference-data seed file created in `Data/` — if Reference Data = Yes. +- [ ] `Program.cs` `DataResetFilterPredicate` scoped to the `{Domain}` schema. + +## CodeGen Layer (if Reference Data = Yes) + +- [ ] `ref-data.yaml` populated with all reference-data entity names. +- [ ] CodeGen project runs successfully and produces `*.g.cs` artefacts. +- [ ] Generated `{Domain}DbContext.g.cs` partial method `AddGeneratedModels` is present. +- [ ] Generated model properties added to `{Domain}EfDb` after running CodeGen. + +## Post-Scaffold Tooling Steps + +1. Run `{Solution}.{Domain}.CodeGen` — generates `*.g.cs` artefacts (ref-data models, mappers, service entries, controller endpoints). +2. Run `{Solution}.{Domain}.Database` — applies schema migrations and seeds reference data. +3. If Outbox.Relay = Yes — wire the relay host to the Service Bus topic and subscription. +4. If Subscribe = Yes — configure subscriber bindings and register with the message broker. ## Final Validation -- [ ] Diagnostics check returns no errors. -- [ ] Project compiles. -- [ ] Unit tests run and pass for {Solution}.{Domain}.Test.Unit. -- [ ] Api tests run and pass for {Solution}.{Domain}.Test.Api. -- [ ] Added to solution file and organized under the {Domain} solution folder (including test projects). -- [ ] README/docs updated where required. +- [ ] No compiler errors or nullable warnings. +- [ ] Project compiles clean. +- [ ] Unit tests run and pass for `{Solution}.{Domain}.Test.Unit`. +- [ ] API tests run and pass for `{Solution}.{Domain}.Test.Api`. +- [ ] All projects added to the solution file and organised under the `{Domain}` solution folder. +- [ ] README / docs updated where applicable. diff --git a/.github/templates/domain/Infrastructure/Mapping/EntityStatusMapper.cs.template b/.github/templates/domain/Infrastructure/Mapping/EntityStatusMapper.cs.template deleted file mode 100644 index 98500d89..00000000 --- a/.github/templates/domain/Infrastructure/Mapping/EntityStatusMapper.cs.template +++ /dev/null @@ -1,16 +0,0 @@ -namespace {Solution}.{Domain}.Infrastructure.Mapping; - -internal class {Entity}StatusMapper : BiDirectionMapper -{ - protected override Persistence.{Entity}Status OnMap(Contracts.{Entity}Status source) => throw new NotImplementedException(); - - protected override Contracts.{Entity}Status OnMap(Persistence.{Entity}Status source) => new() - { - Id = source.Id!, - Code = source.Code, - Text = source.Text, - SortOrder = source.SortOrder, - IsInactive = !source.IsActive, - ETag = source.ETag - }; -} diff --git a/.github/templates/domain/Infrastructure/Persistence/EntityStatus.cs.template b/.github/templates/domain/Infrastructure/Persistence/EntityStatus.cs.template deleted file mode 100644 index 7f27d618..00000000 --- a/.github/templates/domain/Infrastructure/Persistence/EntityStatus.cs.template +++ /dev/null @@ -1,3 +0,0 @@ -namespace {Solution}.{Domain}.Infrastructure.Persistence; - -public partial class {Entity}Status : ReferenceDataModelBase { } diff --git a/.github/templates/domain/Infrastructure/Repositories/DomainDbContext.cs.template b/.github/templates/domain/Infrastructure/Repositories/DomainDbContext.cs.template deleted file mode 100644 index bc020773..00000000 --- a/.github/templates/domain/Infrastructure/Repositories/DomainDbContext.cs.template +++ /dev/null @@ -1,64 +0,0 @@ -namespace {Solution}.{Domain}.Infrastructure.Repositories; - -public class {Domain}DbContext(DbContextOptions<{Domain}DbContext> options, SqlServerDatabase database) : DbContext(options), IEfDbContext -{ - public IDatabase BaseDatabase { get; } = database.ThrowIfNull(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - base.OnConfiguring(optionsBuilder); - - if (!optionsBuilder.IsConfigured) - optionsBuilder.UseSqlServer(BaseDatabase.Connection); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ThrowIfNull().Entity(e => - { - e.ToTable("{Entity}", "{Domain}"); - e.HasKey(p => p.Id); - e.Property(p => p.Id).HasColumnName("{Entity}Id").HasColumnType("NVARCHAR(50)"); - e.Property(p => p.CustomerId).HasColumnName("CustomerId").HasColumnType("NVARCHAR(100)"); - e.Property(p => p.StatusCode).HasColumnName("StatusCode").HasColumnType("NVARCHAR(50)"); - e.Property(p => p.CreatedBy).HasColumnName("CreatedBy").HasColumnType("NVARCHAR(250)"); - e.Property(p => p.CreatedOn).HasColumnName("CreatedOn").HasColumnType("DATETIMEOFFSET"); - e.Property(p => p.UpdatedBy).HasColumnName("UpdatedBy").HasColumnType("NVARCHAR(250)"); - e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); - e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(StringBase64Converter.Default); - e.HasMany(p => p.Items).WithOne().HasForeignKey(i => i.{Entity}Id).OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.ThrowIfNull().Entity(e => - { - e.ToTable("{ChildEntity}", "{Domain}"); - e.HasKey(p => p.Id); - e.Property(p => p.Id).HasColumnName("{ChildEntity}Id").HasColumnType("NVARCHAR(50)"); - e.Property(p => p.{Entity}Id).HasColumnName("{Entity}Id").HasColumnType("NVARCHAR(50)"); - e.Property(p => p.ProductId).HasColumnName("ProductId").HasColumnType("NVARCHAR(100)"); - e.Property(p => p.Quantity).HasColumnName("Quantity").HasColumnType("DECIMAL(18,4)"); - e.Property(p => p.UnitPrice).HasColumnName("UnitPrice").HasColumnType("DECIMAL(18,4)"); - e.Property(p => p.CreatedBy).HasColumnName("CreatedBy").HasColumnType("NVARCHAR(250)"); - e.Property(p => p.CreatedOn).HasColumnName("CreatedOn").HasColumnType("DATETIMEOFFSET"); - e.Property(p => p.UpdatedBy).HasColumnName("UpdatedBy").HasColumnType("NVARCHAR(250)"); - e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); - }); - - modelBuilder.ThrowIfNull().Entity(e => - { - e.ToTable("{Entity}Status", "{Domain}"); - e.HasKey(p => p.Id); - e.Property(p => p.Id).HasColumnName("{Entity}StatusId").HasColumnType("NVARCHAR(50)"); - e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); - e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); - e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); - e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); - e.Property(p => p.CreatedBy).HasColumnName("CreatedBy").HasColumnType("NVARCHAR(250)"); - e.Property(p => p.CreatedOn).HasColumnName("CreatedOn").HasColumnType("DATETIMEOFFSET"); - e.Property(p => p.UpdatedBy).HasColumnName("UpdatedBy").HasColumnType("NVARCHAR(250)"); - e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); - e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(StringBase64Converter.Default); - e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); - }); - } -} diff --git a/.github/templates/domain/Infrastructure/Repositories/DomainEfDb.cs.template b/.github/templates/domain/Infrastructure/Repositories/DomainEfDb.cs.template deleted file mode 100644 index 832ceb30..00000000 --- a/.github/templates/domain/Infrastructure/Repositories/DomainEfDb.cs.template +++ /dev/null @@ -1,9 +0,0 @@ -namespace {Solution}.{Domain}.Infrastructure.Repositories; - -public sealed class {Domain}EfDb({Domain}DbContext dbContext) : EfDb<{Domain}DbContext>(dbContext) -{ - public EfDbModel {Entity}Statuses => Model(); - - public EfDbMappedModel {EntityPlural} - => Model().ToMappedModel({Entity}Mapper.Default); -} diff --git a/.github/templates/domain/Infrastructure/Repositories/DomainOutboxPublisher.cs.template b/.github/templates/domain/Infrastructure/Repositories/DomainOutboxPublisher.cs.template deleted file mode 100644 index c7c4c885..00000000 --- a/.github/templates/domain/Infrastructure/Repositories/DomainOutboxPublisher.cs.template +++ /dev/null @@ -1,7 +0,0 @@ -namespace {Solution}.{Domain}.Infrastructure.Repositories; - -public class {Domain}OutboxPublisher(SqlServerDatabase database, IDestinationProvider? destinationProvider = null, IEventFormatter? formatter = null, ILogger<{Domain}OutboxPublisher>? logger = null) - : SqlServerOutboxPublisher(database, destinationProvider, formatter, logger) -{ - public override SqlStatement Statement { get; set; } = SqlStatement.StoredProcedure("[{Domain}].[spOutboxEnqueue]"); -} diff --git a/.github/templates/domain/Infrastructure/Repositories/ReferenceDataRepository.cs.template b/.github/templates/domain/Infrastructure/Repositories/ReferenceDataRepository.cs.template deleted file mode 100644 index 2d0a5aee..00000000 --- a/.github/templates/domain/Infrastructure/Repositories/ReferenceDataRepository.cs.template +++ /dev/null @@ -1,10 +0,0 @@ -namespace {Solution}.{Domain}.Infrastructure.Repositories; - -[ScopedService] -public class ReferenceDataRepository({Domain}EfDb ef) : IReferenceDataRepository -{ - private readonly {Domain}EfDb _ef = ef.ThrowIfNull(); - - public Task GetAll{Entity}StatusesAsync() - => _ef.{Entity}Statuses.Query().ToMappedItemsAsync({Entity}StatusMapper.From); -} diff --git a/.github/templates/domain/Infrastructure/Mapping/EntityMapper.cs.template b/.github/templates/domain/Infrastructure/_shared/Mapping/EntityMapper.cs.template similarity index 100% rename from .github/templates/domain/Infrastructure/Mapping/EntityMapper.cs.template rename to .github/templates/domain/Infrastructure/_shared/Mapping/EntityMapper.cs.template diff --git a/.github/templates/domain/Infrastructure/Persistence/ChildEntity.cs.template b/.github/templates/domain/Infrastructure/_shared/Persistence/ChildEntity.cs.template similarity index 100% rename from .github/templates/domain/Infrastructure/Persistence/ChildEntity.cs.template rename to .github/templates/domain/Infrastructure/_shared/Persistence/ChildEntity.cs.template diff --git a/.github/templates/domain/Infrastructure/Persistence/Entity.cs.template b/.github/templates/domain/Infrastructure/_shared/Persistence/Entity.cs.template similarity index 100% rename from .github/templates/domain/Infrastructure/Persistence/Entity.cs.template rename to .github/templates/domain/Infrastructure/_shared/Persistence/Entity.cs.template diff --git a/.github/templates/domain/Infrastructure/_shared/Repositories/DomainEfDb.cs.template b/.github/templates/domain/Infrastructure/_shared/Repositories/DomainEfDb.cs.template new file mode 100644 index 00000000..458ed043 --- /dev/null +++ b/.github/templates/domain/Infrastructure/_shared/Repositories/DomainEfDb.cs.template @@ -0,0 +1,13 @@ +namespace {Solution}.{Domain}.Infrastructure.Repositories; + +public sealed class {Domain}EfDb({Domain}DbContext dbContext) : EfDb<{Domain}DbContext>(dbContext, _options) +{ + private static readonly EfDbOptions _options = new EfDbOptions() + .WithModel(m => m.WithLogicalDeleteFilter()); + + // After running {Solution}.{Domain}.CodeGen, add generated reference-data model properties here. + // Example: public EfDbModel {Entity}Statuses => Model(); + + public EfDbMappedModel {EntityPlural} + => Model().ToMappedModel({Entity}Mapper.Default); +} diff --git a/.github/templates/domain/Infrastructure/Repositories/EntityRepository.cs.template b/.github/templates/domain/Infrastructure/_shared/Repositories/EntityRepository.cs.template similarity index 95% rename from .github/templates/domain/Infrastructure/Repositories/EntityRepository.cs.template rename to .github/templates/domain/Infrastructure/_shared/Repositories/EntityRepository.cs.template index bc54bac9..c49014b0 100644 --- a/.github/templates/domain/Infrastructure/Repositories/EntityRepository.cs.template +++ b/.github/templates/domain/Infrastructure/_shared/Repositories/EntityRepository.cs.template @@ -30,9 +30,10 @@ public class {Entity}Repository({Domain}EfDb ef) : I{Entity}Repository return await entities.Where(parsed).OrderBy(parsed).ToMappedItemsResultAsync(x => new Contracts.{Entity}Lite { Id = x.Id, - CustomerId = x.CustomerId, StatusCode = x.StatusCode, ChangeLog = new ChangeLog { CreatedBy = x.CreatedBy, CreatedOn = x.CreatedOn, UpdatedBy = x.UpdatedBy, UpdatedOn = x.UpdatedOn } }, paging).ConfigureAwait(false); } + + public Task QuerySchemaAsync() => Task.FromResult(_queryConfig.ToJsonSchema()); } diff --git a/.github/templates/domain/Infrastructure/_shared/rop/EntityRepository.cs.template b/.github/templates/domain/Infrastructure/_shared/rop/EntityRepository.cs.template new file mode 100644 index 00000000..883ce26a --- /dev/null +++ b/.github/templates/domain/Infrastructure/_shared/rop/EntityRepository.cs.template @@ -0,0 +1,48 @@ +namespace {Solution}.{Domain}.Infrastructure.Repositories; + +[ScopedService] +public class {Entity}Repository({Domain}EfDb ef) : I{Entity}Repository +{ + private readonly {Domain}EfDb _ef = ef.ThrowIfNull(); + + private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() + .WithFilter(filter => filter + .WithDefaultModelPrefix("{Entity}") + .AddField(nameof(Contracts.{Entity}Base.CustomerId), c => c.WithOperators(QueryFilterOperator.EqualityOperators | QueryFilterOperator.StartsWith)) + .AddReferenceDataField(nameof(Contracts.{Entity}Base.Status), "StatusCode")) + .WithOrderBy(orderby => orderby + .WithDefaultModelPrefix("{Entity}") + .AddField(nameof(Contracts.{Entity}Base.CustomerId), c => c.WithDefault().WithAlwaysInclude())); + + public Task> GetAsync(string id) => Result + .GoAsync(() => _ef.{EntityPlural}.GetWithResultAsync(id)); + + public Task> CreateAsync(Contracts.{Entity} entity) => Result + .GoAsync(() => _ef.{EntityPlural}.CreateWithResultAsync(entity)); + + public Task> UpdateAsync(Contracts.{Entity} entity) => Result + .GoAsync(() => _ef.{EntityPlural}.UpdateWithResultAsync(entity)); + + public Task DeleteAsync(string id) => Result + .GoAsync(() => _ef.{EntityPlural}.DeleteWithResultAsync(id)); + + public async Task>> QueryAsync(QueryArgs? query, PagingArgs? paging) + { + var parsed = _queryConfig.Parse(query); + if (parsed.HasErrors) + return parsed.ToResult>(); + + var entities = _ef.{EntityPlural}.Model.Query(); + + var result = await entities.Where(parsed).OrderBy(parsed).ToMappedItemsResultAsync(x => new Contracts.{Entity}Lite + { + Id = x.Id, + StatusCode = x.StatusCode, + ChangeLog = new ChangeLog { CreatedBy = x.CreatedBy, CreatedOn = x.CreatedOn, UpdatedBy = x.UpdatedBy, UpdatedOn = x.UpdatedOn } + }, paging).ConfigureAwait(false); + + return Result.Ok(result); + } + + public Task QuerySchemaAsync() => Task.FromResult(_queryConfig.ToJsonSchema()); +} diff --git a/.github/templates/domain/Infrastructure/postgres/Domain.Infrastructure.csproj.template b/.github/templates/domain/Infrastructure/postgres/Domain.Infrastructure.csproj.template new file mode 100644 index 00000000..802b64c6 --- /dev/null +++ b/.github/templates/domain/Infrastructure/postgres/Domain.Infrastructure.csproj.template @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/.github/templates/domain/Infrastructure/postgres/GlobalUsing.cs.template b/.github/templates/domain/Infrastructure/postgres/GlobalUsing.cs.template new file mode 100644 index 00000000..646a0f95 --- /dev/null +++ b/.github/templates/domain/Infrastructure/postgres/GlobalUsing.cs.template @@ -0,0 +1,19 @@ +global using {Solution}.{Domain}.Application.Repositories; +global using {Solution}.{Domain}.Infrastructure.Mapping; +global using CoreEx; +global using CoreEx.Data; +global using CoreEx.Data.Models; +global using CoreEx.Data.Querying; +global using CoreEx.Database; +global using CoreEx.Database.Postgres; +global using CoreEx.Database.Postgres.Outbox; +global using CoreEx.DependencyInjection; +global using CoreEx.Entities; +global using CoreEx.EntityFrameworkCore; +global using CoreEx.EntityFrameworkCore.Converters; +global using CoreEx.Events; +global using CoreEx.Events.Publishing; +global using CoreEx.Mapping; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.Logging; +global using System.Text.Json; diff --git a/.github/templates/domain/Infrastructure/postgres/Repositories/DomainDbContext.cs.template b/.github/templates/domain/Infrastructure/postgres/Repositories/DomainDbContext.cs.template new file mode 100644 index 00000000..383416f5 --- /dev/null +++ b/.github/templates/domain/Infrastructure/postgres/Repositories/DomainDbContext.cs.template @@ -0,0 +1,35 @@ +namespace {Solution}.{Domain}.Infrastructure.Repositories; + +public partial class {Domain}DbContext(DbContextOptions<{Domain}DbContext> options, PostgresDatabase database) : DbContext(options), IEfDbContext +{ + /// + public IDatabase BaseDatabase { get; } = database.ThrowIfNull(); + + /// + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + // Uses IDatabase.Connection to ensure the same database/connection is used. + if (!optionsBuilder.IsConfigured) + optionsBuilder.UseNpgsql(BaseDatabase.Connection, contextOwnsConnection: false); + } + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Entity and reference-data configurations are generated by {Solution}.{Domain}.CodeGen + // into {Domain}DbContext.g.cs as the AddGeneratedModels partial method. + // Run the CodeGen project after scaffolding to produce this output. + AddGeneratedModels(modelBuilder); + + // Add any configuration that CodeGen cannot infer — for example navigation properties, + // owned entity types, value conversions, or composite keys. + // Example for a child-entity cascade relationship (include when Child Entity = Yes): + // modelBuilder.Entity() + // .HasMany(p => p.Items) + // .WithOne() + // .HasForeignKey(i => i.{Entity}Id) + // .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/.github/templates/domain/Infrastructure/Domain.Infrastructure.csproj.template b/.github/templates/domain/Infrastructure/sqlserver/Domain.Infrastructure.csproj.template similarity index 57% rename from .github/templates/domain/Infrastructure/Domain.Infrastructure.csproj.template rename to .github/templates/domain/Infrastructure/sqlserver/Domain.Infrastructure.csproj.template index 05aa7fe4..1421b6fa 100644 --- a/.github/templates/domain/Infrastructure/Domain.Infrastructure.csproj.template +++ b/.github/templates/domain/Infrastructure/sqlserver/Domain.Infrastructure.csproj.template @@ -1,12 +1,15 @@ + - - - - - + + + + + - + + + diff --git a/.github/templates/domain/Infrastructure/GlobalUsing.cs.template b/.github/templates/domain/Infrastructure/sqlserver/GlobalUsing.cs.template similarity index 100% rename from .github/templates/domain/Infrastructure/GlobalUsing.cs.template rename to .github/templates/domain/Infrastructure/sqlserver/GlobalUsing.cs.template diff --git a/.github/templates/domain/Infrastructure/sqlserver/Repositories/DomainDbContext.cs.template b/.github/templates/domain/Infrastructure/sqlserver/Repositories/DomainDbContext.cs.template new file mode 100644 index 00000000..a26feaf0 --- /dev/null +++ b/.github/templates/domain/Infrastructure/sqlserver/Repositories/DomainDbContext.cs.template @@ -0,0 +1,35 @@ +namespace {Solution}.{Domain}.Infrastructure.Repositories; + +public partial class {Domain}DbContext(DbContextOptions<{Domain}DbContext> options, SqlServerDatabase database) : DbContext(options), IEfDbContext +{ + /// + public IDatabase BaseDatabase { get; } = database.ThrowIfNull(); + + /// + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + // Uses IDatabase.Connection to ensure the same database/connection is used. + if (!optionsBuilder.IsConfigured) + optionsBuilder.UseSqlServer(BaseDatabase.Connection, contextOwnsConnection: false); + } + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Entity and reference-data configurations are generated by {Solution}.{Domain}.CodeGen + // into {Domain}DbContext.g.cs as the AddGeneratedModels partial method. + // Run the CodeGen project after scaffolding to produce this output. + AddGeneratedModels(modelBuilder); + + // Add any configuration that CodeGen cannot infer — for example navigation properties, + // owned entity types, value conversions, or composite keys. + // Example for a child-entity cascade relationship (include when Child Entity = Yes): + // modelBuilder.Entity() + // .HasMany(p => p.Items) + // .WithOne() + // .HasForeignKey(i => i.{Entity}Id) + // .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/.github/templates/domain/Outbox.Relay/postgres/Domain.Outbox.Relay.csproj.template b/.github/templates/domain/Outbox.Relay/postgres/Domain.Outbox.Relay.csproj.template new file mode 100644 index 00000000..c60312ad --- /dev/null +++ b/.github/templates/domain/Outbox.Relay/postgres/Domain.Outbox.Relay.csproj.template @@ -0,0 +1,28 @@ + + + + Exe + net9.0 + enable + enable + {Solution}.{Domain}.Outbox.Relay + + + + + + + + + + + + + + + + + + + + diff --git a/.github/templates/domain/Outbox.Relay/postgres/Program.cs.template b/.github/templates/domain/Outbox.Relay/postgres/Program.cs.template new file mode 100644 index 00000000..3937d6f5 --- /dev/null +++ b/.github/templates/domain/Outbox.Relay/postgres/Program.cs.template @@ -0,0 +1,38 @@ +var builder = WebApplication.CreateBuilder(args); + +// ---- Database (PostgreSQL) ------------------------------------------------- +builder.AddAzureNpgsqlDataSource("Postgres"); +builder.Services + .AddPostgresDatabase() + .AddPostgresUnitOfWork() + .AddPostgresOutboxRelay(); + +builder.AddPostgresOutboxRelayHostedService(); + +// ---- Messaging (Azure Service Bus) ----------------------------------------- +builder.AddAzureServiceBusClient("ServiceBus"); +builder.Services + .AddAzureServiceBusPublisher(o => + { + o.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId; + }); + +// ---- Hosted service management --------------------------------------------- +builder.Services.AddHostedServiceManager(); + +// ---- Telemetry ------------------------------------------------------------- +builder.Services + .AddOpenTelemetry() + .WithTracing(t => t + .WithCoreExPostgresTelemetry() + .WithCoreExServiceBusTelemetry() + .AddAspNetCoreInstrumentation() + .AddOtlpExporter()); + +// ---- Build ----------------------------------------------------------------- +var app = builder.Build(); + +app.MapHealthChecks("/health"); +app.MapHostedServices(); + +app.Run(); diff --git a/.github/templates/domain/Outbox.Relay/postgres/appsettings.json.template b/.github/templates/domain/Outbox.Relay/postgres/appsettings.json.template new file mode 100644 index 00000000..958c5433 --- /dev/null +++ b/.github/templates/domain/Outbox.Relay/postgres/appsettings.json.template @@ -0,0 +1,25 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "{Solution}", + "DomainName": "{Domain}", + "Services": { + "Interval": "00:00:00.500", + "OutboxRelay": { + "BatchSize": 10, + "PerWorkerPartitionCount": 2, + "LeaseDuration": "00:00:05", + "BackoffDuration": "00:00:05", + "ServicesCount": 4 + } + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/.github/templates/domain/Outbox.Relay/sqlserver/Domain.Outbox.Relay.csproj.template b/.github/templates/domain/Outbox.Relay/sqlserver/Domain.Outbox.Relay.csproj.template new file mode 100644 index 00000000..39f17a86 --- /dev/null +++ b/.github/templates/domain/Outbox.Relay/sqlserver/Domain.Outbox.Relay.csproj.template @@ -0,0 +1,28 @@ + + + + Exe + net9.0 + enable + enable + {Solution}.{Domain}.Outbox.Relay + + + + + + + + + + + + + + + + + + + + diff --git a/.github/templates/domain/Outbox.Relay/sqlserver/Program.cs.template b/.github/templates/domain/Outbox.Relay/sqlserver/Program.cs.template new file mode 100644 index 00000000..0801aa99 --- /dev/null +++ b/.github/templates/domain/Outbox.Relay/sqlserver/Program.cs.template @@ -0,0 +1,38 @@ +var builder = WebApplication.CreateBuilder(args); + +// ---- Database (SQL Server) -------------------------------------------------- +builder.AddSqlServerClient("SqlServer"); +builder.Services + .AddSqlServerDatabase() + .AddSqlServerUnitOfWork() + .AddSqlServerOutboxRelay(); + +builder.AddSqlServerOutboxRelayHostedService(); + +// ---- Messaging (Azure Service Bus) ----------------------------------------- +builder.AddAzureServiceBusClient("ServiceBus"); +builder.Services + .AddAzureServiceBusPublisher(o => + { + o.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId; + }); + +// ---- Hosted service management --------------------------------------------- +builder.Services.AddHostedServiceManager(); + +// ---- Telemetry ------------------------------------------------------------- +builder.Services + .AddOpenTelemetry() + .WithTracing(t => t + .WithCoreExSqlServerTelemetry() + .WithCoreExServiceBusTelemetry() + .AddAspNetCoreInstrumentation() + .AddOtlpExporter()); + +// ---- Build ----------------------------------------------------------------- +var app = builder.Build(); + +app.MapHealthChecks("/health"); +app.MapHostedServices(); + +app.Run(); diff --git a/.github/templates/domain/Outbox.Relay/sqlserver/appsettings.json.template b/.github/templates/domain/Outbox.Relay/sqlserver/appsettings.json.template new file mode 100644 index 00000000..958c5433 --- /dev/null +++ b/.github/templates/domain/Outbox.Relay/sqlserver/appsettings.json.template @@ -0,0 +1,25 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "{Solution}", + "DomainName": "{Domain}", + "Services": { + "Interval": "00:00:00.500", + "OutboxRelay": { + "BatchSize": 10, + "PerWorkerPartitionCount": 2, + "LeaseDuration": "00:00:05", + "BackoffDuration": "00:00:05", + "ServicesCount": 4 + } + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/.github/templates/domain/README.md b/.github/templates/domain/README.md new file mode 100644 index 00000000..05f59d5e --- /dev/null +++ b/.github/templates/domain/README.md @@ -0,0 +1,68 @@ +# Domain Scaffold Templates + +Ready-made templates for bootstrapping a new CoreEx domain. Run the scaffold prompt, answer a few questions, and get a compilable, test-covered project set — with no hand-written generated files. + +## How to use + +**Claude Code** — open this repo and run: + +``` +/scaffold-domain-from-templates +``` + +**GitHub Copilot Chat** (VS Code) — open Copilot Chat and attach the prompt file: + +``` +#file:.github/prompts/scaffold-domain-from-templates.prompt.md scaffold a new domain +``` + +In both cases the agent will ask you the questions below, then clone and materialise every relevant template with your values substituted in. No files are generated that would otherwise come from a code generator (no `*.g.cs`, `*.g.sql`, etc.). + +## Questions you'll be asked + +1. **Solution** and **Domain** names (e.g. `Contoso` / `Orders`). +2. **Entity** name (e.g. `Order`) — plural, kebab, and snake-case variants are derived automatically. +3. **Database engine** — SQL Server (default) or PostgreSQL. +4. **Reference Data** — whether the domain has a status/lookup type. Yes includes a `CodeGen` project that generates contracts, mappers, and endpoints via `CoreEx.CodeGen`; No removes all ref-data patterns entirely. Default **No**. +5. **Child Entity** — whether the primary entity owns a child collection (e.g. `OrderItem`). Conditional migration and infrastructure wiring included. Default **No**. +6. **Domain project** — whether to include a DDD-style domain layer with aggregate roots and value objects. Default **No**. +7. **ROP** (Railway Oriented Programming) — whether service and repository layers use `Result` pipelines instead of throw-on-failure. Default **No**. +8. **Outbox Relay** — whether to include a standalone relay host that forwards outbox events to Service Bus. Default **Yes**. +9. **Subscribe** — whether to include an event-subscriber host. Default **Yes**. + +## Directory layout + +``` +templates/domain/ +├── Contracts/ # Shared entity contracts +├── Application/ # Services, interfaces, validators +│ └── rop/ # Result variants (ROP = Yes) +├── Infrastructure/ +│ ├── _shared/ # Engine-agnostic repositories, mappers, persistence models +│ │ └── rop/ # Result variants (ROP = Yes) +│ ├── sqlserver/ # SQL Server DbContext and csproj +│ └── postgres/ # PostgreSQL DbContext and csproj +├── Api/ +│ ├── sqlserver/ # SQL Server Program.cs and csproj +│ └── postgres/ # PostgreSQL Program.cs and csproj +├── Database/ +│ ├── _shared/ # Engine-agnostic seed data +│ ├── sqlserver/ # SQL Server migrations and DbEx config +│ └── postgres/ # PostgreSQL migrations and DbEx config +├── CodeGen/ # Reference data code-gen console (Ref Data = Yes) +├── Domain/ # DDD aggregate roots and value objects (Domain = Yes) +├── Outbox.Relay/ +│ ├── sqlserver/ # SQL Server relay host (Outbox Relay = Yes) +│ └── postgres/ # PostgreSQL relay host (Outbox Relay = Yes) +└── Subscribe/ + ├── _shared/ # Subscriber class and global usings + ├── sqlserver/ # SQL Server subscriber host (Subscribe = Yes) + └── postgres/ # PostgreSQL subscriber host (Subscribe = Yes) +``` + +`_shared/` directories are always included. Engine-specific directories are selected based on your database engine answer; the other is dropped. + +## Reference + +- **[scaffold-domain-from-templates.prompt.md](../../prompts/scaffold-domain-from-templates.prompt.md)** — full placeholder list, conditional inclusion rules, and post-generation steps the agent follows. +- **[DomainScaffold.checklist.md](./DomainScaffold.checklist.md)** — acceptance checklist the agent runs through before reporting completion. diff --git a/.github/templates/domain/Subscribe/_shared/GlobalUsing.cs.template b/.github/templates/domain/Subscribe/_shared/GlobalUsing.cs.template new file mode 100644 index 00000000..6473bf0d --- /dev/null +++ b/.github/templates/domain/Subscribe/_shared/GlobalUsing.cs.template @@ -0,0 +1,8 @@ +global using {Solution}.{Domain}.Application.Interfaces; +global using CoreEx; +global using CoreEx.DependencyInjection; +global using CoreEx.Events; +global using CoreEx.Events.Subscribing; +global using CoreEx.Json; +global using CoreEx.Results; +global using CoreEx.Validation; diff --git a/.github/templates/domain/Subscribe/_shared/Subscribers/{Entity}EventSubscriber.cs.template b/.github/templates/domain/Subscribe/_shared/Subscribers/{Entity}EventSubscriber.cs.template new file mode 100644 index 00000000..8bd5a2c8 --- /dev/null +++ b/.github/templates/domain/Subscribe/_shared/Subscribers/{Entity}EventSubscriber.cs.template @@ -0,0 +1,19 @@ +namespace {Solution}.{Domain}.Subscribe.Subscribers; + +/// +/// Subscribes to created/updated events and delegates to . +/// +[ScopedService] +[Subscribe("{solution-kebab}.{domainKebab}.{entityKebab}.created.v1")] +[Subscribe("{solution-kebab}.{domainKebab}.{entityKebab}.updated.v1")] +public class {Entity}EventSubscriber(I{Entity}Service service) : SubscribedBase +{ + private readonly I{Entity}Service _service = service.ThrowIfNull(); + + /// + protected override Task OnReceiveAsync(Contracts.{Entity} value, EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default) + { + // TODO: determine intent from @event.Subject / @event.Action and delegate to _service + throw new NotImplementedException(); + } +} diff --git a/.github/templates/domain/Subscribe/postgres/Domain.Subscribe.csproj.template b/.github/templates/domain/Subscribe/postgres/Domain.Subscribe.csproj.template new file mode 100644 index 00000000..faaebda1 --- /dev/null +++ b/.github/templates/domain/Subscribe/postgres/Domain.Subscribe.csproj.template @@ -0,0 +1,37 @@ + + + + Exe + net9.0 + enable + enable + {Solution}.{Domain}.Subscribe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/templates/domain/Subscribe/postgres/Program.cs.template b/.github/templates/domain/Subscribe/postgres/Program.cs.template new file mode 100644 index 00000000..b00b1014 --- /dev/null +++ b/.github/templates/domain/Subscribe/postgres/Program.cs.template @@ -0,0 +1,53 @@ +var builder = WebApplication.CreateBuilder(args); + +// ---- Database (PostgreSQL) ------------------------------------------------- +builder.AddAzureNpgsqlDataSource("Postgres"); +builder.Services + .AddPostgresDatabase() + .AddPostgresUnitOfWork() + .AddPostgresOutboxPublisher() + .AddPostgresEfDb<{Domain}EfDb>(); + +// ---- Caching ---------------------------------------------------------------- +builder.AddRedisDistributedCache("Redis"); +builder.Services + .AddFusionCache() + .WithDistributedCache() + .WithStackExchangeRedisBackplane(); + +// ---- Application services --------------------------------------------------- +builder.Services + .AddScoped() + .AddReferenceDataOrchestrator(); // conditional: Reference Data = Yes + +// ---- Subscriber (Azure Service Bus) ---------------------------------------- +builder.AddAzureServiceBusClient("ServiceBus"); +builder.Services + .AddSubscribedManager((_, c) => c.AddSubscribersUsing<{Entity}EventSubscriber>()); + +builder.Services + .AzureServiceBusReceiving() + .WithSessionReceiver(o => o.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId) + .WithSubscribedSubscriber() + .WithHostedService() + .Build(); + +// ---- Hosted service management --------------------------------------------- +builder.Services.AddHostedServiceManager(); + +// ---- Telemetry ------------------------------------------------------------- +builder.Services + .AddOpenTelemetry() + .WithTracing(t => t + .WithCoreExPostgresTelemetry() + .WithCoreExServiceBusTelemetry() + .AddAspNetCoreInstrumentation() + .AddOtlpExporter()); + +// ---- Build ----------------------------------------------------------------- +var app = builder.Build(); + +app.MapHealthChecks("/health"); +app.MapHostedServices(); + +app.Run(); diff --git a/.github/templates/domain/Subscribe/postgres/appsettings.json.template b/.github/templates/domain/Subscribe/postgres/appsettings.json.template new file mode 100644 index 00000000..a7d85bdb --- /dev/null +++ b/.github/templates/domain/Subscribe/postgres/appsettings.json.template @@ -0,0 +1,15 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "{Solution}", + "DomainName": "{Domain}" + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/.github/templates/domain/Subscribe/sqlserver/Domain.Subscribe.csproj.template b/.github/templates/domain/Subscribe/sqlserver/Domain.Subscribe.csproj.template new file mode 100644 index 00000000..cbb7b699 --- /dev/null +++ b/.github/templates/domain/Subscribe/sqlserver/Domain.Subscribe.csproj.template @@ -0,0 +1,37 @@ + + + + Exe + net9.0 + enable + enable + {Solution}.{Domain}.Subscribe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/templates/domain/Subscribe/sqlserver/Program.cs.template b/.github/templates/domain/Subscribe/sqlserver/Program.cs.template new file mode 100644 index 00000000..a3546f3f --- /dev/null +++ b/.github/templates/domain/Subscribe/sqlserver/Program.cs.template @@ -0,0 +1,53 @@ +var builder = WebApplication.CreateBuilder(args); + +// ---- Database (SQL Server) -------------------------------------------------- +builder.AddSqlServerClient("SqlServer"); +builder.Services + .AddSqlServerDatabase() + .AddSqlServerUnitOfWork() + .AddSqlServerOutboxPublisher() + .AddSqlServerEfDb<{Domain}EfDb>(); + +// ---- Caching ---------------------------------------------------------------- +builder.AddRedisDistributedCache("Redis"); +builder.Services + .AddFusionCache() + .WithDistributedCache() + .WithStackExchangeRedisBackplane(); + +// ---- Application services --------------------------------------------------- +builder.Services + .AddScoped() + .AddReferenceDataOrchestrator(); // conditional: Reference Data = Yes + +// ---- Subscriber (Azure Service Bus) ---------------------------------------- +builder.AddAzureServiceBusClient("ServiceBus"); +builder.Services + .AddSubscribedManager((_, c) => c.AddSubscribersUsing<{Entity}EventSubscriber>()); + +builder.Services + .AzureServiceBusReceiving() + .WithSessionReceiver(o => o.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId) + .WithSubscribedSubscriber() + .WithHostedService() + .Build(); + +// ---- Hosted service management --------------------------------------------- +builder.Services.AddHostedServiceManager(); + +// ---- Telemetry ------------------------------------------------------------- +builder.Services + .AddOpenTelemetry() + .WithTracing(t => t + .WithCoreExSqlServerTelemetry() + .WithCoreExServiceBusTelemetry() + .AddAspNetCoreInstrumentation() + .AddOtlpExporter()); + +// ---- Build ----------------------------------------------------------------- +var app = builder.Build(); + +app.MapHealthChecks("/health"); +app.MapHostedServices(); + +app.Run(); diff --git a/.github/templates/domain/Subscribe/sqlserver/appsettings.json.template b/.github/templates/domain/Subscribe/sqlserver/appsettings.json.template new file mode 100644 index 00000000..a7d85bdb --- /dev/null +++ b/.github/templates/domain/Subscribe/sqlserver/appsettings.json.template @@ -0,0 +1,15 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "{Solution}", + "DomainName": "{Domain}" + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/CoreEx.sln b/CoreEx.sln index c71b701c..55208380 100644 --- a/CoreEx.sln +++ b/CoreEx.sln @@ -230,6 +230,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{4BE535E8-7 samples\docs\hosts-layer.md = samples\docs\hosts-layer.md samples\docs\infrastructure-layer.md = samples\docs\infrastructure-layer.md samples\docs\layers.md = samples\docs\layers.md + samples\docs\local-dev.md = samples\docs\local-dev.md samples\docs\patterns.md = samples\docs\patterns.md samples\docs\testing.md = samples\docs\testing.md samples\docs\tooling.md = samples\docs\tooling.md diff --git a/README.md b/README.md index 7a3cb0a4..f5d4e92c 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ This is a **major** version release; a re-imagine / re-invention of the existing Version 4 is currently in **preview**; the packages are published with a `-preview` suffix and may contain future breaking changes. The packages in their current state can be used for Production-based solutions. Feedback is very welcome to help shape the final release. +The Copilot and Claude [AI](#ai) integrations should be considered experimental and subject to change/improvements. + ## Status The build status is [![CI](https://github.com/Avanade/CoreEx/actions/workflows/CI.yml/badge.svg)](https://github.com/Avanade/CoreEx/actions/workflows/CI.yml) with the NuGet package status as follows, including links to the underlying source code and documentation: @@ -115,6 +117,31 @@ The repository includes [Contoso reference samples](./samples/README.md) that im | [Testing Guide](./samples/docs/testing.md) | Unit, intra-domain, inter-domain, and E2E testing strategy | | [Aspire & E2E Guide](./samples/docs/aspire.md) | Local orchestration and cross-domain end-to-end validation | +## AI + +The repository includes an AI workflow set in [`.github/`](./.github/) that gives GitHub Copilot and Claude Code authoritative knowledge of CoreEx patterns, conventions, and architecture — no need to explain CoreEx to the tool each time. The artefacts can also be copied into a consuming project. + +**Agent** — `coreex-expert` provides architecture guidance, pattern recommendations, and design reviews aligned to the sample implementations. See the [agent README](./.github/agents/README.md) for the resolution flowchart and local doc cache design. + +**Commands** — type `/` in chat to invoke. Skills use `#file:` attachment in Copilot Chat. + +| Command | What it does | Claude Code | GitHub Copilot Chat | +|---------|-------------|-------------|---------------------| +| [Expert guidance](./.github/agents/README.md) | Architecture, pattern, and design advice | `@coreex-expert` | Agent mode → **CoreEx Expert** | +| [`/scaffold-domain-from-templates`](./.github/templates/domain/README.md) | Fast domain scaffolding via template substitution | `/scaffold-domain-from-templates` | `/scaffold-domain-from-templates` | +| [`/generate-domain`](./.github/skills/generate-domain/README.md) | Guided, reasoning-based domain generation | `/generate-domain` | `#file:.github/skills/generate-domain/SKILL.md` | +| [`/add-capability`](./.github/skills/add-capability/README.md) | Retrofit an existing domain with messaging/integration | `/add-capability` | `#file:.github/skills/add-capability/SKILL.md` | +| [`/coreex-docs-sync`](./.github/skills/coreex-docs-sync/README.md) | Cache CoreEx docs and per-package AI guides locally | `/coreex-docs-sync` | `#file:.github/skills/coreex-docs-sync/SKILL.md` | +| [`/acquire-codebase-knowledge`](./.github/skills/acquire-codebase-knowledge/README.md) | Map and document an existing codebase | `/acquire-codebase-knowledge` | `#file:.github/skills/acquire-codebase-knowledge/SKILL.md` | +| [`/aspire`](./.github/skills/aspire/README.md) | Orchestrate Aspire apps locally (start, stop, logs) | `/aspire` | `#file:.github/skills/aspire/SKILL.md` | +| `/init` · `/setup` | Initialize or configure a solution | `/init` · `/setup` | `/init` · `/setup` | + +**Instructions** — 10 scoped instruction files are injected automatically when editing matching file types (contracts, services, repositories, controllers, tests, etc.). No action required. + +**Domain templates** — 77 ready-made templates covering all layers, both SQL Server and PostgreSQL, and optional features (CodeGen, Domain, Outbox Relay, Subscribe, ROP). See the [domain templates README](./.github/templates/domain/README.md). + +→ **[Full AI workflow overview](./.github/README.md)** + ## License _CoreEx_ is open source under the [MIT license](./LICENCE) and is free for commercial use. @@ -129,4 +156,4 @@ See our [security disclosure](./SECURITY.md) policy. ## Who is Avanade? -[Avanade](https://www.avanade.com) is the leading provider of innovative digital and cloud services, business solutions and design-led experiences on the Microsoft ecosystem, and the power behind the Accenture Microsoft Business Group. \ No newline at end of file +[Avanade](https://www.avanade.com) is the leading provider of innovative digital and cloud services, business solutions and design-led experiences on the Microsoft ecosystem, and the power behind the Accenture Microsoft Business Group.