Skip to content

Value Objects

Preview Value Objects Reviewed 2026-09-19 purview-dev/value-objects c-sharp csharp ddd domain-driven-design dotnet dto entity-framework immutable json roslyn scalar serialization single-case-union source-generator validation value-object value-objects zodsharp

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, and the diagnostic analyzer.

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.
var email = EmailAddress.Create(" [email protected] ");
// 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]\""

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 via Hydrate, skipping validation.
  • Strict – reconstructs via Create, 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, 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("[email protected]");
var result = EmailAddressSchema.Validate(email); // ValidationResult<EmailAddress>

Because EmailAddress is both [Scalar] and [ZodSchema], the generated Create also validates the constructed instance through EmailAddressSchemaEmailAddress.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 JSON columns.
  • Value-Object-Design.md – where validation lives and the Create/Hydrate split.
  • ZodSharp-Validation.md – validating value objects with Purview.ZodSharp.
  • The src/src/Sample and src/src/ZodSharpSample projects for runnable examples.