Avanade

coreex-aggregate

"Add or modify a DDD domain object in a CoreEx Domain layer. USE FOR: new aggregate root (Aggregate<TId,TSelf>), new child entity (Entity<TId,TSelf>) 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)."

Avanade 28 8 Updated 2mo ago

Resources

1
GitHub

Install

npx skillscat add avanade/coreex/coreex-aggregate

Install via the SkillsCat registry.

SKILL.md

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 where the *.Domain project exists
(added via dotnet new coreex-domain). 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

Mix and match within a domain. Once a *.Domain project exists, adopting it is not all-or-nothing across
every entity in that domain. Only give an entity the full aggregate/entity/value-object ceremony (mutation
guards, PersistenceState, factory methods) when it genuinely benefits — meaningful invariants, state
transitions, or integration events. A simple reference/lookup-style entity in the same domain can remain
CRUD-oriented (Application service → repository directly), even while a sibling aggregate in the same
*.Domain project uses the full pattern.

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

Confirm this skill applies before writing any domain object. It is gated on the presence of the
*.Domain project (src/*.Domain/). If it is absent, the domain is CRUD-oriented — stop and use
coreex-app-service against repository interfaces instead. To add the Domain layer, run
dotnet new coreex-domain -n Company.Product.Books.Domain and wire it into the solution first.

Quick Reference

  • Aggregate root → Aggregate<TId, TSelf> (extends Entity<TId, TSelf>) — adds Events/AddEvent/ClearEvents for integration-event accumulation
  • Child entity → Entity<TId, TSelf> — 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<T> for consistency with Application-layer Result<T> 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<TId,TSelf>.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<EntryPoint> + 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
  • A value object backed by a JSON database column (e.g. Address stored as basket.ShippingAddressJson) is authored exactly like any other value object — a plain sealed record/record class with invariant-enforcing init setters. It sits as the middle type in a three-type chain (Contracts.Xxx ↔ Domain.ValueObjects.Xxx ↔ Persistence.Xxx) bridged by two separate mappers (Application: Domain↔Contract; Infrastructure: Domain↔Persistence) — see `coreex-domain.instructions.md#value-objects-backed-by-a-json-column` and the `coreex-db-migration` skill for the column/mapper setup

For full workflow and code examples see `references/workflow.md`.

Key References