@@ -22,6 +22,11 @@ For the examples below, also add a provider such as SQLite:
2222dotnet add package Microsoft.EntityFrameworkCore.Sqlite
2323```
2424
25+ The generated integration is compiled and tested against EF Core ** 7, 9, and 10** (EF Core 7 needs ` net8.0 ` ),
26+ so version-specific API differences are resolved by the generator rather than surfacing in your build. EF Core
27+ 7 has no complex-type mapping, which is the one feature difference: a complex value object reports ` VO1019 `
28+ there. See [ Notes] ( #notes ) for the full version matrix.
29+
2530## Automatic mapping
2631
2732Add one call in ` OnModelCreating ` . The generated ` ConfigureValueObjects ` extension is emitted into your project
@@ -181,7 +186,172 @@ var orders = await db.Orders
181186> undocumented Entity Framework Core implementation detail: if it ever changes, compiled models silently fall
182187> back to the built-in converter, and only raw-primitive comparisons are affected.
183188
184- ## Manual control
189+ ## Keys, foreign keys, and indexes
190+
191+ A value object key needs no special handling: the generated converter maps the value object to its
192+ primitive column, so a key or foreign key typed as a value object persists as that primitive. Convention
193+ then makes `{Type }Id` the primary key , exactly as it would for a `Guid ` or `string `.
194+
195+ ```csharp
196+ sealed class Customer
197+ {
198+ public CustomerId Id { get ; set; } // primary key, stored as uniqueidentifier
199+ public TenantId TenantId { get ; set ; } // foreign key to Tenant.Id
200+ public Tenant Tenant { get ; set ; } = default ! ;
201+ }
202+
203+ sealed class Tenant
204+ {
205+ public TenantId Id { get ; set ; }
206+ public TenantKey Key { get ; set ; } // a scalar value object stored as a single column
207+ }
208+ ```
209+
210+ Column facets are configured on the value object property and apply to the converted column, so keep
211+ configuring them the way you would on a primitive:
212+
213+ ``` csharp
214+ public sealed class TenantConfiguration : IEntityTypeConfiguration <TenantEntity >
215+ {
216+ public void Configure (EntityTypeBuilder <TenantEntity > builder )
217+ {
218+ builder .Property (entity => entity .Id ).ValueGeneratedNever (); // when the domain owns the key
219+ builder .Property (entity => entity .Key ).HasMaxLength (100 ).IsRequired ();
220+ builder .HasIndex (entity => new { entity .TenantId , entity .Key }).IsUnique ();
221+ }
222+ }
223+ ```
224+
225+ Because the converter is expression-based, indexes, unique constraints, and comparisons over value object
226+ properties behave like their primitive equivalents — including in composite indexes and ` HasQueryFilter ` .
227+
228+ ## Generating key values
229+
230+ A Guid-backed scalar value object can generate its own key values. Opt in per type, or once for the whole
231+ assembly:
232+
233+ ``` csharp
234+ [Scalar (GenerateEFValueGenerator = true )]
235+ public readonly partial record struct CustomerId
236+ {
237+ public Guid Value { get ; }
238+ }
239+
240+ // or: [assembly: ValueObjectDefaults(GenerateEFValueGenerator = true)]
241+ ```
242+
243+ The generator then emits an ` EF.ValueGeneratorFactory ` on the value object and registers every opted-in
244+ type in the generated ` ValueObjectKeyValueGeneratorConvention ` . Register that convention from
245+ ` ConfigureConventions ` — one line, and no per-entity configuration:
246+
247+ ``` csharp
248+ protected override void ConfigureConventions (ModelConfigurationBuilder configurationBuilder )
249+ {
250+ base .ConfigureConventions (configurationBuilder );
251+ configurationBuilder .UseValueObjectKeyGenerators ();
252+ }
253+
254+ protected override void OnModelCreating (ModelBuilder modelBuilder )
255+ {
256+ modelBuilder .ConfigureValueObjects ();
257+ }
258+ ```
259+
260+ What this gives you:
261+
262+ - An ** unset** key (still ` Guid.Empty ` ) is assigned a time-ordered identifier when the entity is added, so
263+ keys stay unique without a database round trip and sort by creation time in stores that compare identifiers
264+ byte by byte.
265+ - A key the ** domain already set** is never touched: Entity Framework Core only invokes a generator while
266+ the property holds its CLR default.
267+ - The convention runs at the lowest configuration source, so an entity configuration that owns its keys —
268+ ` ValueGeneratedNever() ` , an explicitly configured generator, or a store-generated default — always wins.
269+
270+ The generated identifier is a time-ordered UUID created without a database round trip, using only base
271+ Entity Framework Core APIs, so the convention holds for every provider. Which bytes carry the timestamp
272+ depends on the store, and you choose that with the registration overload.
273+
274+ ### Key ordering
275+
276+ Where a store compares the identifier's sixteen bytes in order — PostgreSQL, SQLite, MySQL, and non-clustered
277+ SQL Server keys — version 7 ordering is what you want. SQL Server's ` uniqueidentifier ` compares the ** trailing
278+ six bytes first** , so plain version 7 values are effectively random there and a clustered key fragments. Pass
279+ the ordering the store needs:
280+
281+ ``` csharp
282+ // Default: version 7, ascending in plain byte order.
283+ configurationBuilder .UseValueObjectKeyGenerators ();
284+
285+ // SQL Server: the timestamp moves into the trailing six bytes, which SQL Server compares first.
286+ configurationBuilder .UseValueObjectKeyGenerators (ValueObjectKeyOrdering .SqlServer );
287+ ```
288+
289+ | Ordering | Timestamp location | Ascends by creation time in | Well-formed version 7 UUID |
290+ | --- | --- | --- | --- |
291+ | ` ValueObjectKeyOrdering.UuidV7 ` (default) | bytes 0-5 | PostgreSQL, SQLite, MySQL, and non-clustered SQL Server keys | Yes |
292+ | ` ValueObjectKeyOrdering.SqlServer ` | bytes 10-15 | SQL Server's ` uniqueidentifier ` ordering | No — a version 4 UUID |
293+
294+ Both orderings produce unique identifiers in process, keep the domain's own value when it set one, and start
295+ from a random value so nothing leaks about the sequence.
296+
297+ The generated ` ValueObjectSequentialGuid ` helper is ** public** in the ` Microsoft.EntityFrameworkCore `
298+ namespace, so application code — including code in another assembly — can mint the identifier it wants an
299+ entity to carry, before the round trip that would otherwise assign one:
300+
301+ ``` csharp
302+ // The same value the convention would have generated, created where the domain needs it.
303+ var customerId = CustomerId .Create (ValueObjectSequentialGuid .NewGuid ());
304+ var customer = Customer .Create (customerId , tenantId , email , " Contoso" );
305+ ```
306+
307+ Because Entity Framework Core only invokes a generator while the property still holds its CLR default, the key
308+ the application set is persisted as-is. The helper exposes:
309+
310+ | Member | Purpose |
311+ | --- | --- |
312+ | ` NewGuid() ` / ` NewGuid(DateTimeOffset) ` | Creates a version 7 identifier, from now or a given creation time. |
313+ | ` NewSqlServerGuid() ` / ` NewSqlServerGuid(DateTimeOffset) ` | Creates a SQL Server-ordered identifier. |
314+ | ` TryGetTimestamp(Guid, out DateTimeOffset) ` | Reads the creation time out of a version 7 identifier; false for any other shape. |
315+ | ` TryGetSqlServerTimestamp(Guid, out DateTimeOffset) ` | Reads the creation time out of a SQL Server-ordered identifier. |
316+ | ` MinSqlServerGuidFor(DateTimeOffset) ` / ` MaxSqlServerGuidFor(DateTimeOffset) ` | Inclusive bounds for a creation time, so ` id >= MinSqlServerGuidFor(t) && id <= MaxSqlServerGuidFor(t) ` is an index seek. |
317+
318+ Value generation is supported for ** Guid-backed** scalars only, and requires the Entity Framework
319+ converter. Requesting it anywhere else reports ` VO1021 ` . A value object declared in an assembly that does
320+ not reference Entity Framework Core still gets a generator: the consuming project emits it alongside the
321+ inline converters.
322+
323+ ## Query filters and translated predicates
324+
325+ Tenant and soft-delete filters compare value objects directly, because each converted property keeps a
326+ translatable converter:
327+
328+ ``` csharp
329+ modelBuilder .Entity <Invoice >().HasQueryFilter (invoice => invoice .TenantId == currentTenant .TenantId );
330+ ```
331+
332+ The same applies to raw primitives — a filter or predicate may compare the value object to the underlying
333+ value, because ` Guid.Empty ` , ` "USD" ` , or an enum member converts implicitly:
334+
335+ ``` csharp
336+ modelBuilder .Entity <Tenant >().HasQueryFilter (tenant => tenant .Id == Platform .SystemTenantId );
337+ ```
338+
339+ ## Schema and migrations
340+
341+ - A scalar value object is a ** single column** of its provider primitive, so ` dotnet ef migrations add `
342+ sees the primitive: renaming the value object's property does not change the schema, and changing the
343+ underlying type is a column type change you review like any other.
344+ - A complex value object mapped as a complex type is a ** set of columns** named after its members, and one
345+ mapped with ` EFMapping = Json ` is a ** single JSON column** . Moving a value object between those shapes is
346+ a schema change: add a migration and consider the data path (a JSON column usually needs a data migration
347+ to reshape existing values).
348+ - Multi-provider repositories keep one migration set per provider. The generated converters and the key
349+ generator convention are provider-independent, so the same model works for SQL Server, PostgreSQL, and
350+ SQLite; only the primitive column types differ.
351+ - Design-time factories (` IDesignTimeDbContextFactory<TContext> ` ) and model-cache keys that depend on
352+ runtime state (for example a query filter built from the current tenant) must be applied consistently,
353+ because a cached model is reused for every context instance created the same way.
354+
185355
186356The generator exposes per value object a nested static ` EF ` class. Use it for per-property configuration instead
187357of (or alongside) the automatic registry:
@@ -253,14 +423,44 @@ value-object provider assemblies referenced by EF consumers:
253423 ` Microsoft.EntityFrameworkCore ` .
254424- ` VO1010 ` — a scalar value object wraps an underlying type EF Core cannot map natively, so automatic
255425 conversion is skipped (map the property manually, or store it as JSON).
426+ - ` VO1016 ` — a value object member has a setter. Value objects must be immutable; declare the member
427+ get-only or init-only.
428+ - ` VO1017 ` — a complex value object maps to a JSON column while JSON converter generation is disabled, so
429+ the column content would be produced by reflection serialization and can differ from the value object's
430+ own JSON contract.
431+ - ` VO1018 ` — a complex value object maps as an Entity Framework Core complex type but a member cannot be
432+ converted by the generated mapping (a collection, or a type that is neither a mappable primitive, a
433+ string, an enum, nor a value object with Entity Framework support). Map it manually or use
434+ ` EFMapping = Json ` .
435+ - ` VO1019 ` — a complex value object maps as a complex type but the project's Entity Framework Core version
436+ is older than 8, so no Entity Framework mapping is generated. Use ` EFMapping = Json ` or configure the
437+ property manually.
438+ - ` VO1021 ` — ` GenerateEFValueGenerator ` was requested for a value object that is not Guid-backed, or whose
439+ Entity Framework converter is disabled; no value generator is emitted.
256440
257441## Notes
258442
259443- Only ` [Scalar] ` /` [ValueObject] ` types are given a conversion; a plain ` enum ` property keeps Entity Framework
260444 Core's own enum mapping untouched.
261- - EF Core 8+ is required for complex type mapping; on older EF references, complex value objects fall back to
262- no automatic mapping (use ` EntityFrameworkMapping.Json ` or configure manually).
445+ - EF Core 8+ is required for ** complex type mapping** . An EF Core 7 reference set reports ` VO1019 ` and leaves
446+ complex value objects unmapped; use ` EFMapping = Json ` (a value converter, so it works on every version) or
447+ configure the property manually. The complex-type block in the registry and the compiled-model members the
448+ generated converters expose (` JsonReaderWriter ` ) are EF Core 8+ only as well. Everything else the generator
449+ emits — converters, the key value generator, the convention, and ` ValueObjectSequentialGuid ` — works on
450+ EF Core 7 and later.
451+ - A generated key value generator is typed as the ** value object** , not as its provider value, because Entity
452+ Framework Core assigns what a generator returns straight to the property. Application code that mints keys
453+ before ` SaveChanges ` calls the generated ` ValueObjectSequentialGuid ` helper, which is public in the
454+ ` Microsoft.EntityFrameworkCore ` namespace and produces the same values the convention would assign.
455+ - The generated code is compiled against EF Core 7, 9, and 10 in
456+ ` src/tests/ValueObjects.EFCompatibility.IntegrationTests ` , so version-specific API shifts (for example
457+ ` ValueGeneratorFactory.Create ` 's second parameter changing to ` ITypeBase ` ) are caught by the build rather
458+ than by a consumer.
263459- Value objects are immutable; EF tracks them by value like any struct/record. The generator emits a
264460 parameterless constructor for ` [ValueObject] ` types to support EF Core materialization.
461+ - ** Materialization is a replay path.** Entity Framework Core rebuilds a value object with ` Hydrate(...) ` ,
462+ so neither ` OnValidate ` nor a ZodSharp schema runs when a row is read — the same guarantee as
463+ ` ValueObjectDeserializationMode.Hydrate ` . ` ValueObjectDeserializationMode.Strict ` applies to the JSON wire
464+ format, not to the database: validate before persisting, or enforce the invariant in the schema.
265465- See ` src/src/Sample ` for a runnable EF Core (SQLite) example, and
266466 ` src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs ` for integration tests.
0 commit comments