# Value Objects > Source-generated scalar and complex value objects for .NET. F#-style single-case types for C#. Mark a partial struct or class with [Scalar] or [ValueObject] and an incremental source generator produces Create/Hydrate factories with normalization and validation, equality, comparison, implicit conversions, JSON converters, and contextual creation. Value objects serialize as their underlying value, map cleanly onto Entity Framework JSON columns, and can be validated with Purview.ZodSharp schemas. - Repository: https://github.com/purview-dev/value-objects - Package: https://www.nuget.org/packages/Purview.ValueObjects - Project page: https://purview.dev/projects/value-objects/ - Documentation: https://purview.dev/docs/value-objects/ - Full machine-readable content: https://purview.dev/projects/value-objects/llms-full.txt # Getting Started This guide walks through modeling DTOs and domain values with `Purview.ValueObjects`. ## 1. Reference the package ```text 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#. ```csharp 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. ```csharp 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\"" ``` ## 3. Complex value objects A complex value object wraps multiple members and validates them together. ```csharp [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`: ```csharp [Scalar] public readonly partial record struct OrderStatus : IContextualValueObject { public OrderStatusCode Value { get; } public static OrderStatus Create(OrderStatusCode value, in ValueObjectContext context) { var current = context.Owner.Status.Value; return IsValidTransition(current, value) ? new(value) : throw new InvalidOperationException($"Invalid transition {current} -> {value}"); } } ``` `ValueObjectContext` 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`: ```csharp 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. ```csharp [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`): ```csharp [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](https://www.nuget.org/packages/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()`: ```csharp 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 ``` 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 `ZodException`s 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. --- # Entity Framework `Purview.ValueObjects` integrates with Entity Framework Core by source-generating the mapping members into your project **when** `Microsoft.EntityFrameworkCore` is referenced. Scalar value objects convert to their underlying primitive column; complex value objects map as EF Core complex types (EF Core 8+) or JSON columns. The runtime package stays free of Entity Framework dependencies: all Entity Framework code is generated into the consuming project, and everything below is opt-in per feature with an opt-out hierarchy (`DisableValueObjectsEFGeneration` MSBuild property → assembly defaults → per-type options). ## Prerequisite Reference Entity Framework Core (the integration activates automatically when the project references it): ```text dotnet add package Microsoft.EntityFrameworkCore ``` For the examples below, also add a provider such as SQLite: ```text dotnet add package Microsoft.EntityFrameworkCore.Sqlite ``` The generated integration is compiled and tested against EF Core **7, 9, and 10** (EF Core 7 needs `net8.0`), so version-specific API differences are resolved by the generator rather than surfacing in your build. EF Core 7 has no complex-type mapping, which is the one feature difference: a complex value object reports `VO1019` there. See [Notes](https://github.com/purview-dev/value-objects/blob/main/docs#notes) for the full version matrix. ## Automatic mapping Add one call in `OnModelCreating`. The generated `ConfigureValueObjects` extension is emitted into your project in the `Microsoft.EntityFrameworkCore` namespace (the same namespace as `ModelBuilder`), so no extra `using` is required when that namespace is already imported, and it maps every value object it finds on your entities: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ConfigureValueObjects(); } ``` ### Value objects in referenced assemblies The mapping is not limited to value objects declared in the same project. When a referenced assembly (for example a shared domain models project) references `Microsoft.EntityFrameworkCore`, the generator emits each of its value objects an `EF` nested class and an `IEFScalarValueObject`/`IEFComplexValueObject` marker interface. Your project's generated registry discovers those markers and maps the shared value objects just like locally-declared ones — so `EmailAddress` from a `SharedModels` assembly is automatically converted on your entities: ```csharp // SharedModels assembly (references Microsoft.EntityFrameworkCore): [Scalar] public readonly partial record struct EmailAddress { public string Value { get; } } // Consumer assembly (references Microsoft.EntityFrameworkCore + SharedModels): protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ConfigureValueObjects(); // maps SharedModels.EmailAddress too } ``` A provider assembly that only *defines* value objects (and does not own any `DbContext`) can opt out of emitting its own registry so consumer projects aren't affected by duplicate `ValueObjectEFExtensions`/ `ValueObjectModelCustomizer` types in the `Microsoft.EntityFrameworkCore` namespace: ```xml true ``` #### Value objects in assemblies that do not reference EF Core The declaring assembly does not need to reference `Microsoft.EntityFrameworkCore`. A domain project that must stay free of Entity Framework dependencies (for example a model assembly shipped as a WASM contract) emits no `EF` members and no marker interfaces. The consumer still discovers those value objects — from their `[Scalar]`/`[ValueObject]` attributes — and generates the converters and comparers **inline** in its own registry, so all EF code is produced in the consuming project: ```csharp // Domain/Models assembly (references Purview.ValueObjects only — no Entity Framework): [Scalar] public readonly partial record struct TenantId { public Guid Value { get; } // Persistence assembly (references Microsoft.EntityFrameworkCore + Domain/Models): services.AddDbContextFactory(options => options.UseSqlite("Data Source=shop.db").UseValueObjects()); // maps Domain/Models.TenantId inline ``` The inline conversion mirrors what the per-type `EF` members emit: scalars convert via `vo => vo.Value` / `T.Hydrate(v)` for the provider-to-model path, JSON-mapped complex value objects serialize to a string column, and complex-type-mapped value objects map as EF Core complex types. EF uses the hydrate path even when the value object's `Create(...)` factory is strict, so query parameterization and persistence remain safe for provider values such as `Guid`, strings, enums, and other EF-mappable primitives. Both paths build their converter from the generated `ValueObjectConverter`, which accepts either the value object or an already provider-shaped value. > **Limitation.** Referenced value objects are discovered through their marker interfaces when the declaring > assembly references EF Core, or through their attributes when it does not. A complex value object in > another assembly is only discovered when it opted into at least one EF feature (a comparer, a complex-type > mapping, or a JSON column converter); a complex type with `EFMapping` set but both `GenerateEFComparer = false` > and no JSON mapping is not auto-discovered across assemblies — configure it manually on the entity. ### Configure from DI registration (`AddDbContext`, `AddDbContextFactory`, `AddDbContextPool`) Instead of overriding `OnModelCreating` per context, chain the generated `UseValueObjects()` extension on the options builder when you register the context. It registers a generated `ModelCustomizer` that runs `ConfigureValueObjects` automatically after `OnModelCreating`: ```csharp services.AddDbContextFactory(options => options.UseSqlite("Data Source=shop.db").UseValueObjects()); // or: services.AddDbContext(options => options.UseSqlServer(connectionString).UseValueObjects()); ``` This works for `AddDbContext`, `AddDbContextFactory`, `AddDbContextPool`, and manual construction (add `UseValueObjects()` to the options builder there too). With it, no `OnModelCreating` override is required — the mapping applies to every context created from that registration. What the mapping does: - **Scalar value objects** (`[Scalar]`) map to their underlying primitive via a per-value-object generated converter class (a `ValueObjectConverter`, exposed as `{Type}.EF.Converter`) + `ValueComparer`. The provider-to-model conversion uses `Hydrate(...)` so raw provider values can be materialized safely from queries and persisted rows, and the converter accepts either the value object or an already provider-shaped value so comparisons against the underlying primitive translate. An enum-backed scalar converts through the enum's **integral** type (for example `ValueConverter`), because leaving the enum as the provider type makes Entity Framework Core compose its own enum-to-number converter with the generated one — and the composite loses the provider tolerance. `EmailAddress` stores as a `TEXT` column. - **Complex value objects** (`[ValueObject]`) map as **EF Core complex types** (EF Core 8+) by default, producing a column per member — including nested scalar value objects (e.g. `Money.Currency` converts to its primitive). - Complex value objects with `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` map to a single JSON column using the generated JSON converter. ## Queries — no `.Value` required Because scalar value objects convert to their underlying primitive column, queries compare the value object type directly and translate to SQL: ```csharp EmailAddress email = EmailAddress.Create("demo@example.com"); var customers = await db.Customers .Where(c => c.Email == email) // translates to [email] = @p .ToListAsync(); ``` Complex value objects map as complex types, so nested members are queryable too: ```csharp var orders = await db.Orders .Where(o => o.Total.Amount > 20m) // o.Total.Amount > 20.0 .Where(o => o.Total.Currency == CurrencyCode.Create("USD")) .ToListAsync(); ``` > **Comparing to a raw primitive.** A query may compare a scalar value object property to either the value > object **or** its raw underlying value — both translate: > > ```csharp > EmailAddress email = "demo@example.com"; // value object (implicit conversion) > .Where(c => c.Email == email) > > .Where(c => c.Email == "demo@example.com") // raw underlying string > .Where(m => m.Id == guid) // raw underlying Guid > ``` > > This works because every generated converter is built from a per-value-object `ValueObjectConverter` type that > accepts either shape (enum-backed scalars convert through the enum's integral type). Entity Framework Core > hands the raw provider value to a converted property's converter in this case, and its built-in converter > coerces that value with `Convert.ChangeType`, which throws for provider types that do not implement > `IConvertible` (`Guid`, `DateTimeOffset`, `TimeSpan`, `DateOnly`, `TimeOnly`) or cannot be converted at all > (strings). See [dotnet/efcore#32030](https://github.com/dotnet/efcore/issues/32030). > > **Compiled models.** Entity Framework Core's design-time generator rebuilds a converter as > `new ValueConverter(…)` — the built-in type — unless the converter exposes a > `JsonValueReaderWriter`-taking constructor and a `JsonReaderWriter` property. Every generated converter does, > so a compiled model (`dotnet ef dbcontext optimize`) keeps the same provider tolerance. That detection is an > undocumented Entity Framework Core implementation detail: if it ever changes, compiled models silently fall > back to the built-in converter, and only raw-primitive comparisons are affected. ## Keys, foreign keys, and indexes A value object key needs no special handling: the generated converter maps the value object to its primitive column, so a key or foreign key typed as a value object persists as that primitive. Convention then makes `{Type}Id` the primary key, exactly as it would for a `Guid` or `string`. ```csharp sealed class Customer { public CustomerId Id { get; set; } // primary key, stored as uniqueidentifier public TenantId TenantId { get; set; } // foreign key to Tenant.Id public Tenant Tenant { get; set; } = default!; } sealed class Tenant { public TenantId Id { get; set; } public TenantKey Key { get; set; } // a scalar value object stored as a single column } ``` Column facets are configured on the value object property and apply to the converted column, so keep configuring them the way you would on a primitive: ```csharp public sealed class TenantConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.Property(entity => entity.Id).ValueGeneratedNever(); // when the domain owns the key builder.Property(entity => entity.Key).HasMaxLength(100).IsRequired(); builder.HasIndex(entity => new { entity.TenantId, entity.Key }).IsUnique(); } } ``` Because the converter is expression-based, indexes, unique constraints, and comparisons over value object properties behave like their primitive equivalents — including in composite indexes and `HasQueryFilter`. ## Generating key values A Guid-backed scalar value object can generate its own key values. Opt in per type, or once for the whole assembly: ```csharp [Scalar(GenerateEFValueGenerator = true)] public readonly partial record struct CustomerId { public Guid Value { get; } } // or: [assembly: ValueObjectDefaults(GenerateEFValueGenerator = true)] ``` The generator then emits an `EF.ValueGeneratorFactory` on the value object and registers every opted-in type in the generated `ValueObjectKeyValueGeneratorConvention`. Register that convention from `ConfigureConventions` — one line, and no per-entity configuration: ```csharp protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { base.ConfigureConventions(configurationBuilder); configurationBuilder.UseValueObjectKeyGenerators(); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ConfigureValueObjects(); } ``` What this gives you: - An **unset** key (still `Guid.Empty`) is assigned a time-ordered identifier when the entity is added, so keys stay unique without a database round trip and sort by creation time in stores that compare identifiers byte by byte. - A key the **domain already set** is never touched: Entity Framework Core only invokes a generator while the property holds its CLR default. - The convention runs at the lowest configuration source, so an entity configuration that owns its keys — `ValueGeneratedNever()`, an explicitly configured generator, or a store-generated default — always wins. The generated identifier is a time-ordered UUID created without a database round trip, using only base Entity Framework Core APIs, so the convention holds for every provider. Which bytes carry the timestamp depends on the store, and you choose that with the registration overload. ### Key ordering Where a store compares the identifier's sixteen bytes in order — PostgreSQL, SQLite, MySQL, and non-clustered SQL Server keys — version 7 ordering is what you want. SQL Server's `uniqueidentifier` compares the **trailing six bytes first**, so plain version 7 values are effectively random there and a clustered key fragments. Pass the ordering the store needs: ```csharp // Default: version 7, ascending in plain byte order. configurationBuilder.UseValueObjectKeyGenerators(); // SQL Server: the timestamp moves into the trailing six bytes, which SQL Server compares first. configurationBuilder.UseValueObjectKeyGenerators(ValueObjectKeyOrdering.SqlServer); ``` | Ordering | Timestamp location | Ascends by creation time in | Well-formed version 7 UUID | | --- | --- | --- | --- | | `ValueObjectKeyOrdering.UuidV7` (default) | bytes 0-5 | PostgreSQL, SQLite, MySQL, and non-clustered SQL Server keys | Yes | | `ValueObjectKeyOrdering.SqlServer` | bytes 10-15 | SQL Server's `uniqueidentifier` ordering | No — a version 4 UUID | Both orderings produce unique identifiers in process, keep the domain's own value when it set one, and start from a random value so nothing leaks about the sequence. The generated `ValueObjectSequentialGuid` helper is **public** in the `Microsoft.EntityFrameworkCore` namespace, so application code — including code in another assembly — can mint the identifier it wants an entity to carry, before the round trip that would otherwise assign one: ```csharp // The same value the convention would have generated, created where the domain needs it. var customerId = CustomerId.Create(ValueObjectSequentialGuid.NewGuid()); var customer = Customer.Create(customerId, tenantId, email, "Contoso"); ``` Because Entity Framework Core only invokes a generator while the property still holds its CLR default, the key the application set is persisted as-is. The helper exposes: | Member | Purpose | | --- | --- | | `NewGuid()` / `NewGuid(DateTimeOffset)` | Creates a version 7 identifier, from now or a given creation time. | | `NewSqlServerGuid()` / `NewSqlServerGuid(DateTimeOffset)` | Creates a SQL Server-ordered identifier. | | `TryGetTimestamp(Guid, out DateTimeOffset)` | Reads the creation time out of a version 7 identifier; false for any other shape. | | `TryGetSqlServerTimestamp(Guid, out DateTimeOffset)` | Reads the creation time out of a SQL Server-ordered identifier. | | `MinSqlServerGuidFor(DateTimeOffset)` / `MaxSqlServerGuidFor(DateTimeOffset)` | Inclusive bounds for a creation time, so `id >= MinSqlServerGuidFor(t) && id <= MaxSqlServerGuidFor(t)` is an index seek. | Value generation is supported for **Guid-backed** scalars only, and requires the Entity Framework converter. Requesting it anywhere else reports `VO1021`. A value object declared in an assembly that does not reference Entity Framework Core still gets a generator: the consuming project emits it alongside the inline converters. ## Query filters and translated predicates Tenant and soft-delete filters compare value objects directly, because each converted property keeps a translatable converter: ```csharp modelBuilder.Entity().HasQueryFilter(invoice => invoice.TenantId == currentTenant.TenantId); ``` The same applies to raw primitives — a filter or predicate may compare the value object to the underlying value, because `Guid.Empty`, `"USD"`, or an enum member converts implicitly: ```csharp modelBuilder.Entity().HasQueryFilter(tenant => tenant.Id == Platform.SystemTenantId); ``` ## Schema and migrations - A scalar value object is a **single column** of its provider primitive, so `dotnet ef migrations add` sees the primitive: renaming the value object's property does not change the schema, and changing the underlying type is a column type change you review like any other. - A complex value object mapped as a complex type is a **set of columns** named after its members, and one mapped with `EFMapping = Json` is a **single JSON column**. Moving a value object between those shapes is a schema change: add a migration and consider the data path (a JSON column usually needs a data migration to reshape existing values). - Multi-provider repositories keep one migration set per provider. The generated converters and the key generator convention are provider-independent, so the same model works for SQL Server, PostgreSQL, and SQLite; only the primitive column types differ. - Design-time factories (`IDesignTimeDbContextFactory`) and model-cache keys that depend on runtime state (for example a query filter built from the current tenant) must be applied consistently, because a cached model is reused for every context instance created the same way. The generator exposes per value object a nested static `EF` class. Use it for per-property configuration instead of (or alongside) the automatic registry: ```csharp builder.Entity() .Property(c => c.Email) .HasConversion(EmailAddress.EF.Converter, EmailAddress.EF.Comparer); ``` Complex value objects can be configured explicitly with `ComplexProperty`: ```csharp builder.Entity() .ComplexProperty(o => o.Total, money => { money.Property(m => m.Amount); money.Property(m => m.Currency).HasConversion(CurrencyCode.EF.Converter, CurrencyCode.EF.Comparer); }); ``` ## Options ### Per type ```csharp [Scalar(GenerateEFConverter = false, GenerateEFComparer = false)] // opt this scalar out of EF support public readonly partial record struct InternalCode { ... } [ValueObject(EFMapping = EntityFrameworkMapping.Json)] // map as a JSON column instead of complex type public readonly partial record struct Audit { ... } [ValueObject(EFMapping = EntityFrameworkMapping.None, GenerateEFComparer = false)] // no EF support for this type public readonly partial record struct Notes { ... } ``` ### Per assembly (`[ValueObjectDefaults]`) Assembly-level defaults apply to every value object and can be overridden per type. This is also how you set the complex/JSON mapping mode as the assembly default: ```csharp [assembly: ValueObjectDefaults(EFMapping = EntityFrameworkMapping.Json)] [assembly: ValueObjectDefaults(GenerateEFConverter = false, GenerateEFComparer = false)] // opt the whole assembly out ``` ### MSBuild property (whole project) Disable all Entity Framework generation for the compilation: ```xml true ``` Disable only the assembly-level registry (keeping per-type `EF` members and marker interfaces) — useful for value-object provider assemblies referenced by EF consumers: ```xml true ``` ## Diagnostics - `VO1009` — an Entity Framework option was set explicitly but the project does not reference `Microsoft.EntityFrameworkCore`. - `VO1010` — a scalar value object wraps an underlying type EF Core cannot map natively, so automatic conversion is skipped (map the property manually, or store it as JSON). - `VO1016` — a value object member has a setter. Value objects must be immutable; declare the member get-only or init-only. - `VO1017` — a complex value object maps to a JSON column while JSON converter generation is disabled, so the column content would be produced by reflection serialization and can differ from the value object's own JSON contract. - `VO1018` — a complex value object maps as an Entity Framework Core complex type but a member cannot be converted by the generated mapping (a collection, or a type that is neither a mappable primitive, a string, an enum, nor a value object with Entity Framework support). Map it manually or use `EFMapping = Json`. - `VO1019` — a complex value object maps as a complex type but the project's Entity Framework Core version is older than 8, so no Entity Framework mapping is generated. Use `EFMapping = Json` or configure the property manually. - `VO1021` — `GenerateEFValueGenerator` was requested for a value object that is not Guid-backed, or whose Entity Framework converter is disabled; no value generator is emitted. ## Notes - Only `[Scalar]`/`[ValueObject]` types are given a conversion; a plain `enum` property keeps Entity Framework Core's own enum mapping untouched. - EF Core 8+ is required for **complex type mapping**. An EF Core 7 reference set reports `VO1019` and leaves complex value objects unmapped; use `EFMapping = Json` (a value converter, so it works on every version) or configure the property manually. The complex-type block in the registry and the compiled-model members the generated converters expose (`JsonReaderWriter`) are EF Core 8+ only as well. Everything else the generator emits — converters, the key value generator, the convention, and `ValueObjectSequentialGuid` — works on EF Core 7 and later. - A generated key value generator is typed as the **value object**, not as its provider value, because Entity Framework Core assigns what a generator returns straight to the property. Application code that mints keys before `SaveChanges` calls the generated `ValueObjectSequentialGuid` helper, which is public in the `Microsoft.EntityFrameworkCore` namespace and produces the same values the convention would assign. - The generated code is compiled against EF Core 7, 9, and 10 in `src/tests/ValueObjects.EFCompatibility.IntegrationTests`, so version-specific API shifts (for example `ValueGeneratorFactory.Create`'s second parameter changing to `ITypeBase`) are caught by the build rather than by a consumer. - Value objects are immutable; EF tracks them by value like any struct/record. The generator emits a parameterless constructor for `[ValueObject]` types to support EF Core materialization. - **Materialization is a replay path.** Entity Framework Core rebuilds a value object with `Hydrate(...)`, so neither `OnValidate` nor a ZodSharp schema runs when a row is read — the same guarantee as `ValueObjectDeserializationMode.Hydrate`. `ValueObjectDeserializationMode.Strict` applies to the JSON wire format, not to the database: validate before persisting, or enforce the invariant in the schema. - See `src/src/Sample` for a runnable EF Core (SQLite) example, and `src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs` for integration tests. --- # Value Object Design ## The `Create` / `Hydrate` split Every value object exposes two static factories: - `Create(value)` – the strict creation path. It runs `OnNormalize` then `OnValidate`, then constructs. Callers use this for command/input data. - `Hydrate(value)` – the reconstruction path. It constructs without re-validating. Use this for persisted state, replay, and deserialization where validation has already happened. `TryCreate(value, out result)` wraps `Create` and returns `false` (instead of throwing) when validation fails. ## Normalization `OnNormalize(ref T value)` transforms the raw input before validation. Common uses: - trimming whitespace - casing canonicalization (email, currency codes) - stripping separators Normalization is deterministic and runs on every `Create`. ## Validation `OnValidate(T value)` (scalar) or `partial void OnValidate(...)` (complex) enforces invariants and throws domain-appropriate exceptions. Keep validation pure and deterministic; it must not perform I/O. ## Where validation lives | Concern | Location | | --- | --- | | Primitive format and canonicalization | `OnNormalize` / `OnValidate` in the value object | | Cross-field invariants | `partial void OnValidate(...)` in a `[ValueObject]` | | Owner/state-machine transitions | contextual `Create(TValue, in ValueObjectContext)` | | External schema rules / DTO validation | Purview.ZodSharp schemas (see `ZodSharp-Validation.md`) | Use the value object hooks for invariants that must hold for every construction path. Use ZodSharp when you need schema-driven validation (DataAnnotations-based `[ZodSchema]` validators, hand-built `Z.*` schemas, or DTO validation before mapping to value objects). When a value object is annotated with both `[Scalar]`/`[ValueObject]` and `[ZodSchema]`, the generator runs the ZodSharp schema automatically inside `Create` (construct → `{Type}Schema.Validate(instance)` → throw `ZodException` on failure). `ZodSchemaMode` controls whether the `OnValidate` hook also runs (`InAdditionToHooks`, the default) or is replaced (`InsteadOfHooks`); `OnNormalize` always runs. ## Deserialization modes `[Scalar]` and `[ValueObject]` default to `ValueObjectDeserializationMode.Hydrate`, so JSON reads do not re-validate. Use `Strict` when round-trip fidelity requires re-running validation on read. ## Contextual value objects `IContextualValueObject` lets a value object validate against the owning instance: ```csharp public static OrderStatus Create(OrderStatusCode value, in ValueObjectContext context) { ... } ``` `ValueObjectContext` provides `Owner`, `MemberName`, and an optional `Reason`. Keep the contextual `Create` deterministic and side-effect free. ## Queryability Primitive scalar values are the most query-friendly shape for database filters. Scalar values that wrap complex CLR types preserve invariants and serialization, but deep predicates through `.Value` may not translate to SQL in all providers. If you need deep filtering, expose a separately mapped mirror property derived from canonical state. ## Value objects in a domain model Value objects carry identity and invariants; entities and aggregates carry lifecycle. The two compose, and the split that keeps the dependency graph clean is: | Project | References | Contains | | --- | --- | --- | | Domain (e.g. `MyApp.Core`) | `Purview.ValueObjects`, `Purview.ZodSharp` | Value objects, entities/aggregates, domain services. **No Entity Framework.** | | Persistence (e.g. `MyApp.Persistence`) | Entity Framework Core, the domain project | `DbContext`, entity POCOs, `ConfigureValueObjects()`, migrations | A domain project that does not reference Entity Framework gets no `EF` members and no marker interfaces. That is intentional: the consuming project discovers its value objects from their attributes and generates the converters **into the persistence assembly**, so no Entity Framework code leaks into the domain. ```csharp // Domain project — no Entity Framework reference. [Scalar] public readonly partial record struct CustomerId { public Guid Value { get; } } public sealed class Customer { Customer(CustomerId id, EmailAddress email) => (Id, Email) = (id, email); public CustomerId Id { get; } public EmailAddress Email { get; private set; } public static Customer Create(CustomerId id, EmailAddress email) => new(id, email); } ``` **Typed identifiers.** Every identity is a scalar value object, so a `CustomerId` can never be passed where an `OrderId` is expected. Where the identifier is generated for you, opt the type into Entity Framework key value generation (see `Entity-Framework.md`) and keep the domain free of identifier plumbing; where the domain owns the identifier, create it in the factory and keep `ValueGeneratedNever()` on the entity. Which bytes the generated identifier orders by is a store concern, so the ordering is chosen where the convention is registered — in the persistence project, not on the value object. **Entities next to value objects.** Entities are ordinary classes with a private constructor and a static factory that validates, and they hold value objects rather than primitives. An entity's `Create` is a command boundary; its mutation methods enforce the invariants that span members. ## Choosing a persistence shape | Shape | Use when | Notes | | --- | --- | --- | | Scalar value object → single column (default) | The value wraps one primitive (ids, codes, emails). | Query-friendly: predicates compare the value object directly. | | `[ValueObject]` → EF complex type (default) | A small, fixed group of members that belongs to one row. | One column per member; nested scalars convert; not a key. | | `[ValueObject(EFMapping = Json)]` | The group is wide, optional, or does not need to be queried by member. | One JSON column; content follows the value object's JSON contract. | | Flat columns on the entity | The members participate in keys, unique constraints, or frequent predicates. | Map them individually and compose the value object in a mapper. | Identity that spans two or more members (for example "provider connection + external id") is awkward as a complex type: complex types cannot be keys. Either flatten the members into the entity and put a unique index over them, or store the value object as a JSON column and index the derived columns you actually query. ## Failure contract | Path | Behavior | | --- | --- | | `Create(...)` | Throws on invalid input: the hook's exception, or a `ZodException` when a ZodSharp schema is generated for the type. | | `TryCreate(...)` | Returns `false` instead of throwing. | | `Hydrate(...)` | Never validates. Persistence, replay, and deserialization use this path. | A ZodSharp `ZodException` carries one or more `ValidationError` entries with a code and a path, so the same error codes you use in hooks (`ErrorFactory`-style constants) flow to an ASP.NET Core Problem Details response when `Purview.ZodSharp.AspNetCore` is registered. See `ZodSharp-Validation.md`. --- # Validating Value Objects with ZodSharp [Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp) is a high-performance C# port of the [Zod](https://github.com/colinhacks/zod) schema validation library. It complements `Purview.ValueObjects`: the value object owns the invariants, ZodSharp owns the rule definitions and validation results. Three patterns are covered here, demonstrated in the `src/src/ZodSharpSample` project: 1. **Generator-integrated validation** — a value object annotated with both `[Scalar]`/`[ValueObject]` and `[ZodSchema]` has its generated `Create` wired to the ZodSharp-generated schema. 2. **Generated validators** — annotate a value object or DTO with `[ZodSchema]` and DataAnnotations; a source generator emits a zero-allocation `{Type}Schema` validator. 3. **Schema-first validation** — build a schema for the scalar's underlying value with `Z.String()`, `Z.Number()`, `Z.Enum()`, then construct the value object through its strict `Create` factory. ## Install ```text dotnet add package Purview.ZodSharp ``` ## 1. Generated validators on value objects Mark a `[Scalar]` value object with `[ZodSchema]` and add DataAnnotations to its underlying value. The generator produces a static `{Type}Schema` class plus a `{Type}SchemaValidator` adapter. ```csharp using System.ComponentModel.DataAnnotations; using Purview.ValueObjects.Serialization; using ZodSharp; [Scalar] [ZodSchema] public readonly partial record struct EmailAddress { [EmailAddress] [StringLength(254, MinimumLength = 3)] 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)); } } ``` Validate the value object directly: ```csharp var email = EmailAddress.Create("demo@example.com"); var result = EmailAddressSchema.Validate(email); // ValidationResult if (result.IsSuccess) Console.WriteLine(result.Value); // demo@example.com var parsed = EmailAddressSchema.Parse(email); // throws ZodException on failure // Compose additional rules: var allowed = EmailAddressSchema.ApplyRefine( email, static e => e.Domain == "example.com", "Only example.com addresses allowed"); ``` `[ZodSchema]` supports classes, structs, and records, and reads DataAnnotations such as `[Required]`, `[StringLength]`, `[Range]`, `[RegularExpression]`, `[EmailAddress]`, `[AllowedValues]`, and `[DeniedValues]`. ## 2. Generator-integrated validation When a value object is annotated with **both** `[Scalar]`/`[ValueObject]` **and** `[ZodSchema]`, the value-object generator detects it and routes the generated `Create(...)` through the ZodSharp-generated schema — no manual schema wiring needed: ```csharp [Scalar] [ZodSchema] public readonly partial record struct EmailAddress { [EmailAddress] public string Value { get; } // ... } EmailAddress.Create("not-an-email"); // throws ZodException via EmailAddressSchema.Validate ``` The generated `Create` constructs the instance, calls `EmailAddressSchema.Validate(instance)`, and throws a `ZodException` when validation fails. `Hydrate(...)` remains replay-safe (no re-validation), and `ValueObjectDeserializationMode.Strict` (which deserializes through `Create`) picks up the schema validation automatically. `ZodSchemaMode` on `[Scalar]`/`[ValueObject]` controls how the schema and the hand-written hooks combine: - `ZodSchemaMode.InAdditionToHooks` (default) — the schema runs **and** the `OnValidate` hook runs. - `ZodSchemaMode.InsteadOfHooks` — the schema runs **instead of** the `OnValidate` hook. `OnNormalize` still runs so input is canonicalized first. ```csharp [Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks)] [ZodSchema] public readonly partial record struct PhoneNumber { [RegularExpression(@"^\+?\d{7,15}$")] public string Value { get; } } ``` The `[ZodSchema]` attribute also exposes generator options that tune the emitted schema: - `SchemaName` — overrides the generated schema class name (default `{TypeName}Schema`); the ZodSharp DI adapter becomes `{SchemaName}Validator`. The value object generator resolves the same name for its generated `Create`, so a custom name works — as long as it is a valid C# identifier. ZodSharp applies any non-empty value verbatim (including whitespace), so an unusable name is reported as `VO1015` rather than silently falling back to the default. - `CustomValidationMethodName` — names a static async method that the generated validator's `ValidateAsync` awaits after the synchronous rules pass (default `CustomValidationAsync`). - Synchronous refinements are written as the generator-declared `OnZodValidate` hook rather than a named method; see [Zod-compatible refinement hooks on value objects](https://github.com/purview-dev/value-objects/blob/main/docs#zod-compatible-refinement-hooks-on-value-objects). ### Zod-compatible refinement hooks on value objects A value object annotated with `[ZodSchema]` gets the ZodSharp generator's optional partial hook for rules that DataAnnotations cannot express (cross-member invariants, allowed domains, state checks). Implement `OnZodValidate(RefineCtx)` and add issues with the ZodSharp context: ```csharp [Scalar] [ZodSchema] public readonly partial record struct CorporateEmail { [EmailAddress] public string Value { get; } static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; partial void OnZodValidate(RefineCtx context) { if (!context.Value.Value.EndsWith("@contoso.com", StringComparison.Ordinal)) context.AddIssue("invalid_domain", "Corporate emails must use the contoso.com domain.", [nameof(Value)]); } } ``` ```csharp CorporateEmail.Create("demo@gmail.com"); // throws ZodException carrying 'invalid_domain' CorporateEmail.Hydrate("demo@gmail.com"); // replay-safe: no validation runs ``` - The hook is **declared and invoked by the ZodSharp generator** inside `{Type}Schema.Validate`, so it runs for every schema entry point: `Validate`, `Parse`, the DI adapter, `IValidateOptions`, the value object's generated `Create`, and `ValueObjectDeserializationMode.Strict` (which deserializes through `Create`). The default `Hydrate` mode never validates. - Because the hook belongs to the schema, the value-object generator neither declares nor invokes it. A value object therefore observes refinements through exactly the same path as any other `[ZodSchema]` consumer, and `{Type}Schema.Validate(instance)` reports the same issues as `Create`. - The target type (and every containing type) must be declared `partial` so the ZodSharp generator can declare the hook on it. ZodSharp reports `ZODSGEN034` (not `partial`) and `ZODSGEN035` (malformed signature). - Refinements are no longer written as an `IEnumerable Validate()` method on the value object. That contract is retired; ZodSharp reports `ZODSGEN036` if a member still uses it. - The hook is independent of `ZodSchemaMode`: `InsteadOfHooks` only skips the value object's own `OnValidate` hook, never the Zod refinement hook. ### ZodSharp integration diagnostics The value-object analyzer reports the integration states that would otherwise pass silently: | Rule | Severity | Reported when | | --- | --- | --- | | `VO1013` | Warning | `OnValidate` is implemented while `ZodSchemaMode.InsteadOfHooks` is set, making that implementation unreachable in the generated `Create`. | | `VO1015` | Error | `[ZodSchema(SchemaName = "...")]` is not a valid C# identifier, which ZodSharp applies verbatim and this generator cannot reference. Generation is skipped for that type. | ZodSharp's own diagnostics (`ZODSGEN034`-`ZODSGEN036`) cover the refinement hook itself. ## 3. Schema-first validation When you do not want the generator involved, build a schema for the scalar's underlying value and map a successful result onto the value object: ```csharp using ZodSharp; public static class ScalarSchemas { public static readonly IZodSchema EmailSchema = Z.String().Email().Min(3).Max(254); public static readonly IZodSchema CurrencySchema = Z.String().Regex("^[A-Z]{3}$"); public static readonly IZodSchema OrderStatusSchema = Z.Enum(); public static readonly IZodSchema MoneyAmountSchema = Z.Number().Positive(); public static ValidationResult ValidateEmail(string value) => Map(EmailSchema.Validate(value), EmailAddress.Create); static ValidationResult Map( ValidationResult result, Func construct) => result.IsSuccess ? ValidationResult.Success(construct(result.Value!)) : ValidationResult.Failure(result.Errors); } ``` Note that ZodSharp validates the raw value exactly as supplied — normalization (trimming, casing) is the value object's job in `OnNormalize`. Validate the raw input, then construct with `Create` so the value object normalizes and wraps it. ## 4. Validating DTOs before mapping to value objects Annotate a request/DTO class with `[ZodSchema]`, validate it, then map the validated values onto value objects: ```csharp [ZodSchema] public sealed partial class RegistrationDto { [Required, StringLength(100, MinimumLength = 2)] public string Name { get; init; } = string.Empty; [Range(13, 120)] public int Age { get; init; } [Required, EmailAddress] public string Email { get; init; } = string.Empty; // Custom refinement, declared by the ZodSharp generator. The generator runs these issues after // the DataAnnotations rules. partial void OnZodValidate(RefineCtx context) { if (context.Value.Name.StartsWith("x", StringComparison.OrdinalIgnoreCase)) context.AddIssue("name", "Name cannot start with 'x'.", [nameof(Name)]); } } var result = RegistrationDtoSchema.Validate(dto); if (result.IsSuccess) { var email = EmailAddress.Create(result.Value.Email); var currency = CurrencyCode.Create("USD"); var money = Money.Create(19.99m, currency); } ``` ### Async custom validation `CustomValidationMethodName` names a static async method with the signature `static ValueTask> Method(T value, CancellationToken cancellationToken)`. The generated `{Type}SchemaValidator` (which implements `IZodSchemaValidator`) awaits it in its `ValidateAsync` after the synchronous rules pass: ```csharp [ZodSchema(CustomValidationMethodName = nameof(ValidatePromoCodeAsync))] public sealed class PromoCode { [Required, RegularExpression(@"^[A-Z0-9]{4,10}$")] public string Code { get; init; } = string.Empty; internal static ValueTask> ValidatePromoCodeAsync( PromoCode value, CancellationToken cancellationToken) => ValueTask.FromResult( value.Code is "SAVE10" or "WELCOME20" ? ValidationResult.Success(value) : ValidationResult.Failure( new ValidationError("code", "Unknown promotional code.", [nameof(Code)])) ); } PromoCodeSchemaValidator validator = new(); var result = await validator.ValidateAsync(new PromoCode { Code = "HOMERUN42" }); ``` ## 5. Dependency injection and the schema factory `ZodSchemaFactory` resolves validators by validated type. Register the generated adapter or wrap a hand-built schema with `ZodSchemaValidator`: ```csharp using ZodSharp.Core; ZodSchemaFactory factory = new(); factory.Register(new EmailAddressSchemaValidator()); // generated adapter factory.Register(new ZodSchemaValidator(ScalarSchemas.EmailSchema)); // hand-built var emailResult = factory.Validate(EmailAddress.Create("demo@example.com")); var stringResult = factory.Validate("demo@example.com"); ``` ## Error handling `ValidationResult` is a struct with `IsSuccess`, `Value` (only when successful), and `Errors` (`ImmutableArray`). Each `ValidationError` has a `Path` and a `Message`: ```csharp foreach (var error in result.Errors) Console.WriteLine($"{string.Join(".", error.Path)}: {error.Message}"); ``` Use `Parse` / `GetValueOrThrow()` to throw a `ZodException` on failure instead of inspecting the result. ### ASP.NET Core Problem Details In ASP.NET Core, `Purview.ZodSharp.AspNetCore` maps thrown `ZodException`s to standard `HttpValidationProblemDetails` responses. This covers strict deserialization of value objects (`ValueObjectDeserializationMode.Strict`) and any `Create`/`Parse` failure that bubbles up as a `ZodException`. Wire the handler into the pipeline: ```csharp builder.Services.AddZodSharpProblemDetails(); builder.Services.AddProblemDetails(); var app = builder.Build(); app.UseExceptionHandler(); ``` Mark the value object for strict deserialization and ZodSharp validation so invalid request bodies throw during model binding: ```csharp [Scalar( ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)] [ZodSchema] public readonly partial record struct EmailAddress { [Required, EmailAddress] public string Value { get; } } builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory())); ``` A `POST` body with an invalid email now returns `400 application/problem+json` with the structured issues in the `issues` extension. Map error codes to HTTP statuses and formatted messages with `ErrorType` + `ErrorTypeRegistry`. Mark a static partial class with `[ErrorType]` on a `static readonly ErrorType` field and the bundled `ErrorTypeGenerator` emits `Create{Field}(...)` (builds a `ValidationError`) and `Throw{Field}(...)` (a `void` + `[DoesNotReturn]` method that throws the `ZodException`): ```csharp [ErrorType] public static readonly ErrorType SaveFailed = new( Code: "aggregate_save_failed", Description: "The order could not be saved because it was modified concurrently.", HttpStatus: StatusCodes.Status409Conflict, MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save") { Parameters = ["OrderId", "AggregateType"] }; ErrorTypeRegistry.Default.Register(ErrorTypes.SaveFailed); ``` The generated `ThrowSaveFailed(orderId, aggregateType)` throws a `ZodException` carrying the `aggregate_save_failed` code, yielding a `409 Conflict` whose message is formatted from the error's parameters. Because it is `void` + `[DoesNotReturn]`, use it as a terminal call — for example a `void` minimal-API handler that always throws (the endpoint returns the mapped `409` via the exception handler): ```csharp app.MapPost("/orders/{orderId}/confirm", ConfirmOrder); static void ConfirmOrder(string orderId) => ErrorTypes.ThrowSaveFailed(orderId, "Order"); ``` (If you prefer not to use the generator, construct the `ZodException` manually with `ValidationError.Create(code, message, path: [], parameters: ...)` — it maps the same way.) The bundled `ZODSASP001` analyzer flags `MessageFormat` placeholders missing from `Parameters` at compile time. See the [ASP.NET Core integration](https://purview.dev/docs/zodsharp/aspnetcore-integration/) guide and the `src/src/ZodSharp.AspNetCoreSample` project. ## JSON Schema export Export a schema to JSON Schema (Draft 2020-12) for cross-platform sharing with TypeScript Zod: ```csharp var jsonSchema = Z.ToJsonSchema(ScalarSchemas.EmailSchema, new ToJsonSchemaOptions { Title = "Email" }); ``` ## Direct-reference note When a project uses `Purview.ZodSharp` types directly (as this sample does), reference the package explicitly — do not rely on transitive flow. The ZodSharp source generator is active in any project that references the package, so `[ZodSchema]` is available there. ## Testing the dual-generator integration Tests that run the value-object generator and the ZodSharp generator together come in two shapes: - **In-memory (unit):** `src/tests/SourceGenerator.UnitTests` registers the packaged ZodSharp generator through `ZodSchemaValidationGeneratorTestOptions`, which resolves the component types out of band via `Common/ZodSharpSourceGenerators.cs`. The project copies `analyzers/dotnet/cs/Purview.ZodSharp.SourceGenerators.dll` beside the test binaries (`GeneratePathProperty` + `None`/`CopyToOutputDirectory`) and loads it with `Assembly.LoadFrom`. Never turn that copy into a ``: a merged analyzer component used to carry `Purview.SourceGeneratorFramework.*` types that then collide (`CS0433`) with the framework assembly the test harness loads. `Common/ZodSharpSourceGeneratorsTests.cs` guards the invariant. - **Real compile (integration):** `src/tests/ValueObjects.IntegrationTests` declares `[Scalar]` + `[ZodSchema]` fixtures and asserts runtime behaviour directly — both generators run in the real compiler for that project, so nothing has to be reflected or registered. The loaded generator carries its own framework implementation, so it keeps its own log sink and CodeWriter scope validation: do not assert on its log entries, and leave `ValidateCodeWriterScopes` off for that run. ## See also - The runnable `src/src/ZodSharpSample` project. - [Getting Started](../) - [Value Object Design](../value-object-design/)