-
Notifications
You must be signed in to change notification settings - Fork 1
Value Objects Integration with Frameworks and Libraries
Value Objects in .NET provide a structured way to improve consistency and maintainability in domain modeling. This article examines their integration with popular frameworks and libraries, highlighting best practices for seamless implementation. From working with Entity Framework to leveraging their advantages in ASP.NET, we explore how Value Objects can be effectively incorporated into various architectures. By understanding their role in framework integration, developers can optimize data handling and enhance code clarity without unnecessary complexity.
Article series
- Value Objects: Solving Primitive Obsession in .NET
- Handling Complexity: Introducing Complex Value Objects in .NET
- Value Objects in .NET: Integration with Frameworks and Libraries ⬅
In the first article, we established the foundation of value objects for solving primitive obsession. Article 2 expanded on this by introducing complex value objects for modeling concepts with multiple related properties.
While a well-designed domain model with value objects is beneficial, its practical utility depends on seamless integration with the application’s infrastructure. How do we serialize these objects to JSON? How does ASP.NET Core bind incoming request data to them? How do we persist them in a database using Entity Framework Core?
This article addresses these practical integration challenges. We’ll explore how value objects created with Thinktecture.Runtime.Extensions integrate with common .NET frameworks, ensuring that your rich domain model works smoothly across application boundaries.
Consider a team that has successfully modeled concepts like Amount
, EmailAddress
, and DeliveryAddress
as value objects. However, they now face questions like:
- When returning an
Order
DTO containing anAmount
value object from a Web API, how is it serialized to the numeric value10.50
? - If an API endpoint accepts an
EmailAddress
as a query parameter (/api/[email protected]
), how does ASP.NET Core convert the incoming string"[email protected]"
into a validatedEmailAddress
instance? - How do we ensure that our value objects are properly documented in OpenAPI specifications for external partners to understand our API contracts, and how can frontend developers generate accurate TypeScript types from these specifications?
- When saving an
Order
entity containing aDeliveryAddress
value object using Entity Framework Core, how are the address components (street, city, postal code) mapped to database columns?
Without proper integration support, developers might resort to manual mapping layers (e.g., converting value objects to primitives before serialization), which negates many of the benefits and introduces boilerplate. Thinktecture.Runtime.Extensions provides built-in solutions for these common integration points.
Serialization of value objects to and from JSON is essential for Web APIs and data exchange. The library supports both System.Text.Json
and Newtonsoft.Json
.
Serialization Behavior:
-
“Simple” Value Objects (e.g.,
Amount
): Serialize as their underlying key value (e.g.,"10.50"
). -
Complex Value Objects (e.g.,
Boundary
): Serialize as standard JSON objects (e.g.,{"Lower": 1.0, "Upper": 10.0}
). -
Custom Serialization: You can override default behavior using
[ObjectFactory<T>(UseForSerialization = ...)]
to define custom conversion from/toT
(as shown in the section below and in theFileUrn
example in the documentation).
There are 2 integration options:
Add the appropriate NuGet package (Thinktecture.Runtime.Extensions.Json or Thinktecture.Runtime.Extensions.Newtonsoft.Json) as a dependency to the project containing the value objects. The source generator automatically adds the necessary JsonConverterAttribute
to the value object.
<ItemGroup>
<PackageReference Include="Thinktecture.Runtime.Extensions.Json" Version="x.y.z" />
</ItemGroup>
Both, value objects with a key member and complex value objects become automatically JSON-serializable.
[ValueObject<decimal>]
public partial struct Amount;
[ComplexValueObject]
public partial struct Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
}
// Example for Value Object
var amount = Amount.Create(10.50m);
string amountJson = JsonSerializer.Serialize(amount); // 10.50
// Example for complex Value Object
var boundary = Boundary.Create(1.0m, 10.0m);
string boundaryJson = JsonSerializer.Serialize(boundary); // {"Lower":1.0,"Upper":10.0}
If you prefer not to add a direct dependency to your domain project, install the package in your application host project and register the corresponding converter factory manually (ThinktectureJsonConverterFactory
or ThinktectureNewtonsoftJsonConverterFactory
)
// ASP.NET Core MVC
builder.Services.AddControllers()
.AddJsonOptions(options =>
options.JsonSerializerOptions.Converters.Add(new ThinktectureJsonConverterFactory()));
// Minimal APIs
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new ThinktectureJsonConverterFactory()));
This approach keeps serialization concerns out of the domain project but requires explicit setup.
When handling requests, ASP.NET Core automatically maps incoming HTTP data (from route, query string, or form) to the parameters of your controller actions and API endpoints.
Binding of complex value objects from primitive route/query parameters requires custom logic. You can enable this by implementing a string-based factory using [ObjectFactory<string>]
on your complex value object as described in the section below.
MVC provides an extensible model binding pipeline. To enable binding and validation of value objects, register the ThinktectureModelBinderProvider
from the Thinktecture.Runtime.Extensions.AspNetCore package.
<ItemGroup>
<PackageReference Include="Thinktecture.Runtime.Extensions.AspNetCore" Version="x.y.z" />
</ItemGroup>
builder.Services.AddControllers(options =>
{
options.ModelBinderProviders.Insert(0, new ThinktectureModelBinderProvider());
});
This provider enables binding of value objects from route, query, and form data. If value object (like EmailAddress
) returns an error for an invalid input, the ModelState
becomes invalid. The caller gets an automatic 400 Bad Request
response, when using [ApiController]
.
[Route("api/users")]
[ApiController]
public class UsersController : Controller
{
// Binds value object from route, assuming EmailAddress is [ValueObject<string>]
[HttpGet("{email}")]
public IActionResult GetUser(EmailAddress email)
{
// Invalid email will lead to invalid ModelState.
// 📝 This manual check is not required when using [ApiController]
// because ASP.NET Core will reject the request automatically.
if (!ModelState.IsValid)
return BadRequest(ModelState);
// ... find user ...
return Ok(/* user */);
}
}
Minimal APIs primarily use IParsable<T>
for binding route and query parameters. Since value objects with parsable key members (like int
, DateTime
, etc.) implement this interface automatically, they work without extra configuration.
// Request: api/users/[email protected]
// Response: "[email protected]"
app.MapGet("/api/users/{email}", (EmailAddress email) => email);
TryParse
. If binding fails, it typically results in a generic response 400 Bad Request
.
As a workaround to provide the validation framework with an error message, we can use the maybe-pattern along with an endpoint filter. Similar as with Nullable<T>
, the pattern provides either the value (on successful binding) or the error message.
An example of such implementation could be a class
implementing the method TryParse
which returns true
in both cases, success and failure. If we don’t return true
then the binding fails and the request will be rejected before making it to the validation.
// Interface without generics for use in endpoint filter
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 previously accepting the EmailAdress
has to use the newly implemented MaybeBound
instead. Furthermore, we need to add an endpoint filter checking the outcome of the model binding.
📝 The endpoint filter for validation can be made more generic (using reflection) or integrated into another validation framework to eliminate the repetitive code.
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 the introduction of model validation in .NET 10 we won’t need the endpoint filter. Instead, the class MaybeBound
must implement IValidatableObject to become self-validating.
public class MaybeBound<T, TKey, TValidationError> : IMaybeBound,
IValidatableObject // for .NET 10
{
// ...
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
return Error is null
? []
: [new ValidationResult(Error, [validationContext.MemberName ?? nameof(Value)])];
}
}
Value Objects can be integrated with Swashbuckle to provide OpenAPI documentation for Web APIs. Install the package Thinktecture.Runtime.Extensions.Swashbuckle and register the filters with dependency injection to enable OpenAPI support:
services.AddSwaggerGen()
.AddThinktectureOpenApiFilters();
You can customize the OpenAPI schema generation with options. Currently, there is one option that determines what members of a complex value object are considered required in the OpenAPI schema.
services.AddThinktectureOpenApiFilters(options =>
{
options.RequiredMemberEvaluator = RequiredMemberEvaluator.All;
});
The available options are:
-
Default
: A member is considered required, if:- it is a
struct
withAllowDefaultStructs
equals tofalse
(which is the default) - it is a non-nullable reference type.
- it is a
-
All
: All members are flagged as required. -
None
: Members are not flagged as required. -
FromDependencyInjection
: Resolves implementation ofIRequiredMemberEvaluator
from dependency injection
Alternatively, you case use RequiredAttribute
to flag specific properties as required:
[ComplexValueObject]
public partial class Boundary
{
[Required]
public decimal Lower { get; }
public decimal Upper { get; }
}
With the provided configuration, the decimal
-based value object Amount
is described as a number
, and the complex value object Boundary
as an object
having all properties flagged as required
.
{
"schemas": {
"Amount": {
"type": "number",
"format": "double"
},
"Boundary": {
"required": [
"lower",
"upper"
],
"type": "object",
"properties": {
"lower": {
"type": "number",
"format": "double",
"readOnly": true
},
"upper": {
"type": "number",
"format": "double",
"readOnly": true
}
},
"additionalProperties": false
}
}
}
Value objects with a key member are typically mapped to a single column corresponding to their key member type using EF Core’s Value Converters. Complex value objects are usually mapped using EF Core’s Complex Properties or Owned Entity Types and are thus not touched by the library.
There are different packages for different EF Core versions: Thinktecture.Runtime.Extensions.EntityFrameworkCore{Version}
(e.g., EntityFrameworkCore8
). The value converters can be registered globally, e.g. for the whole DbContext
or just for an entity/property.
Value converters can be registered either when setting up the DbContext
or inside OnModelCreating
.
// via DbContextOptionsBuilder
builder.Services.AddDbContext<MyDbContext>(options => options.UseThinktectureValueConverters());
// Alternatively, via ModelBuilder
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Registers converters for all value objects with a key member
modelBuilder.UseThinktectureValueConverters();
}
📝By default, registered value converters use constructors instead of factories when loading data because databases are usually considered the source of truth. Besides that, using factories along with their validation will lead to a considerable performance penalty.
Once configured, you can use value objects naturally in your entities and LINQ queries. EF Core handles the conversion behind the scenes. In the example below the value object Amount
will be persisted as decimal
.
public class Product
{
public int Id { get; set; }
public Amount Price { get; set; }
}
// Usage
var price = Amount.Create(100m);
// Creation
var product = new Product { Price = price };
dbContext.Products.Add(product);
await dbContext.SaveChangesAsync();
// Query
var results = await dbContext.Products.Where(p => p.Price >= price).ToListAsync();
Due to implicit conversion to key type, which is enabled by default, you can use primitive types in conditions as well.
var results = await dbContext.Products.Where(p => p.Price >= 100m).ToListAsync();
By default, value objects serialize to their natural representation: simple value objects to their underlying primitive type (e.g., a string
for EmailAddress
) and complex value objects to a JSON object (e.g., {"Lower": 1.0, "Upper": 10.0}
). However, there are scenarios where a different, often more compact, representation is needed. This feature can be applied to both simple and complex value objects. For instance, you might want to represent the value object Boundary
as a single string like 1.5:2.5
for use in route or query parameters.
The library enables this custom conversion through the [ObjectFactoryAttribute<T>]
. By applying this attribute, you can define how your value object is converted to and from another type, most commonly a string
. This custom representation can then be used for JSON serialization, ASP.NET Core model binding, OpenAPI documentation, and Entity Framework Core persistence.
To implement a custom conversion, you must decorate the value object with the [ObjectFactory<T>]
. The source generator will add one or two interfaces to your value object that you need to implement. The method Validate
is for parsing the custom format and creating an instance of the value object. The method ToValue
is for converting the value object back into its custom representa
[ComplexValueObject]
[ObjectFactory<string>(
UseForSerialization = SerializationFrameworks.All, // JSON, MessagePack
UseForModelBinding = true, // Model Binding, OpenAPI
UseWithEntityFramework = true)] // Entity Framework Core
public partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
// Required for deserialization; expects format "lower:upper"
public static ValidationError? Validate(
string? value, // e.g. "1.5:2.5"
IFormatProvider? provider,
out Boundary? item)
{
item = null;
if (value is null)
return null;
// Split the string (or Span<char>) and parse the parts
var parts = value.Split(":",
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
return new ValidationError("Invalid format. Expected 'lower:upper', e.g. '1.5:2.5'");
if (!decimal.TryParse(parts[0], provider, out var lower) ||
!decimal.TryParse(parts[1], provider, out var upper))
return new ValidationError("Invalid numbers. Expected decimal values, e.g. '1.5:2.5'");
// Delegate to regular validation
return Validate(lower, upper, out item);
}
// Required for serialization
public string ToValue() => $"{Lower}:{Upper}"; // e.g. "1.5:2.5"
}
With the [ObjectFactory<T>]
attribute, you define the custom conversion logic for a value object. However, this attribute alone is not sufficient for framework integration. You must still perform the framework-specific setup described in the other sections. For example, you need to register the ValueObjectModelBinderProvider
for ASP.NET Core, configure Swashbuckle filters for OpenAPI, or call UseThinktectureValueConverters
for Entity Framework Core. The UseFor...
properties on the attribute simply instruct the library on how to handle the value object once they are configured.
Thinktecture.Runtime.Extensions provides the necessary tools to make the integration process for JSON serialization, ASP.NET Core model binding, OpenAPI documentation, and Entity Framework Core as smooth as possible. By leveraging the provided packages, you can ensure that your value objects work seamlessly across your application’s boundaries, allowing you to gain the full benefits of a richer, safer domain model without drowning in boilerplate code.
So far, we’ve explored the technical mechanics of creating and using value objects, but we’ve only scratched the surface of their true power. In the next article, we’ll dive into the strategic role of value objects within Domain-Driven Design – revealing how they serve as a bridge between technical code and business concepts. You’ll discover how value objects act as fundamental building blocks in your domain model alongside entities and aggregates, while helping to create a shared language between developers and domain experts. We’ll explore how they naturally enforce business rules throughout your codebase and encapsulate domain-specific behavior in elegant, reusable ways.