Skip to content

Latest commit

 

History

History
318 lines (242 loc) · 12.8 KB

Getting-Started.md

File metadata and controls

318 lines (242 loc) · 12.8 KB

Getting Started

This guide walks through modeling DTOs and domain values with Purview.ValueObjects.

1. Reference the package

dotnet add package Purview.ValueObjects

The package includes the runtime contracts, the source generator, the diagnostic analyzer, and code fixes for the analyzer's diagnostics (for example VO1001 offers Add 'partial' modifier).

2. Scalar value objects

A scalar value object wraps a single primitive value. It is the F#-style single-case union for C#.

using Purview.ValueObjects.Serialization;

[Scalar]
public readonly partial record struct EmailAddress
{
    public string Value { get; }

    static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!;

    static partial void OnValidate(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new ArgumentException("Email is required.", nameof(value));

        if (!value.Contains('@', StringComparison.Ordinal))
            throw new ArgumentException("Invalid email format.", nameof(value));
    }
}

The generator adds:

  • EmailAddress.Create(string) – normalizes, validates, then constructs. Throws on invalid input.
  • EmailAddress.Hydrate(string) – constructs without re-validating (for persisted data).
  • EmailAddress.TryCreate(string, out EmailAddress) – returns false instead of throwing.
  • EmailAddress.Empty – a default instance.
  • Equality, comparison, CompareTo, ToString, implicit conversions, and a JSON converter.
  • The underlying value's standard interfaces (see Formatting and Standard interfaces) when it implements them.
var email = EmailAddress.Create("  Demo@Example.COM  ");
// email.Value == "demo@example.com"

bool ok = EmailAddress.TryCreate("not-an-email", out _);
// ok == false

string json = System.Text.Json.JsonSerializer.Serialize(email);
// json == "\"demo@example.com\""

Built-in string normalization

For the common trim-and-canonicalize case, the generator can apply the normalization for you, so the scalar does not need an OnNormalize hook:

[Scalar<string>(Trim = true, Casing = StringCasing.LowerInvariant)]
public readonly partial record struct EmailAddress { }

Create trims and then applies the casing before validation; Hydrate is left untouched. See Value object design.

Automatic vs manual underlying property

[Scalar] is the manual form: you declare the property. [Scalar<T>] and [Scalar(typeof(T))] are the automatic form: the generator declares it. They are mutually exclusive for a given type — declaring the property with an automatic form is an error (VO1022).

// Automatic: the generator emits `public Guid Value { get; init; }`.
[Scalar<Guid>]                     // or [Scalar(typeof(Guid))]
public readonly partial record struct InstallationId { }

// Manual: you declare the property.
[Scalar]
public readonly partial record struct InstallationId
{
    public Guid Value { get; init; }
}

Use Nullable when the automatic property should be a nullable reference type. Nullable reference types cannot be generic attribute arguments ([Scalar<string?>] does not compile — CS8970) or typeof operands (typeof(string?) does not compile — CS8639), so express nullability on the attribute:

[Scalar<string>(Nullable = true)]              // emits `public string? Value { get; init; }`
[Scalar(typeof(string), Nullable = true)]      // same
public readonly partial record struct Handle { }

Nullable value types are written directly, for example [Scalar<int?>] or [Scalar(typeof(int?))].

A nullable scalar round-trips JSON null: [Scalar<string>(Nullable = true)] and [Scalar<int?>] deserialize null and serialize back to null. A non-nullable reference scalar still rejects null with a JsonException. Validation is yours to write, so make it null-tolerant — for a value that must be null or non-whitespace:

[Scalar<string>(Nullable = true)]
public readonly partial record struct Nickname
{
    static partial void OnValidate(string? value)
    {
        if (value is not null && string.IsNullOrWhiteSpace(value))
            throw new ArgumentException("Value must be null or non-whitespace.", nameof(value));
    }
}

