-
Notifications
You must be signed in to change notification settings - Fork 5
Value Objects Framework Integration
Value Objects seamlessly integrate with popular .NET frameworks and libraries. This section covers integration with JSON serialization, MessagePack, ASP.NET Core, and Entity Framework Core.
- JSON Serialization
- MessagePack Serialization
- Multi-Format Serialization
- ASP.NET Core Integration
- OpenAPI/Swashbuckle Integration
-
Support for Entity Framework Core
- Performance: Object Factory Types and EF Core
- Option 1: Manual registration of the ValueConverter
- Option 2: Registration of the ValueConverter via extension method for
ModelBuilder - Option 3: Registration of the ValueConverter via extension method for
EntityTypeBuilder - Option 4: Registration of the ValueConverter via extension method for
DbContextOptionsBuilder
- ASP.NET Core Minimal API: MaybeBound pattern
Depending on the concrete JSON library you use, you need a different NuGet package:
There are 2 options to make the Value Objects JSON convertible.
The easiest way is to make Thinktecture.Runtime.Extensions.Json / Thinktecture.Runtime.Extensions.Newtonsoft.Json a dependency of the project(s) the value objects are in. The dependency doesn't have to be a direct one but can be transitive as well.
Both NuGet packages lead to generation of JSON converters and flag the value object with a JsonConverterAttribute. This way the value objects can be converted to and from JSON without extra code.
This option is available for simple value objects only. Complex value objects require the project dependency approach (Option 1).
If adding the NuGet package to the Value Object project is not desirable, you can register a JSON converter factory at the serializer level instead. This allows the NuGet package to be installed in whichever project configures JSON settings.
- Use
ThinktectureJsonConverterFactorywithSystem.Text.Json - Use
ThinktectureNewtonsoftJsonConverterFactorywithNewtonsoft.Json
An example for ASP.NET Core application using System.Text.Json:
var builder = WebApplication.CreateBuilder();
builder.Services.AddControllers()
.AddJsonOptions(options => options.JsonSerializerOptions
.Converters
.Add(new ThinktectureJsonConverterFactory()));An example for minimal apis:
var builder = WebApplication.CreateBuilder();
builder.Services
.ConfigureHttpJsonOptions(options => options.SerializerOptions
.Converters
.Add(new ThinktectureJsonConverterFactory()));The code for Newtonsoft.Json is almost identical:
var builder = WebApplication.CreateBuilder();
builder.Services.AddControllers()
.AddNewtonsoftJson(options => options.SerializerSettings
.Converters
.Add(new ThinktectureNewtonsoftJsonConverterFactory()));Value Objects with [ObjectFactory<ReadOnlySpan<char>>(UseForSerialization = SerializationFrameworks.SystemTextJson)] automatically use zero-allocation JSON deserialization on .NET 9+. This applies to System.Text.Json only -- Newtonsoft.Json and MessagePack continue using the regular string-based or key-based conversion. See Object Factories -- Zero-Allocation JSON for details and examples.
There are 2 options to make the value objects MessagePack serializable.
The easiest way is to make Thinktecture.Runtime.Extensions.MessagePack a dependency of the project(s) the value objects are in. The dependency doesn't have to be a direct one but can be transitive as well.
The NuGet package leads to generation of a MessagePack formatter and flags the value object with a MessagePackFormatterAttribute. This way the value object can be serialized to and from MessagePack without extra code.
This option is available for simple value objects only. Complex value objects require the project dependency approach (Option 1).
If adding the NuGet package to the Value Object project is not desirable, you can register a formatter resolver at the serializer level instead. This allows the NuGet package to be installed in whichever project configures MessagePack options.
An example of a round-trip-serialization of the value object ProductName:
// Use "ThinktectureMessageFormatterResolver"
var resolver = CompositeResolver.Create(ThinktectureMessageFormatterResolver.Instance, StandardResolver.Instance);
var options = MessagePackSerializerOptions.Standard.WithResolver(resolver);
ProductName chocolate = ProductName.Create("Chocolate");
// Serialize to MessagePack
var bytes = MessagePackSerializer.Serialize(chocolate, options, CancellationToken.None);
// Deserialize from MessagePack
var deserializedChocolate = MessagePackSerializer.Deserialize<ProductName>(bytes, options, CancellationToken.None);When your application uses multiple serialization frameworks simultaneously (System.Text.Json, Newtonsoft.Json, MessagePack), the simplest way to ensure all three use the same conversion logic is the project dependency approach: reference all three integration packages from the project containing your Value Objects.
-
Thinktecture.Runtime.Extensions.Json(System.Text.Json) -
Thinktecture.Runtime.Extensions.Newtonsoft.Json(Newtonsoft.Json) -
Thinktecture.Runtime.Extensions.MessagePack(MessagePack)
Each package triggers source-generated converters that use the same underlying factory method (Create/Validate), so all formats produce consistent round-trip results automatically. This is all you need -- no [ObjectFactory] attribute required.
If you need to override the default key-based conversion with a custom format, apply [ObjectFactory<T>] with UseForSerialization = SerializationFrameworks.All so all three frameworks use your custom Validate and ToValue methods:
[ValueObject<int>]
[ObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)]
public partial class ProductId
{
public static ValidationError? Validate(
string? value, IFormatProvider? provider, out ProductId? item)
{
if (value is not null
&& value.StartsWith("PROD-", StringComparison.Ordinal)
&& Int32.TryParse(value.AsSpan(5), provider, out var id))
{
return Validate(id, provider, out item);
}
item = null;
return new ValidationError("Expected format: PROD-{number}");
}
public string ToValue() => $"PROD-{_value}";
}All three frameworks will serialize ProductId as "PROD-42" and deserialize it using the same Validate method.
Use the SerializationFrameworks enum to target individual frameworks. This is useful when one framework needs a different representation (e.g., byte[] for MessagePack):
| Value | Description |
|---|---|
None |
No serialization integration |
All |
All supported frameworks |
Json |
System.Text.Json + Newtonsoft.Json |
SystemTextJson |
System.Text.Json only |
NewtonsoftJson |
Newtonsoft.Json only |
MessagePack |
MessagePack only |
Multiple [ObjectFactory] attributes can target different frameworks with non-overlapping UseForSerialization values. See Object Factories -- Multiple Object Factories for details.
Covers Minimal API parameter binding, MVC model binding, and validation.
While JSON serializability is important for deserialization of objects from request body, model binding requires additional setup. If a value of a simple value object is received as a query parameter, then there is no JSON conversion in play but ASP.NET Core Model Binding. Besides model binding, i.e., conversion from query string to a value object, there is model validation as well.
ASP.NET Core Model Binding is for simple value objects only and for complex objects with
ObjectFactoryAttribute<string>(see section Custom Type Conversion). A complex value object has more than 1 property/field, so, deserialization (withoutObjectFactory<string>) from astringto 2+ members is a case for JSON (de)serialization.
Object Factory integration: If you need model binding for a type whose key is not a
string, or for complex Value Objects and unions, you can use[ObjectFactory<string>(UseForModelBinding = true)]to enable string-based model binding. Note that the attribute alone is not sufficient -- you still need to registerThinktectureModelBinderProvider(for MVC controllers). See the Object Factories page for full details.
The parameter binding of Minimal APIs in .NET 8 is still quite primitive in comparison to the model binding of MVC controllers. To make a type bindable it has to implement either TryParse or BindAsync. A simple Value Object implements TryParse (interface IParsable<T>) by default, so it can be used with Minimal APIs without any changes.
At the moment, the parameter binding (with TryParse and BindAsync) doesn't allow to pass custom validation errors to be returned to the client. The only information we can pass is an indication whether the parameter could be bound or not.
ASP.NET Core MVC provides more control during model binding. For example, if we expect from client a ProductName and receive the value A, which is rejected by the validation, then the ASP.NET Core ModelState will be invalid. In this case we can reject (or let ApiControllerAttribute reject) the request.
By rejecting the request, the client gets the status code BadRequest (400) and the error:
{
"productName": [
"Product name cannot be 1 character long."
]
}To help out the Model Binding we have to register the ThinktectureModelBinderProvider with ASP.NET Core. By using the custom model binder, the NuGet package can be installed in any project where ASP.NET Core is configured.
Place the "ThinktectureModelBinderProvider" before default providers, so they don't try to bind value objects.
var builder = WebApplication.CreateBuilder();
builder.Services.AddControllers(options =>
options.ModelBinderProviders.Insert(0, new ThinktectureModelBinderProvider()));Value Objects can be integrated with Swashbuckle to provide OpenAPI documentation for Web APIs.
To enable OpenAPI support for Value Objects, register the Thinktecture OpenAPI filters with dependency injection:
services.AddEndpointsApiExplorer()
.AddSwaggerGen(options => options.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" }))
.AddThinktectureOpenApiFilters();You can customize the OpenAPI schema generation with options:
services.AddThinktectureOpenApiFilters(options =>
{
// Configure the required member evaluator
options.RequiredMemberEvaluator = RequiredMemberEvaluator.All
});Required member evaluator
The RequiredMemberEvaluator option determines what members are considered required in the OpenAPI schema. The available options are:
-
Default: The member is considered required, if:- it is a struct value object with
AllowDefaultStructsequals tofalse(which is the default) - it is a non-nullable reference type.
- it is a struct value object with
-
All: All members are flagged as required. -
None: Members are not flagged as required. -
FromDependencyInjection: Resolves implementation ofIRequiredMemberEvaluatorfrom dependency injection
For fine-grained control over individual properties, you can also use the standard [Required] attribute:
public class Order
{
[Required]
public ProductName Name { get; set; }
public ProductName? OptionalName { get; set; }
}With the filters registered, Swashbuckle generates appropriate OpenAPI schemas for your Value Objects:
- Simple value objects are represented as their underlying type:
{
"ProductName": {
"type": "string"
},
"Amount": {
"type": "number",
"format": "double"
}
}- Complex value objects are represented as objects with their properties:
{
"Boundary": {
"type": "object",
"properties": {
"lower": { "type": "number", "format": "double" },
"upper": { "type": "number", "format": "double" }
}
}
}Starting with Entity Framework Core 2.1 we've got the feature Value Conversion. By providing a value converter, EF Core can convert a simple value object (like ProductName) to and from a primitive type (like string) when persisting the data and when reading the value from database.
Design principle -- database as source of truth: By default, the generated value converters use the constructor (not the factory method) when loading data from the database. This is intentional: data in the database is considered validated, so re-running factory validation on every load would add an unnecessary performance penalty. If you need to re-validate on load (e.g., after changing validation rules), you can set
UseConstructorForRead = falsein theConfigurationobject (see Options 3 and 4 below).
Object Factory integration: For complex Value Objects or unions that need single-column persistence, you can use
[ObjectFactory<T>(UseWithEntityFramework = true)]to enable EF Core value conversion via a single value. Note that the attribute alone is not sufficient -- you still need to register the value converters using one of the options below (UseThinktectureValueConverters(),AddThinktectureValueConverters(), etc.). See the Object Factories page for full details.
When EF Core loads a type that uses [ObjectFactory<T>], it calls the Validate method by default -- unlike simple value objects, which use the constructor directly. This means validation runs on every database read, even though the database is the source of truth. To bypass validation and match the behavior of simple value objects, set HasCorrespondingConstructor = true and provide a matching constructor:
[ComplexValueObject]
[ObjectFactory<string>(UseForSerialization = SerializationFrameworks.All, HasCorrespondingConstructor = true)]
public partial class FileUrn
{
// Constructor used by EF Core -- bypasses Validate on read
private FileUrn(string value)
{
var parts = value.Split(':', 2);
FileStore = parts[0];
Urn = parts[1];
}
// ...
}This only affects EF Core reads. JSON deserialization, model binding, and all other serialization frameworks still use Validate. See Object Factories -- HasCorrespondingConstructor for full details.
Per-property -- manual HasConversion call for each property.
The registration of a value converter can be done manually by using one of the method overloads of HasConversion in OnModelCreating.
This approach is not recommended because
Createperforms validation logic on every read, adding unnecessary overhead. Data loaded from the database is the source of truth and doesn't need re-validation. Use Option 2, 3, or 4 to register a value converter that uses the constructor directly (by default).
// Entity
public class Product
{
// other properties...
public ProductName Name { get; private set; }
}
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(builder =>
{
builder.Property(p => p.Name)
.HasConversion(name => (string)name,
s => ProductName.Create(s));
});
}
}Entity Framework Core value conversion is for simple value objects only. Treating a complex value object as an owned entity or complex type is more suitable than storing multiple members in a single column.
// Entity
public class Product
{
// other properties...
public Boundary Boundary { get; private set; }
}
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(builder =>
{
builder.ComplexProperty(p => p.Boundary,
boundaryBuilder =>
{
boundaryBuilder.Property(b => b.Lower).HasColumnName("Lower").HasPrecision(18, 2);
boundaryBuilder.Property(b => b.Upper).HasColumnName("Upper").HasPrecision(18, 2);
});
});
}
}The following options use generated value converters that bypass factory validation by default. Option 4 (
DbContextOptionsBuilder) is recommended for most projects because it applies globally and supports automatic max-length configuration.
Per-DbContext -- applies to all entities in a single DbContext.
Alternatively, you can install the appropriate NuGet package for EF Core 8, EF Core 9 or EF Core 10 and use the extension method AddThinktectureValueConverters to register the value converters for you.
Basic usage (automatic configuration):
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddThinktectureValueConverters();
}
}Per-entity -- applies to all value object properties within a specific entity.
You can also configure value converters at the entity level using the AddThinktectureValueConverters extension method on EntityTypeBuilder. This approach allows you to apply value converters only to specific entities.
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(builder =>
{
builder.HasKey(p => p.Id);
// Apply value converters to all Smart Enums and Value Objects in this entity
builder.AddThinktectureValueConverters();
});
}
}You can customize the conversion behavior using a Configuration object:
builder.AddThinktectureValueConverters(new Configuration
{
UseConstructorForRead = true // Use constructor instead of factory method when reading (default: true)
});This method is also available for owned entities through the OwnedNavigationBuilder:
modelBuilder.Entity<Order>(builder =>
{
builder.OwnsOne(o => o.ShippingAddress, addressBuilder =>
{
// Apply value converters to all Smart Enums and Value Objects in this owned entity
addressBuilder.AddThinktectureValueConverters();
});
});Method overload for ComplexPropertyBuilder:
modelBuilder.Entity<Product>(builder =>
{
builder.ComplexProperty(p => p.Boundary, boundaryBuilder =>
{
boundaryBuilder.Property(b => b.Lower).HasColumnName("Lower").HasPrecision(18, 2);
boundaryBuilder.Property(b => b.Upper).HasColumnName("Upper").HasPrecision(18, 2);
// Apply value converters to any Smart Enums or Value Objects within this complex type
boundaryBuilder.AddThinktectureValueConverters();
});
});Global -- applies to all DbContext instances using these options. Recommended.
The recommended approach is to use the extension method UseThinktectureValueConverters for the DbContextOptionsBuilder with the new Configuration API.
Default configuration (recommended - enables automatic max length calculation for string-based smart enums):
services
.AddDbContext<DemoDbContext>(builder => builder
.UseSqlServer(connectionString)
.UseThinktectureValueConverters())This uses Configuration.Default, which automatically:
- Calculates max length for string-based smart enums from their items
- Rounds up to the next multiple of 10
- Does not overwrite existing max length configurations
- Does NOT set max length for keyed value objects by default
Custom configuration with the new Configuration API:
services
.AddDbContext<DemoDbContext>(builder => builder
.UseSqlServer(connectionString)
.UseThinktectureValueConverters(new Configuration
{
SmartEnums = new SmartEnumConfiguration
{
MaxLengthStrategy = DefaultSmartEnumMaxLengthStrategy.Instance
},
KeyedValueObjects = new KeyedValueObjectConfiguration
{
// Set a custom max length strategy for value objects
MaxLengthStrategy = new CustomKeyedValueObjectMaxLengthStrategy((type, keyType) =>
{
if (type == typeof(ProductName))
return 200;
if (type == typeof(EmailAddress))
return 320; // Max email length per RFC 5321
return MaxLengthChange.None;
})
}
}))Available max length strategies:
For Smart Enums:
-
DefaultSmartEnumMaxLengthStrategy.Instance- Automatically calculates max length from string-based enum items, rounds to next 10 -
FixedSmartEnumMaxLengthStrategy- Sets a fixed max length for all smart enums -
CustomSmartEnumMaxLengthStrategy- Custom logic via delegate -
NoOpSmartEnumMaxLengthStrategy.Instance- No automatic max length configuration
For Keyed Value Objects:
-
FixedKeyedValueObjectMaxLengthStrategy- Sets a fixed max length for all keyed value objects -
CustomKeyedValueObjectMaxLengthStrategy- Custom logic via delegate -
NoOpKeyedValueObjectMaxLengthStrategy.Instance- No automatic max length configuration (default)
Use MaybeBound<T> when you need Minimal API endpoints to return specific validation error messages instead of generic 400 responses.
Minimal API parameter binding has limited support for returning detailed validation errors from TryParse. If binding fails, it typically results in a generic 400 Bad Request. The MaybeBound wrapper pattern provides a way to capture validation errors and return them to the client.
public interface IMaybeBound
{
public string? Error { get; }
}
public class MaybeBound<T, TKey, TValidationError> : IMaybeBound
where T : IObjectFactory<T, TKey, TValidationError>
where TKey : IParsable<TKey>
where TValidationError : class, IValidationError<TValidationError>
{
private readonly T? _value;
public T? Value => Error is null ? _value : throw new ValidationException(Error);
public string? Error { get; }
private MaybeBound(T? value, string? error)
{
_value = value;
Error = error;
}
public static bool TryParse(
string s,
IFormatProvider? formatProvider,
out MaybeBound<T, TKey, TValidationError> value)
{
if (!TKey.TryParse(s, formatProvider, out var key))
{
value = new MaybeBound<T, TKey, TValidationError>(
default,
$"The value '{s}' cannot be converted to '{typeof(TKey).FullName}'.");
}
else
{
var validationError = T.Validate(key, formatProvider, out var obj);
value = validationError is null
? new(obj, null)
: new(default, validationError.ToString() ?? "Invalid format");
}
return true;
}
}The endpoint uses MaybeBound instead of the value object directly, with an endpoint filter to check the result:
app.MapGet(
"/api/users/{email}",
(MaybeBound<EmailAddress, string, ValidationError> email) => email.Value)
.AddEndpointFilter(async (context, next) =>
{
var maybeBound = context.GetArgument<IMaybeBound>(0);
return maybeBound.Error is not null
? Results.BadRequest(maybeBound.Error)
: await next(context);
});With .NET 10 model validation, the MaybeBound class can implement IValidatableObject to become self-validating, eliminating the need for the endpoint filter:
public class MaybeBound<T, TKey, TValidationError> : IMaybeBound,
IValidatableObject // for .NET 10
{
// ... existing members ...
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
return Error is null
? []
: [new ValidationResult(Error, [validationContext.MemberName ?? nameof(Value)])];
}
}- Home
- Smart Enums
- Value Objects
- Discriminated Unions
- Object Factories
- Analyzer Diagnostics
- Source Generator Configuration
- Convenience methods and classes
- Migrations
- Version 7
- Version 8