This guide walks through modeling DTOs and domain values with Purview.ValueObjects.
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).
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)– returnsfalseinstead of throwing.EmailAddress.Empty– a default instance.- Equality, comparison,
CompareTo,ToString, implicit conversions, and a JSON converter.
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\""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));
}
}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.
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 viaHydrate, skipping validation.Strict– reconstructs viaCreate, re-running validation.
[Scalar(DeserializationMode = ValueObjectDeserializationMode.Strict)]
public readonly partial record struct EmailAddress
{
// ...
}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.
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.
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.
Entity-Framework.md– mapping value objects to EF Core: automaticConfigureValueObjects(), keys, generated key values, query filters, manual control, and schema/migration notes.Value-Object-Design.md– where validation lives, theCreate/Hydratesplit, 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), andsrc/src/ZodSharpSampleprojects for runnable examples.