Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/instructions/coreex-application-services.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,36 @@ var contract = BasketMapper.Map(aggregate);

Infrastructure-level mapping (Contract ↔ Persistence model) uses `BiDirectionMapper` and lives in `Infrastructure/Mapping/`. Do not conflate the two layers.

### JSON-backed value object mapping

A Domain value object that is persisted via a JSON column (see [`coreex-domain.instructions.md#value-objects-backed-by-a-json-column`](/.github/instructions/coreex-domain.instructions.md#value-objects-backed-by-a-json-column)) still needs an Application-layer mapper — but because the value can flow **both** directions (read back out to the contract *and* accepted as input on an update operation), use `BiDirectionMapper<TDomain, TContract, TSelf>` here instead of the uni-directional `Mapper<TSource, TDest, TSelf>` used for the root aggregate:

```csharp
// Application/Mapping/AddressMapper.cs
public class AddressMapper : BiDirectionMapper<Domain.ValueObjects.Address, Contracts.Address, AddressMapper>
{
protected override Contracts.Address OnMap(Domain.ValueObjects.Address source) => new()
{
Street1 = source.Street1,
Street2 = source.Street2,
City = source.City,
PostCode = source.PostCode,
State = source.State
};

protected override Domain.ValueObjects.Address OnMap(Contracts.Address source) => new()
{
Street1 = source.Street1!,
Street2 = source.Street2,
City = source.City!,
PostCode = source.PostCode!,
State = source.State!
};
}
```

Call `AddressMapper.To.Map(...)` / `AddressMapper.From.Map(...)` at the point of use — e.g. the root `BasketMapper` composes it for the outbound direction (`ShippingAddress = AddressMapper.To.Map(source.ShippingAddress)`), and the service composes it for the inbound direction when accepting an update (`basket.UpdateShippingAddress(AddressMapper.From.Map(shippingAddress))`). This mapper only ever sees the Domain ↔ Contract boundary — the separate Infrastructure-layer mapper (Domain ↔ Persistence, in `Infrastructure/Mapping/`) handles the other boundary; never conflate the two or skip straight from `Contracts.{ValueObject}` to `Persistence.{ValueObject}`.

## 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:
Expand Down
37 changes: 37 additions & 0 deletions .github/instructions/coreex-domain.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,43 @@ public sealed record class ItemPricing

Place value objects in a `ValueObjects/` sub-folder within the Domain project.

### Value Objects Backed by a JSON Column

Some value objects are persisted as a single serialised JSON column rather than as separate scalar columns on the owning table (e.g. `Basket.ShippingAddress` → `basket.ShippingAddressJson`). This does not change how the value object itself is authored — it is still a plain `sealed record`/`record class` with invariant-enforcing `init` setters, as above — but it does introduce an extra type at each layer boundary:

```csharp
// Domain/ValueObjects/Address.cs — same conventions as any other value object
public record class Address
{
public required string Street1 { get; init => field = value.ThrowIfNullOrEmpty(); }
public string? Street2 { get; init => field = value.ThrowIfEmpty(); }
public required string City { get; init => field = value.ThrowIfNullOrEmpty(); }
public required string PostCode { get; init => field = value.ThrowIfNullOrEmpty(); }
public required string State { get; init => field = value.ThrowIfNullOrEmpty(); }
}
```

The Domain value object sits in the **middle** of a three-type chain: `Contracts.Address` (DTO) ↔ `Domain.ValueObjects.Address` (this type, with invariants) ↔ `Persistence.Address` (hand-authored POCO, no invariants — see [`coreex-tooling.instructions.md`](/.github/instructions/coreex-tooling.instructions.md#json-columns-in-dbex-yaml) and the [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md#json-columns) skill for the `dbex.yaml` column setup and Persistence POCO conventions). Two separate `BiDirectionMapper`s bridge the chain — one per layer boundary:

- **Application layer** (Domain ↔ Contract) — see [`coreex-application-services.instructions.md`](/.github/instructions/coreex-application-services.instructions.md#json-backed-value-object-mapping).
- **Infrastructure layer** (Domain ↔ Persistence) — see [`coreex-repositories.instructions.md`](/.github/instructions/coreex-repositories.instructions.md).

Wire the value object into the aggregate exactly like any other property — a private setter guarded by `Modify(...)` via a dedicated update method:

```csharp
public Address? ShippingAddress { get; private set; }

public Result UpdateShippingAddress(Address? shippingAddress)
{
if (shippingAddress != ShippingAddress)
Modify(() => ShippingAddress = shippingAddress);

return Result.Success;
}
```

The aggregate has no awareness that `ShippingAddress` is persisted as JSON — that is entirely an Infrastructure-layer (EF `ValueConverter`) concern via `TypeToJsonStringEfConverter<T>`.

## When to Introduce the Domain Layer

Only introduce a Domain layer when the domain genuinely has:
Expand Down
20 changes: 19 additions & 1 deletion .github/instructions/coreex-repositories.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ The Infrastructure project is organised into focused sub-folders. The table belo
| `Mapping/` | Bidirectional mappers (`BiDirectionMapper<TContract, TModel, TSelf>`) between Contract types and Persistence model types. |
| `Adapters/` | Implementations of `IXxxAdapter` interfaces defined in `Application/Adapters/`. Registered with `[ScopedService<IInterface>]`. |
| `Clients/` | Typed HTTP client wrappers — one class per external service. Registered via `AddTypedHttpClient<T>()` in `Program.cs`. |
| `Persistence/` | EF entity/model classes. These are **generated** (`*.g.cs`) by the `*.Database` tooling project — do not create or edit manually. |
| `Persistence/` | EF entity/model classes. The `*.g.cs` files are **generated** by the `*.Database` tooling — do not edit them. Hand-authored POCOs for JSON-column storage (plain classes, no base class, no attributes) also live here alongside the generated files — see [Mapping](#mapping). |

Repository and adapter implementations follow the same primary-constructor + guard pattern:

Expand Down Expand Up @@ -337,6 +337,24 @@ protected override Contracts.Employee OnMap(Persistence.Employee source) => new(

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<TSource, TDest, TSelf>`. Do not conflate the two.

**JSON-typed properties** (backed by a `*Json` / `*_json` database column) are mapped in `OnMap` exactly like any other typed property — direct assignment, no `JsonSerializer.Serialize/Deserialize`:

```csharp
// Simple collection — the CLR type is the same on both sides; assign directly
Tags = source.Tags,

// Complex POCO — map fields from the hand-authored Persistence.Address POCO
ShippingAddress = source.ShippingAddress is null ? null : new Persistence.Address
{
Street1 = source.ShippingAddress.Street1,
City = source.ShippingAddress.City,
PostCode = source.ShippingAddress.PostCode,
State = source.ShippingAddress.State,
}
```

`TypeToJsonStringEfConverter<T>` (auto-wired in the generated `*DbContext.g.cs`) handles serialisation transparently at the EF layer. For complex object types, the hand-authored POCO lives in `Infrastructure/Persistence/` alongside the generated `*.g.cs` files — it is a plain class, no base class, no attributes; use `required`/non-nullable for mandatory fields. See the [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md#json-columns) skill for the `dbex.yaml` columns entry and full POCO conventions.

## External Clients and Adapter Implementations

When a domain calls another domain's API over HTTP, split the concern across two focused classes:
Expand Down
34 changes: 34 additions & 0 deletions .github/instructions/coreex-tooling.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,36 @@ tables:

(Add `columns:`, `efModel`, `efModelName`, `includeColumns`/`excludeColumns`, or `columnName*` overrides only when a specific need arises.)

### JSON columns in `dbex.yaml`

A column whose name ends with `Json` (SQL Server `PascalCase`) or `_json` (PostgreSQL `snake_case`) stores a serialised JSON representation of a .NET type. DbEx surfaces this in `Inspect` output as `Json: Yes`. **A `columns:` entry is required** — without it, DbEx generates `string?` with no converter.

```yaml
# SQL Server — PascalCase
- name: Basket
columns:
- name: ShippingAddressJson # DB column name — must include the Json suffix
property: ShippingAddress # C# property name — suffix stripped
type: Persistence.Address? # CLR type: persistence POCO, List<string>?, Dictionary<K,V>?, etc.

# PostgreSQL — snake_case
- name: product
columns:
- name: tags_json
property: Tags
type: List<string>?
```

The `type:` field drives code generation:
- **Non-string type** → DbEx auto-wires `TypeToJsonStringEfConverter<T>` in the generated `*DbContext.g.cs`. Do not add `.HasConversion(...)` by hand.
- **`string` or `string?`** → the JSON is stored as-is with no converter (raw passthrough).

When `type:` is a complex object (not `string`, `List<T>`, `Dictionary<K,V>`, or another natively-serialisable type), a **hand-authored POCO** is required in `Infrastructure/Persistence/`. It is a plain class — no base class, no `[Contract]` or other attributes, no validation logic. Use `required` / non-nullable for mandatory fields and nullable only where genuinely optional. For natively-serialisable types (`List<string>?`, `Dictionary<string,string>?`, etc.) no separate class is needed.

**Default column type: bounded text, matching the DB's normal string-column convention** — `NVARCHAR(n)` (SQL Server) / `VARCHAR(n)` (PostgreSQL) with an explicit maximum length (e.g. `NVARCHAR(2000)`/`VARCHAR(2000)` as a reasonable starting point), the same way any other text column in the database is sized. Do **not** default to unbounded `NVARCHAR(MAX)` / `TEXT` or the native `JSONB`/`JSON` type — those are an explicit **override**, used only when the developer deliberately wants unbounded storage or in-database JSON querying/indexing. See `samples/src/Contoso.Products.Database` (`product.tags_json` → native `JSONB`, an intentional override) vs. `samples/src/Contoso.Shopping.Database` (`basket.ShippingAddressJson` → bounded `NVARCHAR(2000)`, the default) for both patterns side-by-side.

For the full worked examples, DDD aggregate vs CRUD service guidance, and mapper conventions see the [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md#json-columns) skill.

### `CodeGen` phase — generated Infrastructure C#

The `CodeGen` command generates `.g.cs` files into the Infrastructure project:
Expand Down Expand Up @@ -317,6 +347,7 @@ Reading the output:
- Branch on the `## SCHEMA.TABLE - Exists: Yes|No` header first. `No` means the table is absent and must be created.
- Use the **Qualified Name** bullet (e.g. `"public"."contact"` or `[Test].[Contact]`) for DDL casing and quoting — not the uppercased header text.
- Honour the **Reference Data: Yes|No** flag for routing decisions (a reference data table is maintained via `ref-data.yaml` + CodeGen, not by hand).
- Honour the **Json: Yes|No** flag per column — it means the column is (or should be modelled as) serialised JSON content, either because it uses a native JSON database type or by the `Json`/`_json` naming convention (see [JSON columns](#json-columns-in-dbex-yaml)). A required `dbex.yaml` `columns:` entry follows from this.
- PostgreSQL reports canonical type names (`CHARACTER VARYING(50)`, `TIMESTAMP WITH TIME ZONE`); treat these as equivalent to the `VARCHAR(50)` / `TIMESTAMPTZ` forms you would author in a script.
- Per the disclaimer in the output, the live database remains the ultimate truth; the report is derived from system catalogs and may not capture every nuance.

Expand Down Expand Up @@ -422,6 +453,9 @@ When authoring a migration script for an entity that has a corresponding .NET co
| `bool` | `BIT` | `BOOLEAN` |
| `DateTime` | `DATETIME2` | `TIMESTAMP` |
| `DateTimeOffset` | `DATETIMEOFFSET` | `TIMESTAMPTZ` |
| `DateOnly` | `DATE` | `date` |
| `TimeOnly` | `TIME` | `time` |
| Complex type (class/record), or collection/dictionary (`List<T>`, `Dictionary<K,V>`) | `NVARCHAR(n)` bounded by default — JSON suffix convention (see [JSON columns](#json-columns-in-dbex-yaml)); `NVARCHAR(MAX)`/native `JSON` only as an explicit override | `VARCHAR(n)` bounded by default — JSON suffix convention (see [JSON columns](#json-columns-in-dbex-yaml)); native `JSONB` only as an explicit override |

> **Creation procedure → skill.** Authoring/applying a migration script is driven by the
> [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md) skill (inspect → script → fill columns →
Expand Down
3 changes: 2 additions & 1 deletion .github/skills/coreex-aggregate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ then follows the corresponding pattern from `CoreEx.DomainDriven`.
- 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`](/.github/instructions/coreex-domain.instructions.md#value-objects-backed-by-a-json-column) and the [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md#json-columns) skill for the column/mapper setup

For full workflow and code examples see [`references/workflow.md`](references/workflow.md).

Expand All @@ -62,7 +63,7 @@ For full workflow and code examples see [`references/workflow.md`](references/wo
- [`/.github/instructions/coreex-domain.instructions.md`](/.github/instructions/coreex-domain.instructions.md) — full Domain layer conventions (aggregates, entities, value objects, `PersistenceState`)
- [`/.github/instructions/coreex-application-services.instructions.md`](/.github/instructions/coreex-application-services.instructions.md) — how Application services construct, mutate, and map aggregates (`Domain.Xxx.CreateNew(...)`, `Application/Mapping/` `Mapper<TSource,TDest,TSelf>`)
- [`/.github/instructions/coreex-tests.instructions.md`](/.github/instructions/coreex-tests.instructions.md) — `*.Test.Unit` conventions (`WithGenericTester<EntryPoint>`, `Test.Scoped(...)`) reused for aggregate unit tests
- Related skills: [`coreex-contract`](../coreex-contract/SKILL.md) (maps aggregate ↔ contract), [`coreex-repository`](../coreex-repository/SKILL.md) (persists aggregates via `PersistenceState`), [`coreex-app-service`](../coreex-app-service/SKILL.md) (constructs/mutates aggregates, forwards `Events`)
- Related skills: [`coreex-contract`](../coreex-contract/SKILL.md) (maps aggregate ↔ contract), [`coreex-repository`](../coreex-repository/SKILL.md) (persists aggregates via `PersistenceState`), [`coreex-app-service`](../coreex-app-service/SKILL.md) (constructs/mutates aggregates, forwards `Events`), [`coreex-db-migration`](../coreex-db-migration/SKILL.md#json-columns) (JSON column setup for JSON-backed value objects)
- Deep-dive (after `/coreex-docs-sync`) — the primary consumer-resolvable pointers into `CoreEx.DomainDriven`:
- [`/.github/docs/coreex/agents/CoreEx.DomainDriven.md`](/.github/docs/coreex/agents/CoreEx.DomainDriven.md) — package AI usage guide (key types, the "Domain Events — Intentionally Not Supported" rationale)
- [`/.github/docs/coreex/domain-layer.md`](/.github/docs/coreex/domain-layer.md) — Domain layer sample architecture doc
Expand Down
33 changes: 33 additions & 0 deletions .github/skills/coreex-aggregate/references/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,39 @@ public sealed record class {ValueObject}
directly with object initializer syntax; there is no `PersistenceState` to track because it has
no independent identity.

### Persisted as a JSON column

When the owning aggregate/entity stores this value object as serialised JSON (naming convention: a
`{Property}Json`/`{property}_json` database column — see the [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md#json-columns)
skill), the value object itself is authored with **no changes** to the rules above — it still knows
nothing about persistence or JSON. Three additional things fall out of this choice:

1. **Three types instead of two.** The Domain value object (this type) sits between `Contracts.{ValueObject}`
(plain DTO) and `Persistence.{ValueObject}` (hand-authored POCO, no invariants, no base class — created
manually alongside the generated `.g.cs` files, since DbEx cannot generate a type with validation logic).
2. **Two mappers instead of one.** `BiDirectionMapper<Domain.ValueObjects.{ValueObject}, Contracts.{ValueObject}, TSelf>`
in `Application/Mapping/` bridges Domain ↔ Contract; `BiDirectionMapper<Persistence.{ValueObject}, Domain.ValueObjects.{ValueObject}, TSelf>`
in `Infrastructure/Mapping/` bridges Persistence ↔ Domain. Each mapper only ever sees its own boundary — never
skip straight from `Persistence.{ValueObject}` to `Contracts.{ValueObject}`.
3. **Wire it into the aggregate like any other property** — a private setter plus a dedicated
`Update{Property}(...)` method guarded by `Modify(...)`:

```csharp
public {ValueObject}? {Property} { get; private set; }

public Result Update{Property}({ValueObject}? {property})
{
if ({property} != {Property})
Modify(() => {Property} = {property});

return Result.Success;
}
```

For the worked example (`Basket.ShippingAddress` / `Domain.ValueObjects.Address`) see
[`coreex-domain.instructions.md#value-objects-backed-by-a-json-column`](/.github/instructions/coreex-domain.instructions.md#value-objects-backed-by-a-json-column).
For the Application-layer mapper see [`coreex-application-services.instructions.md#json-backed-value-object-mapping`](/.github/instructions/coreex-application-services.instructions.md#json-backed-value-object-mapping).

---

## Unit Testing Aggregates
Expand Down
1 change: 1 addition & 0 deletions .github/skills/coreex-app-service/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Guides you through creating or modifying a CoreEx Application-layer service in `
- All interface methods include `CancellationToken cancellationToken = default` as the last parameter; pass through to every async call and `TransactionAsync(async ct => ..., cancellationToken)`
- CQRS: mutations + `GetAsync` → `{Name}Service`; queries + `GetAsync` → `{Name}ReadService` (both have `GetAsync`)
- Always `.ConfigureAwait(false)` on every `await`
- A Domain value object persisted via a JSON column (e.g. `Basket.ShippingAddress`) is mapped with a `BiDirectionMapper<TDomain, TContract, TSelf>` in `Application/Mapping/` (not the uni-directional `Mapper<TSource,TDest,TSelf>` used for the root aggregate) — see [`coreex-application-services.instructions.md#json-backed-value-object-mapping`](/.github/instructions/coreex-application-services.instructions.md#json-backed-value-object-mapping)

For full workflow and code examples see [`references/workflow.md`](references/workflow.md).

Expand Down
Loading
Loading