diff --git a/.github/prompts/coreex-aggregate.prompt.md b/.github/prompts/coreex-aggregate.prompt.md new file mode 100644 index 00000000..555a0f59 --- /dev/null +++ b/.github/prompts/coreex-aggregate.prompt.md @@ -0,0 +1,12 @@ +--- +mode: agent +description: "Add or modify a DDD domain object (aggregate root, entity, or value object) in a CoreEx Domain layer. Interviews the developer to determine which applies, then follows the CoreEx.DomainDriven pattern." +--- + +Use the `coreex-aggregate` skill to add or modify a domain object in this CoreEx `*.Domain` project. + +Read `.github/skills/coreex-aggregate/SKILL.md` first, confirm a Domain layer is warranted (Phase 0), +interview to determine aggregate root vs entity vs value object (Phase 1), then follow +`.github/skills/coreex-aggregate/references/workflow.md` step by step. + +User request: ${input} diff --git a/.github/skills/coreex-aggregate/SKILL.md b/.github/skills/coreex-aggregate/SKILL.md new file mode 100644 index 00000000..33dec706 --- /dev/null +++ b/.github/skills/coreex-aggregate/SKILL.md @@ -0,0 +1,55 @@ +--- +name: coreex-aggregate +description: "Add or modify a DDD domain object in a CoreEx Domain layer. USE FOR: new aggregate root (Aggregate), new child entity (Entity) owned by an aggregate, new value object (sealed record), adding mutation methods, PersistenceState-aware factory methods, mutation guards (OnCheckCanMutate/OnMutate). Interviews the developer to determine which of the three (aggregate root / entity / value object) applies, then follows the appropriate pattern. DO NOT USE FOR: CRUD-oriented domains with no Domain layer (use coreex-app-service directly against repositories), Application-layer mappers between aggregate and contract (see coreex-application-services.instructions.md), Infrastructure persistence of aggregates (use coreex-repository)." +argument-hint: "Optional: domain object kind (aggregate root / entity / value object), identifier type, mutation operations needed, invariants to enforce" +tags: ["ddd", "domain-driven-design", "aggregate", "entity", "value-object", "domain-layer", "coreex"] +--- + +# CoreEx: Aggregate (DDD Domain Object) + +Guides you through adding or modifying a domain object in a CoreEx Domain layer — aggregate roots, +child entities, and value objects. This skill is scoped to solutions with `--domain-driven-enabled true` +(the `*.Domain` project exists). It interviews you to determine which kind of domain object you need, +then follows the corresponding pattern from `CoreEx.DomainDriven`. + +## When to Use + +- Add a new **aggregate root** — a consistency boundary with mutation guards and (optionally) integration events +- Add a new **child entity** — owned exclusively by an aggregate, with identity but no independent lifecycle +- Add a new **value object** — a concept with no identity, defined entirely by its values +- Add mutation methods, factory methods, or invariant guards to an existing domain object +- Decide whether a domain concept needs a Domain layer at all, or should stay CRUD-oriented + +## When Not to Use + +- The domain is CRUD-oriented with no meaningful invariants to protect at the model level — let the + Application service orchestrate directly against repository interfaces; skip the Domain layer entirely +- Application-layer mapping between aggregate and contract — see `.github/instructions/coreex-application-services.instructions.md` +- Infrastructure persistence of the aggregate (EF mapping, `PersistenceState`-driven insert/update/delete) — use `coreex-repository` +- Validating primitive request DTOs before they reach the domain — use `coreex-validator` + +## Quick Reference + +- **Aggregate root** → `Aggregate` (extends `Entity`) — adds `Events`/`AddEvent`/`ClearEvents` for integration-event accumulation +- **Child entity** → `Entity` — identity-based equality (`Id` only), no independent construction path from outside the aggregate +- **Value object** → `sealed record` — structural equality, `init`-only properties, invariants enforced in the initialiser +- Two factory methods only: `CreateNew(...)` (`.AsNew()`) and `CreateFrom(...)` (`.AsNotModified()`) — constructor is `private` +- `Modify(...)` / `Remove(...)` are the **only** mutation paths — they invoke `OnCheckCanMutate()`, apply the change, transition `PersistenceState`, then call `OnMutate()` +- `OnCheckCanMutate()` returns `Result` but is invoked via `.ThrowOnError()` internally — a failing guard throws the exception carried by the `Result` (typically `BusinessException` for `Result.BusinessError(...)`) from `Modify`/`Remove`, regardless of the `Result` return type declared here +- Public mutation methods on the aggregate should return `Result`/`Result` for consistency with Application-layer `Result` pipelines — even though the internal guard throws +- Child entity mutation methods are `internal` — only the owning aggregate may invoke them +- No async I/O in domain classes — ever. Async belongs in Application services or Policies. +- No native domain-event dispatch (MediatR-style) — only integration events via `Aggregate.Events`, forwarded through `IUnitOfWork.Events` in the Application layer +- Aggregates are the best unit-test target in the codebase (no injected dependencies) — cover every mutation method's happy path and rejection path with `WithGenericTester` + `Test.Scoped(...)`, calling the aggregate directly (no repository, no Application service); assert `OnCheckCanMutate()` guard failures as thrown exceptions, and failed-`Result` returns as `Result` assertions + +For full workflow and code examples see [`references/workflow.md`](references/workflow.md). + +## Key References + +- `.github/instructions/coreex-domain.instructions.md` — full Domain layer conventions (aggregates, entities, value objects, `PersistenceState`) +- `.github/instructions/coreex-application-services.instructions.md` — how Application services construct, mutate, and map aggregates (`Domain.Xxx.CreateNew(...)`, `Application/Mapping/` `Mapper`) +- `src/CoreEx.DomainDriven/README.md` — package overview, key types, and the "Domain Events — Intentionally Not Supported" rationale +- `src/CoreEx.DomainDriven/Aggregate.cs`, `Entity.cs`, `EntityBase.cs` — actual base-class implementation (`Modify`, `Remove`, `OnCheckCanMutate`, `OnMutate`, `PersistenceState` transitions) +- `src/CoreEx.DomainDriven/PersistenceState.cs`, `DomainDrivenExtensions.cs` — state enum and filter helpers (`IsNew`, `IsModified`, `IsNewOrModified`, `IsNotRemoved`) +- `src/CoreEx.Template/README.md` — `--domain-driven-enabled true` template flag and generated `*.Domain` project shape +- `.github/instructions/coreex-tests.instructions.md` — `*.Test.Unit` conventions (`WithGenericTester`, `Test.Scoped(...)`) reused for aggregate unit tests diff --git a/.github/skills/coreex-aggregate/references/workflow.md b/.github/skills/coreex-aggregate/references/workflow.md new file mode 100644 index 00000000..db7f5cc1 --- /dev/null +++ b/.github/skills/coreex-aggregate/references/workflow.md @@ -0,0 +1,376 @@ +# coreex-aggregate: Workflow + +Full workflow for adding or modifying a DDD domain object (aggregate root, entity, or value object) +in a CoreEx `*.Domain` project. + +--- + +## Phase 0 — Confirm the Domain Layer Applies + +Before creating anything, confirm a Domain layer is warranted at all: + +| Question | Guidance | +|---|---| +| Does this concept have invariants that must be enforced at the model level (state machines, child-collection rules, cross-property constraints)? | If no → skip the Domain layer; let the Application service orchestrate directly against repository interfaces. | +| Does the domain already have a `*.Domain` project? | If no and the answer above is yes, the solution needs `--domain-driven-enabled true` — this is a solution-scaffolding concern, not something this skill creates. Confirm with the developer before proceeding. | +| Is this a CRUD-oriented entity (simple get/create/update/delete, no business rules beyond validation)? | If yes → skip the Domain layer entirely; use `coreex-contract` + `coreex-repository` + `coreex-app-service` directly. | + +Only proceed past this point when a Domain layer is confirmed to exist and be warranted. + +--- + +## Phase 1 — Interview: Which Domain Object? + +| Question | Branch | +|---|---| +| Is this the consistency boundary itself — the thing loaded, mutated, and saved as a unit, potentially raising integration events? | **Aggregate Root** → Step 1 | +| Is this owned exclusively by an aggregate, has its own identity, but has no independent lifecycle outside the aggregate? | **Entity** → Step 2 | +| Does this concept have no identity — it's fully defined by its values (a price, a range, a measurement)? | **Value Object** → Step 3 | + +A single feature request often needs more than one — e.g., an aggregate root with one or more child +entities and a value object used by both. Work through each in the order above; child entities and +value objects are usually needed to complete the aggregate root's design. + +--- + +## Step 1 — Aggregate Root + +Extend `Aggregate` from `CoreEx.DomainDriven`. This is the consistency boundary — the +only object the Application layer loads, mutates, and persists directly. + +```csharp +// Domain/{Aggregate}.cs +namespace {Domain}.Domain; + +public sealed class {Aggregate} : Aggregate<{IdType}, {Aggregate}> +{ + // Backing field for a child-entity collection (if applicable) — never expose the mutable list. + private List<{ChildEntity}> _items = []; + + /// Creates a new {Aggregate}. + public static {Aggregate} CreateNew({CtorArgs}) => new {Aggregate}({NewIdExpression}) + { + // Set initial state for a brand-new instance. + {Property} = {value} + }.AsNew(); + + /// Reconstructs an existing {Aggregate} from persisted data. + public static {Aggregate} CreateFrom({IdType} id, {CtorArgs}, IEnumerable<{ChildEntity}>? items, ChangeLog? changeLog, string? etag) => new {Aggregate}(id) + { + {Property} = {value}, + _items = items is null ? [] : [.. items.Select(i => i.Clone(PersistenceState.NotModified))], + ChangeLog = changeLog, + ETag = etag + }.AsNotModified(); + + // Constructor is private — CreateNew/CreateFrom are the only public construction paths. + private {Aggregate}({IdType} id) : base(id) { } + + public IReadOnlyList<{ChildEntity}> Items => _items; + + /// Enforces the mutation guard — called automatically by every Modify/Remove. + protected override Result OnCheckCanMutate() => {StatusProperty}.CanBeMutated + ? Result.Success + : Result.BusinessError($"{Aggregate} has a status of '{StatusProperty}' and cannot be modified.", + c => c.WithKey(Id).WithErrorCode("invalid-status")); + + /// Re-derives dependent state after every successful mutation. + protected override void OnMutate() + { + // Recalculate anything that depends on current child-entity/property state. + } + + /// Adds a new {ChildEntity} to the aggregate. + public Result {ChildEntity}Add({ChildEntity} item) => Modify(() => + { + item.ThrowIfNull(); + _items.Add(item.Clone(PersistenceState.New)); + return Result.Success; + }); +} +``` + +**Rules:** +- `{NewIdExpression}` is `Runtime.NewId()` when `{IdType}` is `string`, or `Runtime.NewGuid()` when + `{IdType}` is `Guid` — `Runtime.NewId()` always returns a `string`, so it is **not** a generic + id-type-agnostic call; pick the one matching `{IdType}` (or another `IIdentifierGenerator`-based + helper for other id types). +- `CreateNew(...)` calls `.AsNew()`; `CreateFrom(...)` calls `.AsNotModified()` — these are the **only** + two public construction paths. The constructor is `private`. +- Only expose **read-only** properties and a small set of intention-revealing **mutation methods** — + never a public setter that bypasses `Modify`. +- `OnCheckCanMutate()` returns `Result.Success` or `Result.BusinessError(...)` — this is invoked via + `CheckCanMutate()` → `.ThrowOnError()` internally, so a failing guard **throws the exception carried + by the failed `Result`** when `Modify`/`Remove` is called — typically a `BusinessException` when + `Result.BusinessError(...)` is used, but whatever exception type the `Result` actually carries. +- `OnMutate()` is called automatically after every successful `Modify`/`Remove` — use it to re-derive + dependent state (e.g., recompute a status from child-entity state), not to perform I/O. +- Public mutation methods (e.g., `{ChildEntity}Add`) should return `Result`/`Result` for consistency + with Application-layer `Result` pipelines — this makes expected business failures explicit and + composable, even though the internal guard mechanism throws. Throwing `BusinessException` directly + from a mutation method is acceptable where it reads more naturally, but `Result` is the recommended + default. +- Add integration events with the inherited `AddEvent(EventData)` — these are **integration** events + only (to inform other systems), never domain events (in-process notifications); see + [Integration Events](#integration-events-not-domain-events) below. +- Never perform async I/O (repository calls, HTTP requests, adapter calls) inside the aggregate — + that belongs in Application services or Policies. + +--- + +## Step 2 — Child Entity + +Extend `Entity`. A child entity has its own identity but no independent lifecycle outside +the owning aggregate. Its `CreateNew`/`CreateFrom` factory methods are `public` (the aggregate, or +occasionally the Application layer, may construct an instance to pass into the aggregate), but every +**mutation method** is `internal` — once inside the aggregate, only the aggregate may change it. + +```csharp +// Domain/{ChildEntity}.cs +namespace {Domain}.Domain; + +public sealed class {ChildEntity} : Entity<{IdType}, {ChildEntity}> +{ + /// Creates a new {ChildEntity}. + public static {ChildEntity} CreateNew({CtorArgs}) + => new {ChildEntity}({NewIdExpression}) { {Property} = {value} }.AsNew(); + + /// Reconstructs an existing {ChildEntity} from persisted data. + public static {ChildEntity} CreateFrom({IdType} id, {CtorArgs}, string? etag) + => new {ChildEntity}(id) { {Property} = {value}, ETag = etag }.AsNotModified(); + + private {ChildEntity}({IdType} id) : base(id) { } + + public {PropertyType} {Property} { get; private set => field = value.ThrowIfNull(); } = null!; + + // Mutation methods are internal — only the owning aggregate may invoke them. + internal void Override{Property}({PropertyType} value) => Modify(() => {Property} = value); + internal void Delete() => Remove(); + + // Optional consumer-authored helper — NOT provided by CoreEx.DomainDriven. Useful when the owning + // aggregate's CreateFrom needs to re-tag a rehydrated child collection with a specific PersistenceState. + internal {ChildEntity} Clone(PersistenceState state) => CreateFrom(Id, {CtorArgs}, ETag).SetPersistenceState(state); +} +``` + +**Rules:** +- `{NewIdExpression}` — see the same `Runtime.NewId()`/`Runtime.NewGuid()` rule as the aggregate root; + choose based on `{ChildEntity}`'s own `{IdType}`, which need not match the owning aggregate's. +- Same factory-method + private-constructor pattern as the aggregate root: `CreateNew` / `CreateFrom`, + `.AsNew()` / `.AsNotModified()`. +- Mutation methods are **`internal`**, never `public` — this ensures only the owning aggregate can + drive a child entity's mutations once it has been added to the aggregate. +- Identity-based equality — `Entity.Equals` only compares `Id`, not property values. +- `Clone(PersistenceState)` (shown above) is **not** a `CoreEx.DomainDriven` base-class member — it is + a common consumer-authored pattern, calling the entity's own `CreateFrom` plus the inherited + `protected SetPersistenceState(...)`, used when the aggregate's `CreateFrom` reconstructs its child + collection and needs to re-tag each item as `NotModified` (or `New`, when merging a newly added item). + +--- + +## Step 3 — Value Object + +Implement as a `sealed record` — no identity, defined entirely by its values, structural equality and +`with`-expression mutation for free. Enforce invariants in the initialiser. + +```csharp +// Domain/ValueObjects/{ValueObject}.cs +namespace {Domain}.Domain.ValueObjects; + +public sealed record class {ValueObject} +{ + public required {PropertyType} {Property} { get; init => field = value.ThrowIfLessThanZero(); } + public {OtherType} {OtherProperty} { get; init => field = value.ThrowIfNull(); } + + // Derived read-only members are welcome — they are not persisted, only computed. + public {ResultType} {Derived} => {expression}; + + /// Validates cross-property invariants not expressible via a single property initialiser. + public {ValueObject} EnsureIsValid() => {condition} ? this + : throw new ValidationException("{Description of the violated invariant}."); +} +``` + +**Rules:** +- `sealed record`, never a mutable class — structural equality and `with`-expression semantics are + intrinsic to a value object's definition. +- Enforce single-property invariants directly in the `init` accessor using the guard helpers + (`.ThrowIfNull()`, `.ThrowIfLessThanZero()`, `.ThrowIfInactive()`, etc.) — they return the guarded + value, making them composable inline. +- Cross-property invariants that cannot be expressed in a single `init` accessor go in an + `EnsureIsValid()` method, called by the owning entity/aggregate after construction. +- Place value objects in a `ValueObjects/` sub-folder within the Domain project. +- A value object never has a factory method pair (`CreateNew`/`CreateFrom`) — it is constructed + directly with object initializer syntax; there is no `PersistenceState` to track because it has + no independent identity. + +--- + +## Unit Testing Aggregates + +Aggregates (and their child entities) are the best possible unit-test target in the whole codebase: +they generally have **no injected dependencies** beyond ambient reference data, so their full logic — +happy paths *and* business-rule rejections — can be verified with fast, isolated, traditional unit +tests. Do not defer this coverage to host-level integration tests; those only prove wiring, not the +domain's own branching logic. + +Follow the same `*.Test.Unit` conventions as validator tests (see +`.github/instructions/coreex-tests.instructions.md`): one test class per aggregate, under +`*.Test.Unit/Domains/`, named `{Aggregate}Tests`, extending `WithGenericTester`, with each +`[Test]` running inside `Test.Scoped(test => { ... })` — this establishes the ambient `ExecutionContext` +that `Runtime.UtcNow` and any `ThrowIfInactive()` reference-data check rely on. (`Runtime.NewId()`/ +`Runtime.NewGuid()` do **not** need `ExecutionContext` — they resolve via `IdentifierGenerator.Current` +— but `Test.Scoped(...)` is still the standard wrapper for every test in these projects.) + +```csharp +namespace {Domain}.Test.Unit.Domains; + +public class {Aggregate}Tests : WithGenericTester +{ + [Test] + public void {MutationMethod}_Succeeds_When_{Condition}() => Test.Scoped(test => + { + // Arrange: construct the aggregate directly via CreateFrom — no repository, no persistence. + var aggregate = {Aggregate}.CreateFrom({id}, {CtorArgs}, items: null, changeLog: null, etag: null); + + // Act: invoke the public mutation method exactly as the Application service would. + aggregate.{MutationMethod}({args}); + + // Assert: verify resulting state directly on the aggregate. + aggregate.{Property}.Should().Be({expected}); + }); + + [Test] + public void {MutationMethod}_Fails_When_{GuardCondition}() => Test.Scoped(test => + { + // Arrange: construct the aggregate in a state that should reject the mutation. + var aggregate = {Aggregate}.CreateFrom({id}, {CtorArgsForGuardedState}, items: null, changeLog: null, etag: null); + + // Act: capture the guarded mutation as a delegate for exception assertion. + Action act = () => aggregate.{MutationMethod}({args}); + + // Assert: OnCheckCanMutate's failed Result throws — assert the exact exception and message. + act.Should().Throw().WithMessage("{Expected guard message}."); + + // Assert: state is unchanged — the guard rejected the mutation before it took effect. + aggregate.{Property}.Should().Be({unchangedValue}); + }); +} +``` + +**Rules:** +- Arrange with `CreateFrom(...)`, not `CreateNew(...)`, when the test needs a specific pre-existing + state (status, child items, etc.) — `CreateFrom` is the reconstruction path and lets the test set up + any combination of properties directly, exactly like rehydrating from a store. +- Call the aggregate's public mutation methods directly — never go through a repository, `IUnitOfWork`, + or Application service in these tests; that orchestration is covered by the host-level integration + tests (`*.Test.Api` / `*.Test.Subscribe`), not here. +- For a rejected mutation that goes through `OnCheckCanMutate()` (i.e., any `Modify(...)`/`Remove(...)` + guard failure), wrap the call in an `Action act = () => ...` delegate and assert with + `act.Should().Throw().WithMessage(...)` — match the exact message from + `Result.BusinessError(...)`, since that message is part of the aggregate's observable contract. Some + mutation methods instead **return** a failed `Result` for expected outcomes that aren't guard + rejections (e.g., a not-found child item, or a business rule checked before calling `Modify` at all) + — assert those as a `Result` failure (`result.IsFailure.Should().BeTrue()` / assert `result.Error`), + not as a thrown exception. +- Assert both the exception **and** that state was left unchanged — a guard that throws but still + mutates state first is a bug this style of test is specifically positioned to catch. +- If the aggregate's properties are backed by reference data (e.g., a status `ThrowIfInactive()` guard), + the unit-test `EntryPoint.ConfigureApplication` needs to provide a test-backed reference-data source — + typically `AddReferenceDataOrchestrator()` plus an in-memory decorator/provider seeded from YAML + (reusing the `*.Database` project's seed data is a common but not the only pattern) — this is what + makes the aggregate testable with zero live infrastructure. +- Cover both the happy path (mutation succeeds, dependent state via `OnMutate()` recalculates correctly) + and the business-rule rejection path (`OnCheckCanMutate()` fails) for every mutation method with a + guard — these branches are exactly what the Domain layer exists to encode. Request-validator tests + (`coreex-validator`) run before the aggregate is even constructed, so they do not exercise mutation + guards or state transitions — the two test styles cover different layers and neither substitutes for + the other. (Note: `OnCheckCanMutate()` may itself delegate to a `CoreEx.Validation` validator for + pre-mutation checks — that validator still warrants its own unit tests in addition to this coverage.) +- Skip re-testing framework-guaranteed behaviour (e.g., that `.ThrowIfNull()` throws for `null`) — focus + coverage on the aggregate's own business rules and state transitions. + +--- + +## Guard Helpers Reference + +These extension methods return the guarded value on success (enabling inline chaining) and throw on +failure — used throughout property setters, `init` accessors, and factory method bodies: + +| Helper | Throws when | +|---|---| +| `.ThrowIfNull()` | value is `null` | +| `.ThrowIfNullOrEmpty()` | value is `null` or an empty string | +| `.ThrowIfInactive()` | a reference-data value's `IsActive` is `false` | +| `.ThrowIfLessThanZero()` | a numeric value is less than zero | + +Chain them for multiple checks on one value: `value.ThrowIfNull().ThrowIfInactive()`. + +--- + +## PersistenceState Reference + +| State | Meaning | Set by | +|---|---|---| +| `Unknown` | Default; never a valid target state — `SetPersistenceState` throws if asked to set it | — | +| `New` | Newly created; insert on next commit | `.AsNew()` in `CreateNew(...)` | +| `NotModified` | Loaded from store; no action required | `.AsNotModified()` in `CreateFrom(...)` — this transition is only permitted from `Unknown` | +| `Modified` | Changed since load; update on next commit | Automatically by `Modify(...)`, but **only** when currently `NotModified` — a `New` entity that is modified stays `New` | +| `Removed` | Marked for deletion; delete on next commit | Automatically by `Remove(...)` — this is terminal: `Remove(...)` also calls `MakeReadOnly()`, so a removed entity cannot be mutated further | + +Filter helpers for child-entity collections: + +```csharp +_items.Where(i => i.PersistenceState.IsNotRemoved) // active items only +_items.Any(i => i.PersistenceState.IsNewOrModified) // has-changes check +``` + +--- + +## Integration Events (Not Domain Events) + +`Aggregate` provides `AddEvent(EventData)` and `ClearEvents()` for **integration events** +only — coarse-grained notifications to other systems, published via the transactional outbox from the +Application layer. CoreEx deliberately does not provide in-process domain-event dispatch (no MediatR +`INotification`-style mechanism): + +- **Why:** fine-grained domain events generate high event volumes with implicit, hard-to-trace + side-effects; coarse integration events communicated through `IUnitOfWork.Events` are explicit, + auditable, and transactional. +- Add events from within a mutation method using the inherited `AddEvent(...)`. +- The Application service is responsible for forwarding `aggregate.Events` into `_unitOfWork.Events` + inside the same `TransactionAsync(...)` scope that persists the aggregate — see + `.github/instructions/coreex-application-services.instructions.md` for the Application-layer side + of this handoff. +- If a genuine in-process domain-event use case exists, that is an opt-in extension a consumer adds + themselves (e.g., raising events dispatched via MediatR after commit) — not a CoreEx default. + +--- + +## Guardrails + +- **No async I/O in domain classes** — repository calls, HTTP requests, adapter calls all belong in + Application services or Policies, never in an aggregate, entity, or value object +- **Constructor is always `private`** — `CreateNew(...)` and `CreateFrom(...)` are the only public + construction paths for aggregates and entities +- **Child entity mutation methods are `internal`**, never `public` — only the owning aggregate may + invoke them; the Application layer never mutates a child entity directly +- **`Modify(...)`/`Remove(...)` are the only mutation paths** — never mutate a private field directly + outside of one of these wrappers; they enforce `OnCheckCanMutate()`, transition `PersistenceState`, + and call `OnMutate()` consistently +- **`Remove(...)` is terminal** — it sets `PersistenceState.Removed` and then calls `MakeReadOnly()`; + there is no built-in restore path, so any further `Modify`/`Remove` call on that instance throws +- **Prefer `Result`/`Result` returns from public mutation methods** — even though `OnCheckCanMutate()` + failures always throw internally, the public method signature staying `Result`-based keeps it + composable in Application-layer `Result` pipelines; throwing `BusinessException` directly is + acceptable but not the default recommendation +- **Value objects are `sealed record`, never mutable classes** — `init`-only properties with invariant + enforcement in the accessor or an `EnsureIsValid()` method +- **No native domain-event dispatch** — only integration events via `Aggregate.Events`, + forwarded by the Application layer; do not introduce MediatR or an in-process event bus into the + Domain layer +- **Do not reference Infrastructure, Application, or host assemblies** from the Domain layer — it + depends only on `Contracts` and `CoreEx`/`CoreEx.DomainDriven` +- **Skip the Domain layer entirely for CRUD-oriented entities** — introducing aggregates/entities for + a domain with no real invariants adds ceremony without benefit; use the Application layer directly + against repository interfaces instead diff --git a/src/CoreEx.Template/CoreEx.Template.csproj b/src/CoreEx.Template/CoreEx.Template.csproj index 768f6093..dd2d712b 100644 --- a/src/CoreEx.Template/CoreEx.Template.csproj +++ b/src/CoreEx.Template/CoreEx.Template.csproj @@ -143,6 +143,9 @@ <_AiFile Include="$(_RepoRoot).github/prompts/coreex-subscriber.prompt.md" DestSubPath=".github/prompts/coreex-subscriber.prompt.md" /> <_AiFile Include="$(_RepoRoot).github/skills/coreex-subscriber/SKILL.md" DestSubPath=".github/skills/coreex-subscriber/SKILL.md" /> <_AiFile Include="$(_RepoRoot).github/skills/coreex-subscriber/references/workflow.md" DestSubPath=".github/skills/coreex-subscriber/references/workflow.md" /> + <_AiFile Include="$(_RepoRoot).github/prompts/coreex-aggregate.prompt.md" DestSubPath=".github/prompts/coreex-aggregate.prompt.md" /> + <_AiFile Include="$(_RepoRoot).github/skills/coreex-aggregate/SKILL.md" DestSubPath=".github/skills/coreex-aggregate/SKILL.md" /> + <_AiFile Include="$(_RepoRoot).github/skills/coreex-aggregate/references/workflow.md" DestSubPath=".github/skills/coreex-aggregate/references/workflow.md" /> <_AiFile Include="$(_RepoRoot).github/skills/solution-scaffolder/SKILL.md" DestSubPath=".github/skills/solution-scaffolder/SKILL.md" /> <_AiFile Include="$(_RepoRoot).github/skills/solution-scaffolder/README.md" DestSubPath=".github/skills/solution-scaffolder/README.md" /> <_AiFile Include="$(_RepoRoot).github/skills/solution-scaffolder/assets/servicebus-config.template.json" DestSubPath=".github/skills/solution-scaffolder/assets/servicebus-config.template.json" /> @@ -267,6 +270,9 @@ +