-
Notifications
You must be signed in to change notification settings - Fork 5
Object Factories
The ObjectFactoryAttribute<T> enables custom conversion between your type and an arbitrary type T. It works with Smart Enums, Value Objects, and Discriminated Unions.
- Overview
- One-Way Conversion
- Two-Way Conversion
- Framework Integration Properties
- Multiple Object Factories
- Zero-Allocation JSON with ReadOnlySpan<char> (.NET 9+)
- Type-Specific Notes
- Attribute Property Reference
- Analyzer Diagnostics
- Runtime Metadata Priority
When you apply [ObjectFactory<T>], the source generator adds the IObjectFactory<YourType, T, ValidationError> interface to your type, requiring you to implement a static Validate method. If T is string, the source generator also implements IParsable<YourType> automatically.
Common scenarios:
-
Model binding:
[ObjectFactory<string>]enables ASP.NET Core to bind types with any key type (not just string-keyed ones). - Custom serialization: Override the default key-based serialization with a custom format.
-
Cross-framework consistency: One pair of methods (
Validate+ToValue) serves JSON serialization, model binding, and EF Core persistence simultaneously.
Apply [ObjectFactory<T>] and implement the required Validate method. This creates a one-way conversion from T to your type.
Smart Enum example -- an int-based Smart Enum that also accepts specific strings:
[SmartEnum<int>]
[ObjectFactory<string>]
public partial class MyEnum
{
public static readonly MyEnum Item1 = new(1);
public static ValidationError? Validate(string? value, IFormatProvider? provider, out MyEnum? item)
{
switch (value)
{
case "=1=":
item = Item1;
return null;
default:
item = null;
return new ValidationError($"Unknown value '{value}'");
}
}
}Value Object example -- a Complex Value Object that accepts a formatted string:
[ComplexValueObject]
[ObjectFactory<string>]
public partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
public static ValidationError? Validate(
string? value,
IFormatProvider? provider,
out Boundary? item)
{
item = null;
if (value is null)
return null;
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'");
return Validate(lower, upper, out item);
}
}Use two-way conversion when you need the factory type for both reading and writing -- typically for serialization, EF Core persistence, or both. Setting UseForSerialization to a non-None value or UseWithEntityFramework = true adds the IConvertible<T> interface to your type, requiring you to implement a ToValue() method.
[SmartEnum<int>]
[ObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)]
public partial class SmartEnumWithFactory
{
public static readonly SmartEnumWithFactory Item1 = new(1);
public static readonly SmartEnumWithFactory Item2 = new(2);
public static ValidationError? Validate(string? value, IFormatProvider? provider, out SmartEnumWithFactory? item)
{
switch (value)
{
case "=1=":
item = Item1;
return null;
case "=2=":
item = Item2;
return null;
default:
item = null;
return new ValidationError($"Unknown value '{value}'");
}
}
// Required by IConvertible<string>
public string ToValue()
{
return $"={Key}=";
}
}The SerializationFrameworks enum controls which serialization frameworks get generated integration code:
| Value | Description |
|---|---|
None |
No serialization integration |
SystemTextJson |
System.Text.Json only |
NewtonsoftJson |
Newtonsoft.Json only |
Json |
Both System.Text.Json and Newtonsoft.Json |
MessagePack |
MessagePack only |
All |
All supported serialization frameworks |
When UseForSerialization is not set (or set to None), no serialization-specific code is generated for the object factory.
Controls which serialization frameworks use this factory for conversion. For keyed types (keyed Smart Enums, simple Value Objects), this replaces the default key-based conversion. For other types (complex Value Objects, unions), this provides single-value serialization support. See Two-Way Conversion for details and the SerializationFrameworks enum values.
For walkthroughs of using multiple serialization frameworks together, see:
- Value Objects -- Multi-Format Serialization
- Smart Enums -- Multi-Format Serialization
- Discriminated Unions -- Multi-Format Serialization (Ad Hoc)
- Discriminated Unions -- Multi-Format Serialization (Regular)
When UseForModelBinding = true, the type T is used for ASP.NET Core Model Binding. For keyed types, this replaces the default key-based binding. For other types (complex Value Objects, unions), this enables model binding from a single value.
[SmartEnum<int>]
[ObjectFactory<string>(UseForModelBinding = true)]
public partial class MyEnum
{
// ...
}This is particularly useful for types whose key type is not a string, or for types without a key at all (complex Value Objects, unions) -- model binding naturally works with strings, so providing an [ObjectFactory<string>(UseForModelBinding = true)] allows ASP.NET Core to bind the type from route/query parameters.
For best results with ASP.NET Core MVC (controllers), register the ThinktectureModelBinderProvider from the Thinktecture.Runtime.Extensions.AspNetCore package:
services.AddControllers(options =>
{
options.ModelBinderProviders.Insert(0, new ThinktectureModelBinderProvider());
});When UseWithEntityFramework = true, the type T is used for persistence with Entity Framework Core. For keyed types, this replaces the default key-based persistence. For other types (complex Value Objects, unions), this enables single-column persistence via a single value.
Note: Setting
UseWithEntityFramework = truerequires aToValue()method (see Two-Way Conversion), since EF Core needs to convert the type back toTwhen writing to the database.
[ComplexValueObject]
[ObjectFactory<string>(UseWithEntityFramework = true)]
public partial class Boundary
{
// ...
}Register the value converters using one of the following approaches (from broadest to most targeted):
Recommended -- register globally via DbContextOptionsBuilder:
services.AddDbContext<DemoDbContext>(builder => builder
.UseSqlServer(connectionString)
.UseThinktectureValueConverters());Model-level -- register for all properties in OnModelCreating:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.AddThinktectureValueConverters();
}Entity-level -- register for a specific entity (also available on OwnedNavigationBuilder and ComplexPropertyBuilder):
modelBuilder.Entity<MyEntity>(builder => builder.AddThinktectureValueConverters());Property-level -- register for a single property:
modelBuilder.Entity<MyEntity>(builder =>
builder.Property(e => e.Boundary).HasThinktectureValueConverter());For full details, configuration options, and the Configuration API, see the EF Core sections in Smart Enums and Value Objects.
When HasCorrespondingConstructor = true, indicates that a constructor accepting a single parameter of type T exists. This constructor is used exclusively by Entity Framework Core when loading data from the database, bypassing validation on the assumption that the database is the source of truth.
[ComplexValueObject]
[ObjectFactory<string>(HasCorrespondingConstructor = true)]
public partial class Boundary
{
// ...
}Important:
-
HasCorrespondingConstructordoes not affect JSON deserialization, MessagePack, Newtonsoft.Json, ASP.NET Core model binding, or any other serialization framework. These always use the factory method (Validate) to ensure validation is applied. -
HasCorrespondingConstructor = trueis not allowed for Smart Enums. Smart Enums use predefined items, not constructors. - The type must actually have a matching constructor.
You can apply multiple [ObjectFactory<T>] attributes with different types to support different conversion scenarios:
[SmartEnum<int>]
[ObjectFactory<string>(UseForSerialization = SerializationFrameworks.SystemTextJson, UseForModelBinding = true)]
[ObjectFactory<byte[]>(UseForSerialization = SerializationFrameworks.MessagePack)]
public partial class MyEnum
{
// Implement Validate for string
public static ValidationError? Validate(string? value, IFormatProvider? provider, out MyEnum? item) { ... }
// Implement Validate for byte[]
public static ValidationError? Validate(byte[]? value, IFormatProvider? provider, out MyEnum? item) { ... }
// Implement ToValue for string (IConvertible<string>)
public string ToValue() { ... }
// Implement ToValue for byte[] (IConvertible<byte[]>)
byte[] IConvertible<byte[]>.ToValue() { ... }
}Conflict rules: When using multiple object factories, each integration point must be unique:
- Only one factory can have
UseWithEntityFramework = true - Only one factory can have
UseForModelBinding = true - Factories must not specify overlapping serialization frameworks in
UseForSerialization
On .NET 9+, you can use ObjectFactory<ReadOnlySpan<char>> to enable zero-allocation JSON deserialization. Instead of allocating a string from the raw UTF-8 JSON bytes, the JSON converter transcodes directly to a ReadOnlySpan<char> and calls your Validate method with the span.
This is especially useful for types whose values are known in advance. C# supports pattern matching ReadOnlySpan<char> against string constants, so for known values no string allocation happens at all.
Simple Value Object example:
[ValueObject<string>]
[ObjectFactory<ReadOnlySpan<char>>(UseForSerialization = SerializationFrameworks.SystemTextJson)]
[KeyMemberEqualityComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>]
[KeyMemberComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>]
public partial struct Name
{
static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string value)
{
if (String.IsNullOrWhiteSpace(value))
{
validationError = new ValidationError("Name cannot be empty.");
return;
}
value = value.Trim();
if (value.Length < 2)
validationError = new ValidationError("Name cannot be less than 2 characters.");
}
// Match known values directly on the span -- no string allocation for these cases
public static ValidationError? Validate(ReadOnlySpan<char> value, IFormatProvider? provider, out Name item)
{
return value switch
{
"Alice" => Validate("Alice", provider, out item),
"Bob" => Validate("Bob", provider, out item),
_ => Validate(value.ToString(), provider, out item) // fallback: allocate only for unknown values
};
}
// Required by IConvertible<ReadOnlySpan<char>>
public ReadOnlySpan<char> ToValue()
{
return _value;
}
}Complex Value Object example -- span slicing replaces string.Split without any allocation:
[ComplexValueObject(DefaultStringComparison = StringComparison.OrdinalIgnoreCase)]
[ObjectFactory<ReadOnlySpan<char>>(UseForSerialization = SerializationFrameworks.SystemTextJson)]
public partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
public static ValidationError? Validate(
ReadOnlySpan<char> value,
IFormatProvider? provider,
out Boundary? item)
{
item = null;
var separatorIndex = value.IndexOf(':');
if (separatorIndex < 0)
return new ValidationError("Invalid format. Expected 'lower:upper'");
if (!decimal.TryParse(value[..separatorIndex], provider, out var lower) ||
!decimal.TryParse(value[(separatorIndex + 1)..], provider, out var upper))
return new ValidationError("Invalid numbers");
return Validate(lower, upper, out item);
}
// Required by IConvertible<ReadOnlySpan<char>>
public ReadOnlySpan<char> ToValue() => $"{Lower}:{Upper}";
}Memory strategy: The JSON converter uses stackalloc for values up to 128 bytes and rents from ArrayPool<char>.Shared for larger values.
Note: Use
UseForSerialization = SerializationFrameworks.SystemTextJson(notAll) since only System.Text.Json supports span-based deserialization. Other frameworks will continue using the regular string-based or key-based conversion.
-
HasCorrespondingConstructor = trueis not allowed for Smart Enums. Smart Enums use predefined items (public static readonlyfields), not constructors. - For keyless Smart Enums (
[SmartEnum]without a key type),ObjectFactoryis the only way to enable serialization and model binding, since there is no key type to convert from. - String-keyed Smart Enums (
[SmartEnum<string>]) already get zero-allocation JSON deserialization automatically on .NET 9+. UseDisableSpanBasedJsonConversion = trueto opt out.
- Works with both Simple (
[ValueObject<TKey>]) and Complex ([ComplexValueObject]) Value Objects. - When
SkipFactoryMethods = trueis set on a Value Object, serialization converters are normally suppressed. However, if an[ObjectFactory<T>]withUseForSerializationis present, it overrides this suppression and serialization converters are still generated. -
HasCorrespondingConstructor = trueis available for Value Objects, enabling EF Core to bypass validation when loading from the database.
-
Ad-hoc unions (
[Union<T1, T2>]) have no type discriminator, so they cannot be serialized as polymorphic JSON.ObjectFactoryis the primary mechanism for enabling serialization -- you define how the union is represented as a single value (commonly astring). -
Regular unions (
[Union]) can use EF Core's built-in inheritance (TPH/TPT) for persistence, butObjectFactoryprovides an alternative single-value serialization approach. - When using
ObjectFactorywith unions, you still need framework-specific setup (e.g., registeringThinktectureModelBinderProviderfor ASP.NET Core, callingUseThinktectureValueConverters()orAddThinktectureValueConverters()for EF Core).
| Property | Type | Default | Description |
|---|---|---|---|
Type |
Type |
typeof(T) |
Value type the factory accepts (readonly) |
UseForSerialization |
SerializationFrameworks |
None |
Which serialization frameworks should use this factory/type |
UseWithEntityFramework |
bool |
false |
Enable EF Core integration for this factory |
UseForModelBinding |
bool (init-only) |
false |
Enable ASP.NET Core model binding |
HasCorrespondingConstructor |
bool (init-only) |
false |
Indicates a single-parameter constructor of type T exists. Used only by EF Core to bypass validation. Not allowed on Smart Enums |
The following diagnostics are specific to [ObjectFactory<T>] usage. For the full list of all diagnostics, see Analyzer Diagnostics.
| Diagnostic | Severity | Description |
|---|---|---|
| TTRESG059 | Error | Type with HasCorrespondingConstructor = true must have a matching constructor |
| TTRESG060 | Error | Smart Enums must not have HasCorrespondingConstructor = true
|
| TTRESG061 | Error | Type must implement a static Validate method with the required signature |
| TTRESG062 | Error | Type must implement ToValue() when UseForSerialization is not None or UseWithEntityFramework is true
|
| TTRESG068 | Error | Multiple factories cannot have UseWithEntityFramework = true
|
| TTRESG069 | Error | Multiple factories cannot have UseForModelBinding = true
|
| TTRESG070 | Error | Multiple factories cannot specify overlapping serialization frameworks |
When a type has both key-based metadata (from [SmartEnum<TKey>] or [ValueObject<TKey>]) and an [ObjectFactory<T>] with a framework integration property enabled, the object factory takes priority for that integration point. For example, if a [SmartEnum<int>] also has [ObjectFactory<string>(UseForSerialization = SerializationFrameworks.SystemTextJson)], JSON serialization will use the string-based factory instead of the int key.
This priority applies per integration point:
-
Serialization: the factory's
UseForSerializationoverrides the default key-based JSON/MessagePack/Newtonsoft converter for the specified frameworks -
EF Core: the factory's
UseWithEntityFramework = trueoverrides the default key-based value converter -
Model binding: the factory's
UseForModelBinding = trueoverrides the default key-based binding
- Home
- Smart Enums
- Value Objects
- Discriminated Unions
- Object Factories
- Analyzer Diagnostics
- Source Generator Configuration
- Convenience methods and classes
- Migrations
- Version 7
- Version 8