Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
39f8b3f
ValueObject implements IComparable for composite VO equality
xavierjohn Mar 28, 2026
4ee3a8e
feat: add tests for Maybe<Money> handling in EF Core
xavierjohn Mar 28, 2026
98a5d0c
Add Maybe<Money> support for EF Core conventions
xavierjohn Mar 28, 2026
05d8c69
fix: enable HandleNull on JSON converters so null VO properties produ…
xavierjohn Mar 28, 2026
b3e38fa
Add MonetaryAmount scalar VO and fix Required* docs
xavierjohn Mar 28, 2026
902a1ca
Document composite ValueObject EF Core owned type pattern
xavierjohn Mar 28, 2026
d9b37dc
Add ReplaceDbProvider<T> helper and TRLSGEN100 test coverage
xavierjohn Mar 28, 2026
1ba3f54
Document ReplaceDbProvider in testing reference and README
xavierjohn Mar 28, 2026
09b3080
Remove unused s_moneyType field and Trellis.Primitives using from May…
xavierjohn Mar 28, 2026
ce46932
Fix IComparable.CompareTo(null) to return 1 per .NET convention
xavierjohn Mar 28, 2026
504e178
Add missing test coverage for MonetaryAmount and ReplaceDbProvider
xavierjohn Mar 28, 2026
a8f86a0
Add TryCreate(string?, string?) to IScalarValue interface
xavierjohn Mar 28, 2026
0e66658
Add IFormatProvider parameter to IScalarValue.TryCreate and simplify …
xavierjohn Mar 28, 2026
7ca92ba
Add IFormattableScalarValue for culture-aware string parsing
xavierjohn Mar 29, 2026
1162f65
Exclude source generators from code coverage
xavierjohn Mar 29, 2026
305b041
Fix formatting and add missing newlines in various test and source files
xavierjohn Mar 29, 2026
d7b7341
Enhance RequiredPartialClassGenerator to support IFormattableScalarVa…
xavierjohn Mar 29, 2026
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
91 changes: 91 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ public static Money Create(decimal amount, string currencyCode)
}
```

### Culture-Aware String Parsing

Numeric and date value objects implement `IFormattableScalarValue` for culture-sensitive parsing:
- `TryCreate(string?)` — always uses `InvariantCulture` (safe for APIs)
- `TryCreate(string?, IFormatProvider?, string?)` — uses the specified culture (for CSV import, user input with known locale)

String-based VOs (`EmailAddress`, `Slug`, etc.) only have `TryCreate(string?)` — culture doesn't affect their format.

## Value Object Category Review

Before adding or approving a new value-like type, classify it first:
Expand Down Expand Up @@ -633,11 +641,31 @@ var matches = await context.Customers.WhereEquals(c => c.Phone, phone).ToListAsy

These methods rewrite the expression tree to target the backing field via `EF.Property<T?>`, so EF Core can translate the query to SQL.

### Maybe\<T\> with Composite Owned Types

`partial Maybe<Money>` is also supported. The conventions automatically configure it as an optional owned type — no `OwnsOne` configuration needed:

```csharp
public partial class Penalty : Aggregate<PenaltyId>
{
public Money Fine { get; set; } = null!; // required Money (2 NOT NULL columns)
public partial Maybe<Money> FinePaid { get; set; } // optional Money (2 nullable columns)
}
```

Column naming follows `MoneyConvention`: `FinePaid` (amount) and `FinePaidCurrency`.

> **Note:** `ExecuteUpdate` helpers (`SetMaybeValue`/`SetMaybeNone`) do not support `Maybe<Money>`. Use tracked entity updates (load, modify, `SaveChangesAsync`) instead.

## Money with EF Core

`Money` properties are automatically mapped as owned types by `ApplyTrellisConventions` — no `OwnsOne` configuration needed.
This includes `Money` properties declared on owned entity types, including items inside `OwnsMany` collections.

For optional Money properties, use `partial Maybe<Money>` — see the Maybe\<T\> section above.

> **Single-currency alternative:** If your system uses one currency everywhere, use `MonetaryAmount` instead of `Money`. It is a scalar value object (`ScalarValueObject<MonetaryAmount, decimal>`) that maps to a single `decimal` column — no currency column needed. See the Trellis.Primitives README for details.

### How It Works

The `MoneyConvention` (registered by `ApplyTrellisConventions`) uses two EF Core convention interfaces:
Expand Down Expand Up @@ -681,6 +709,61 @@ modelBuilder.Entity<Order>(b =>
});
```

## Composite ValueObjects as EF Core Owned Types

When a composite `ValueObject` (like `ShippingAddress`) is persisted via EF Core's `OwnsOne`, it needs specific boilerplate for EF Core materialization:

