Value Objects
Getting Started
Section titled “Getting Started”This guide walks through modeling DTOs and domain values with Purview.ValueObjects.
1. Reference the package
Section titled “1. Reference the package”dotnet add package Purview.ValueObjectsThe package includes the runtime contracts, the source generator, and the diagnostic analyzer.
2. Scalar value objects
Section titled “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)– returnsfalseinstead of throwing.EmailAddress.Empty– a default instance.- Equality, comparison,
CompareTo,ToString, implicit conversions, and a JSON converter.
// email.Value == "[email protected]"
bool ok = EmailAddress.TryCreate("not-an-email", out _);// ok == false
string json = System.Text.Json.JsonSerializer.Serialize(email);// json == "\"[email protected]\""3. Complex value objects
Section titled “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
Section titled “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
Section titled “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 viaHydrate, skipping validation.Strict– reconstructs viaCreate, re-running validation.
[Scalar(DeserializationMode = ValueObjectDeserializationMode.Strict)]public readonly partial record struct EmailAddress{ // ...}6. Assembly-level defaults
Section titled “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, 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
Section titled “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 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.
Next steps
Section titled “Next steps”Entity-Framework.md– mapping value objects to EF JSON columns.Value-Object-Design.md– where validation lives and theCreate/Hydratesplit.ZodSharp-Validation.md– validating value objects with Purview.ZodSharp.- The
src/src/Sampleandsrc/src/ZodSharpSampleprojects for runnable examples.