The manual form is the more explicit option when you prefer the type and nullability to appear in a property declaration. Both forms are supported, and neither is reported as a diagnostic. Other properties are allowed in either form; only the scalar property (and the interface's Value forwarding) is reserved.

Formatting

The generated value object mirrors the underlying value's formatting overloads per type, so it exposes the same options as the property it wraps.

  • When the underlying value implements System.IFormattable (decimal, Guid, DateTime, DateTimeOffset, TimeSpan, the numeric types, char, and enums, but not string or bool), the value object implements IFormattable and forwards ToString(string? format, IFormatProvider? formatProvider).
  • When the underlying value declares a format-only ToString(string? format) (Guid, DateTime, DateTimeOffset, TimeSpan, the numeric types, and enums), that overload is forwarded too.
[Scalar<decimal>]
public readonly partial record struct Amount { }

var amount = Amount.Create(1234.5m);

amount.ToString("N2", CultureInfo.InvariantCulture);           // "1,234.50"
amount.ToString("N2");                                         // current culture
string.Format(CultureInfo.InvariantCulture, "{0:N2}", amount); // "1,234.50"
$"{amount:N2}"                                                 // current culture, e.g. "1,234.50"

A null underlying value formats as the empty string, and a scalar whose underlying type declares neither overload (string, bool) is unchanged. If you declare a matching overload yourself, the generator leaves your implementation in place.

Standard interfaces

The generated value object also implements the same standard interfaces as the type it wraps, so it behaves like that type in equality, sorting, formatting, and parsing contexts:

  • IEquatable<TValue> when the underlying type implements it, and IEquatable<TSelf> for every shape that does not already synthesize it.
  • ISpanFormattable and IUtf8SpanFormattable when the underlying type implements them, forwarding TryFormat.
  • IParsable<TSelf>, ISpanParsable<TSelf>, and IUtf8SpanParsable<TSelf> when the underlying type implements them. Parse validates through Create, and TryParse returns false (through TryCreate) for input that does not parse or fails domain validation.
[Scalar<Guid>]
public readonly partial record struct InstallationId { }

var id = InstallationId.Parse("2f8a...", CultureInfo.InvariantCulture); // Create(...)
InstallationId.TryParse("not-a-guid", null, out _);                     // false

Detection is per type and per target framework: a scalar only gets the interfaces its underlying type actually implements (string is parsable but not span-formattable; an enum is span-formattable but not parsable). If you declare one of the generated members yourself, the generator leaves that interface alone. Numeric and arithmetic interfaces, IConvertible, and collection interfaces are intentionally not mirrored.

3. Complex value objects

A complex value object wraps multiple members and validates them together.

[ValueObject]
public readonly partial record struct Money
{
    public decimal Amount { get; }

    public CurrencyCode Currency { get; }

    partial void OnValidate(decimal amount, CurrencyCode currency)
    {
        if (amount < 0)
            throw new ArgumentOutOfRangeException(nameof(amount), "Amount cannot be negative.");

        if (currency == CurrencyCode.Empty)
            throw new ArgumentException("Currency cannot be empty.", nameof(currency));
    }
}

4. Contextual value objects

When validity depends on the owning instance, implement IContextualValueObject<TSelf, TValue, TOwner>:

[Scalar]
public readonly partial record struct OrderStatus
    : IContextualValueObject<OrderStatus, OrderStatusCode, Order>
{
    public OrderStatusCode Value { get; }

    public static OrderStatus Create(OrderStatusCode value, in ValueObjectContext<Order> context)
    {
        var current = context.Owner.Status.Value;
        return IsValidTransition(current, value)
            ? new(value)
            : throw new InvalidOperationException($"Invalid transition {current} -> {value}");
    }
}

ValueObjectContext<TOwner> carries the owner instance, the member name being assigned, and an optional reason.

5. JSON serialization

Scalar value objects serialize as their underlying value. The generator emits a [JsonConverter] per value object, so they round-trip with default options. For a shared options instance, register ScalarJsonConverterFactory:

var options = new JsonSerializerOptions();
options.Converters.Add(new ScalarJsonConverterFactory());

ValueObjectDeserializationMode controls which factory deserialization uses:

  • Hydrate (default) – reconstructs via Hydrate, skipping validation.
  • Strict – reconstructs via Create, re-running validation.
[Scalar(DeserializationMode = ValueObjectDeserializationMode.Strict)]
public readonly partial record struct EmailAddress
{
    // ...
}

6. Assembly-level defaults

Use [ValueObjectDefaults] to set generic defaults for the whole assembly. Every option that can be set on [Scalar]/[ValueObject] can be defaulted here (GenerateJsonConverter, GenerateComparable, GenerateComparisonOperators, GenerateEnumProperties, GenerateImplicitFromPrimitive, GenerateImplicitToPrimitive, GenerateEmpty, GenerateConstructor, GenerateEFConverter, GenerateEFComparer, EFMapping, DeserializationMode, and ZodSchemaMode):

[assembly: ValueObjectDefaults(
    GenerateConstructor = false,
    GenerateJsonConverter = false,
    ZodSchemaMode = ZodSchemaMode.InsteadOfHooks
)]

Assembly defaults apply to every value object in the assembly; an option explicitly set on an individual [Scalar]/[ValueObject] attribute always overrides it.

7. Validate with ZodSharp

Purview.ZodSharp is a C# port of Zod that can validate value objects. Add [ZodSchema] (plus DataAnnotations on the underlying value) to generate a zero-allocation validator, or build a schema for the raw value with Z.String(), Z.Number(), Z.Enum():

using System.ComponentModel.DataAnnotations;
using ZodSharp;

[Scalar]
[ZodSchema]
public readonly partial record struct EmailAddress
{
    [EmailAddress]
    [StringLength(254, MinimumLength = 3)]
    public string Value { get; }

    // ...
}

var email = EmailAddress.Create("demo@example.com");
var result = EmailAddressSchema.Validate(email);   // ValidationResult<EmailAddress>

Because EmailAddress is both [Scalar] and [ZodSchema], the generated Create also validates the constructed instance through EmailAddressSchema — EmailAddress.Create("not-an-email") throws a ZodException. Use ZodSchemaMode.InsteadOfHooks on the attribute to run the schema instead of the OnValidate hook.

For rules that must observe the value object rather than its Value, map a [ZodRule(typeof(...))] attribute to a rule constrained to the value object. A rule written for the underlying value is adapted automatically (via the generator-emitted ScalarRuleAdapter) when applied to a [Scalar] type. The rule's Code/Origin flow through Create unchanged. See Custom rules on scalars and Reusing a normal rule for a scalar.

In ASP.NET Core, Purview.ZodSharp.AspNetCore converts those ZodExceptions into standard Problem Details responses — combine ValueObjectDeserializationMode.Strict with AddZodSharpProblemDetails() + UseExceptionHandler() so invalid request bodies return HttpValidationProblemDetails automatically. See the src/src/ZodSharp.AspNetCoreSample project.

See ZodSharp-Validation.md and the src/src/ZodSharpSample project.

Next steps

  • Entity-Framework.md – mapping value objects to EF Core: automatic ConfigureValueObjects(), keys, generated key values, query filters, manual control, and schema/migration notes.
  • Value-Object-Design.md – where validation lives, the Create/Hydrate split, and how value objects sit in a domain model next to entities.
  • ZodSharp-Validation.md – validating value objects with Purview.ZodSharp.
  • The src/src/Sample, src/src/EFDomainSample.Persistence (domain + persistence split), and src/src/ZodSharpSample projects for runnable examples.