```csharp
public sealed record ShippingAddress : ValueObject
{
// EF Core requires a private parameterless constructor for materialization
private ShippingAddress() { }

// Properties must use 'private set' (not 'get;' only) and '= null!' for reference types
public Street Street { get; private set; } = null!;
public City City { get; private set; } = null!;
public State State { get; private set; } = null!;
public PostalCode PostalCode { get; private set; } = null!;
public Country Country { get; private set; } = null!;

// Public factory — the only way to create instances
public static Result<ShippingAddress> TryCreate(
Street street, City city, State state, PostalCode postalCode, Country country) =>
new ShippingAddress
{
Street = street, City = city, State = state,
PostalCode = postalCode, Country = country
};

protected override IEnumerable<IComparable?> GetEqualityComponents()
{
yield return Street;
yield return City;
yield return State;
yield return PostalCode;
yield return Country;
}
}
```

**Rules:**
- Always add `private ShippingAddress() { }` — EF Core cannot materialize without it
- Use `{ get; private set; }` not `{ get; }` — EF Core sets properties via reflection
- Add `= null!` to all reference type properties — suppresses nullable warning for the private constructor path
- Value type properties (e.g., `int`, `decimal`, `DateTime`) do not need `= null!`

**Entity configuration:**
```csharp
// In IEntityTypeConfiguration<Customer>
builder.OwnsOne(c => c.ShippingAddress);
```

**Nested OwnsOne with OwnsMany:** When two `OwnsOne` navigations share the same child type that has `OwnsMany` collections, EF Core defaults to the same table name. Use explicit `ToTable()`:
```csharp
builder.OwnsOne(s => s.Inning1, b => b.OwnsMany(i => i.BattingEntries).ToTable("Inning1BattingEntries"));
builder.OwnsOne(s => s.Inning2, b => b.OwnsMany(i => i.BattingEntries).ToTable("Inning2BattingEntries"));
```

## Known Namespace Collisions

