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 (explicit extraction for nullable scalars), 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\""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.
[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, or the more specific
VO1028–VO1030 when the declaration also asserts nullability).
// 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 nullable. For a reference type it adds the nullable
annotation; for a non-nullable value type it wraps the type in Nullable<T>. 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
[Scalar<DateTimeOffset>(Nullable = true)] // emits `public DateTimeOffset? Value { get; init; }`
public readonly partial record struct Handle { }Nullable value types can also be written directly, for example [Scalar<int?>] or [Scalar(typeof(int?))].
Nullable = true is idempotent for an already-nullable value type, so the two spellings are equivalent — and,
for an otherwise identical declaration, the automatic form generates the same members as the manual
[Scalar] form with a nullable property.
A nullable scalar round-trips JSON null: [Scalar<string>(Nullable = true)], [Scalar<int?>], and
[Scalar<DateTimeOffset>(Nullable = true)] 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));
}
}A nullable scalar also exposes Null(), which creates the “unknown” value without validating —
Nickname.Null() is equivalent to Nickname.Hydrate(null). Use Create(null) instead when null is
input that should go through normalization and validation.
A nullable scalar — and any reference-backed scalar, whose Empty/Hydrate(null!) value is null even
when the domain contract says non-null — exposes HasValue, which reports whether the underlying value
is null without validating or normalizing: Nickname.Null().HasValue is false,
Nickname.Create("x").HasValue is true, and Nickname.Empty.HasValue is also false. A
non-nullable value-type scalar (for example [Scalar<Guid>]) has no HasValue; it is always present.
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 on their own; they cannot be combined. Declaring the generator-owned property is reported as an error, with a specific rule for each nullability conflict:
| Rule | When |
|---|---|
VO1022 |
An automatic form declares a member with the generator-owned name (the plain duplicate). |
VO1028 |
Nullable = true is combined with a declared Value: nullability has two sources. |
VO1029 |
Nullable = false is explicit but the declared Value is nullable. |
VO1030 |
The declared Value type does not match the scalar's underlying type. |
VO1028 has a code fix offering three repairs: remove the declared property; convert the attribute to
the manual [Scalar] form; or remove only Nullable = true. Other properties are allowed in either
form; only the scalar property (and the interface's Value forwarding) is reserved.
When a nullable scalar is used as an Entity Framework Core property, declare the property as T?; see
Entity Framework. Using it as a
non-nullable entity property is reported as VO1031.
Equality comparisons work against the underlying value and against null, in either operand order:
NullableString value = NullableString.Null();
_ = value == null; // true
_ = null == value; // true
_ = value != null; // false
NullableString name = NullableString.Create("value");
_ = name == "value"; // true
_ = "value" == name; // true
_ = name == null; // falsedefault(T), Empty, Null(), and Create(null) all compare equal to null, symmetrically. The
comparison reads the underlying value directly — it never calls Create, so it neither normalizes nor
validates.
is null does not work for a non-nullable struct (CS0037); use == null/!= null. A nullable or
reference-backed scalar also exposes HasValue, which inspects the underlying value without constructing
one (and therefore without normalizing or validating), and is the cheap null check. Extraction is
explicit for a nullable scalar, so write .Value or a cast; a non-nullable scalar keeps the
implicit conversion, so string raw = email; continues to work:
string rawNullable = (string)name; // or name.Value
string rawRequired = email; // implicit for a non-nullable scalarThe conversion from the underlying value remains implicit for both, so
NullableString value = "value"; still creates through Create. A non-nullable scalar does not
support == null: a value-type scalar has no conversion from null, and a non-nullable
reference-backed scalar is ambiguous between its primitive operator and the synthesized equality. Check
!value.HasValue when you need to inspect the wrapped value.
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 notstringorbool), the value object implementsIFormattableand forwardsToString(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.
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, andIEquatable<TSelf>for every shape that does not already synthesize it.ISpanFormattableandIUtf8SpanFormattablewhen the underlying type implements them, forwardingTryFormat.IParsable<TSelf>,ISpanParsable<TSelf>, andIUtf8SpanParsable<TSelf>when the underlying type implements them.Parsevalidates throughCreate, andTryParsereturnsfalse(throughTryCreate) 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 _); // falseDetection 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.
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.
GenerateImplicitToPrimitive keeps its name for compatibility. It emits an explicit extraction operator
for a nullable scalar (write (TValue)value or .Value) and an implicit one for a non-nullable scalar;
GenerateImplicitFromPrimitive controls the implicit creation conversion.
Every [ValueObjectDefaults] option also has a matching ValueObjectsDefault<Property> MSBuild property, so a
repository can configure the defaults once in Directory.Build.props without adding an attribute to each
project. Unset properties fall through to the attribute and then to the built-in default.
<PropertyGroup>
<ValueObjectsDefaultTrim>true</ValueObjectsDefaultTrim>
<ValueObjectsDefaultCasing>LowerInvariant</ValueObjectsDefaultCasing>
<ValueObjectsDefaultGenerateJsonConverter>false</ValueObjectsDefaultGenerateJsonConverter>
</PropertyGroup>| Property | Allowed values |
|---|---|
ValueObjectsDefaultGenerateJsonConverter |
true / false |
ValueObjectsDefaultGenerateComparable |
true / false |
ValueObjectsDefaultGenerateComparisonOperators |
true / false |
ValueObjectsDefaultGenerateEnumProperties |
true / false |
ValueObjectsDefaultGenerateImplicitFromPrimitive |
true / false |
ValueObjectsDefaultGenerateImplicitToPrimitive |
true / false |
ValueObjectsDefaultGenerateEmpty |
true / false |
ValueObjectsDefaultGenerateConstructor |
true / false |
ValueObjectsDefaultEFMapping |
ComplexType, Json, None |
ValueObjectsDefaultGenerateEFConverter |
true / false |
ValueObjectsDefaultGenerateEFComparer |
true / false |
ValueObjectsDefaultGenerateEFValueGenerator |
true / false |
ValueObjectsDefaultTrim |
true / false |
ValueObjectsDefaultCasing |
None, LowerInvariant, UpperInvariant |
ValueObjectsDefaultDeserializationMode |
Hydrate, Strict |
ValueObjectsDefaultZodSchemaMode |
InAdditionToHooks, InsteadOfHooks |
The packaged targets turn the properties into a build-generated assembly-level [ValueObjectDefaults] under
obj/, so the generator and analyzer read them exactly like an attribute you declared yourself. The package
declares the properties in its build props, so IDEs offer them while you edit the project file; the declarations
are conditional and never overwrite a value set earlier. An option named explicitly on your own
[ValueObjectDefaults] wins over the MSBuild value, and a per-type or per-member option wins over both. An
unrecognised value fails the build instead of silently falling back to the built-in default.
Because the attribute is compiled into the assembly, the defaults a project generates with are part of its public contract: a value object declared in a referenced assembly keeps the defaults its own project built it with.
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.
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.