### `Trellis.Unit` vs `Mediator.Unit`
Expand All @@ -698,3 +781,11 @@ using Unit = Trellis.Unit;
```

The parameterless `Result.Success()` is preferred — it avoids the type name entirely.

## Pre-Submission Checklist

Before committing any changes:

1. **All tests pass** — `dotnet test` from the repository root must report zero failures.
2. **Code review by GPT-5.4** — Use a code-review agent with `model: gpt-5.4` to review all changed files before committing. Address any issues it flags as bugs, security vulnerabilities, or logic errors.
3. **User review** — Present a summary of changes to the user and wait for explicit approval before committing.
9 changes: 9 additions & 0 deletions Examples/Xunit/DomainDrivenDesignSamplesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ public static Result<CustomerId> TryCreate(Guid value, string? fieldName = null)
? Error.Validation("Customer ID cannot be empty", fieldName ?? "customerId")
: Result.Success(new CustomerId(value));

public static Result<CustomerId> TryCreate(string? value, string? fieldName = null) =>
throw new NotImplementedException();

public static Result<CustomerId> TryCreate(Guid? value) =>
value.ToResult(Error.Validation("Customer ID cannot be empty"))
.Ensure(v => v != Guid.Empty, Error.Validation("Customer ID cannot be empty"))
Expand All @@ -40,6 +43,9 @@ public static Result<OrderId> TryCreate(Guid value, string? fieldName = null) =>
? Error.Validation("Order ID cannot be empty", fieldName ?? "orderId")
: Result.Success(new OrderId(value));

public static Result<OrderId> TryCreate(string? value, string? fieldName = null) =>
throw new NotImplementedException();

public static Result<OrderId> TryCreate(Guid? value) =>
value.ToResult(Error.Validation("Order ID cannot be empty"))
.Ensure(v => v != Guid.Empty, Error.Validation("Order ID cannot be empty"))
Expand Down Expand Up @@ -337,6 +343,9 @@ public static Result<Temperature> TryCreate(decimal value, string? fieldName = n
.Map(v => new Temperature(v));
}

public static Result<Temperature> TryCreate(string? value, string? fieldName = null) =>
throw new NotImplementedException();

public static Temperature FromCelsius(decimal celsius) => new(celsius);
public static Temperature FromFahrenheit(decimal fahrenheit) => new((fahrenheit - 32) * 5 / 9);
public static Temperature FromKelvin(decimal kelvin) => new(kelvin - 273.15m);
Expand Down
1 change: 1 addition & 0 deletions Trellis.Asp/generator/ScalarValueJsonConverterGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
/// </code>
/// </example>
[Generator(LanguageNames.CSharp)]
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public class ScalarValueJsonConverterGenerator : IIncrementalGenerator
{
private const string GenerateAttributeName = "GenerateScalarValueConvertersAttribute";
Expand Down
7 changes: 7 additions & 0 deletions Trellis.Asp/src/Validation/PropertyNameAwareConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ internal sealed class PropertyNameAwareConverter<T> : JsonConverter<T?>
private readonly JsonConverter<T?> _innerConverter;
private readonly string _propertyName;

/// <summary>
/// Tells System.Text.Json to call <see cref="Read"/> even when the JSON token is <c>null</c>.
/// Without this, the serializer bypasses the converter for null tokens on reference-type
/// properties, preventing the inner converter's null-token validation from firing.
/// </summary>
public override bool HandleNull => true;

/// <summary>
/// Creates a new property-name-aware wrapper converter.
/// </summary>
Expand Down
7 changes: 7 additions & 0 deletions Trellis.Asp/src/Validation/ScalarValueJsonConverterBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ public abstract class ScalarValueJsonConverterBase<TResult, TValue, TPrimitive>
where TValue : class, IScalarValue<TValue, TPrimitive>
where TPrimitive : IComparable
{
/// <summary>
/// Tells System.Text.Json to call <see cref="JsonConverter{T}.Read"/> even when the JSON
/// token is <c>null</c>. Without this, the serializer bypasses the converter for null tokens
/// on reference-type results, preventing <see cref="OnNullToken"/> from firing.
/// </summary>
public override bool HandleNull => true;

/// <summary>
/// Returns the result when a JSON null token is read.
/// </summary>
Expand Down
9 changes: 9 additions & 0 deletions Trellis.Asp/tests/MaybeModelBinderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public static Result<UserId> TryCreate(Guid value, string? fieldName = null)
return Error.Validation("UserId cannot be empty.", field);
return new UserId(value);
}

public static Result<UserId> TryCreate(string? value, string? fieldName = null) =>
throw new NotImplementedException();
}

public class ProductCode : ScalarValueObject<ProductCode, string>, IScalarValue<ProductCode, string>
Expand Down Expand Up @@ -60,6 +63,9 @@ public static Result<Quantity> TryCreate(int value, string? fieldName = null)
return Error.Validation("Quantity cannot exceed 1000.", field);
return new Quantity(value);
}

public static Result<Quantity> TryCreate(string? value, string? fieldName = null) =>
throw new NotImplementedException();
}

public class Price : ScalarValueObject<Price, decimal>, IScalarValue<Price, decimal>
Expand All @@ -73,6 +79,9 @@ public static Result<Price> TryCreate(decimal value, string? fieldName = null)
return Error.Validation("Price cannot be negative.", field);
return new Price(value);
}

public static Result<Price> TryCreate(string? value, string? fieldName = null) =>
throw new NotImplementedException();
}

#endregion
Expand Down
12 changes: 12 additions & 0 deletions Trellis.Asp/tests/MaybeScalarValueJsonConverterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ public static Result<Age> TryCreate(int value, string? fieldName = null)
return Error.Validation("Age must be realistic.", field);
return new Age(value);
}

public static Result<Age> TryCreate(string? value, string? fieldName = null) =>
throw new NotImplementedException();
}

public class Percentage : ScalarValueObject<Percentage, decimal>, IScalarValue<Percentage, decimal>
Expand All @@ -60,6 +63,9 @@ public static Result<Percentage> TryCreate(decimal value, string? fieldName = nu
return Error.Validation("Percentage cannot exceed 100.", field);
return new Percentage(value);
}

public static Result<Percentage> TryCreate(string? value, string? fieldName = null) =>
throw new NotImplementedException();
}

public class ItemId : ScalarValueObject<ItemId, Guid>, IScalarValue<ItemId, Guid>
Expand All @@ -73,6 +79,9 @@ public static Result<ItemId> TryCreate(Guid value, string? fieldName = null)
return Error.Validation("ItemId cannot be empty.", field);
return new ItemId(value);
}

public static Result<ItemId> TryCreate(string? value, string? fieldName = null) =>
throw new NotImplementedException();
}

public enum ProcessingMode
Expand All @@ -90,6 +99,9 @@ public static Result<ProcessingModeVO> TryCreate(ProcessingMode value, string? f
value == ProcessingMode.Unknown
? Error.Validation("Processing mode is required.", fieldName ?? "processingMode")
: new ProcessingModeVO(value);

public static Result<ProcessingModeVO> TryCreate(string? value, string? fieldName = null) =>
throw new NotImplementedException();
}

#endregion
Expand Down
9 changes: 9 additions & 0 deletions Trellis.Asp/tests/ModelBindingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ public static Result<UserId> TryCreate(Guid value, string? fieldName = null)
return Error.Validation("UserId cannot be empty.", field);
return new UserId(value);
}

public static Result<UserId> TryCreate(string? value, string? fieldName = null) =>
throw new NotImplementedException();
}

public class ProductCode : ScalarValueObject<ProductCode, string>, IScalarValue<ProductCode, string>
Expand Down Expand Up @@ -58,6 +61,9 @@ public static Result<Quantity> TryCreate(int value, string? fieldName = null)
return Error.Validation("Quantity cannot exceed 1000.", field);
return new Quantity(value);
}

public static Result<Quantity> TryCreate(string? value, string? fieldName = null) =>
throw new NotImplementedException();
}

public class Price : ScalarValueObject<Price, decimal>, IScalarValue<Price, decimal>
Expand All @@ -71,6 +77,9 @@ public static Result<Price> TryCreate(decimal value, string? fieldName = null)
return Error.Validation("Price cannot be negative.", field);
return new Price(value);
}

public static Result<Price> TryCreate(string? value, string? fieldName = null) =>
throw new NotImplementedException();
}

#endregion
Expand Down
Loading
Loading