Skip to content

Object Factories

Pawel Gerr edited this page Mar 2, 2026 · 2 revisions

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

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.

One-Way Conversion

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);
    }
}

Two-Way Conversion

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.

Framework Integration Properties

UseForSerialization

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:

UseForModelBinding

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());
});

UseWithEntityFramework

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 = true requires a ToValue() method (see Two-Way Conversion), since EF Core needs to convert the type back to T when 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.

HasCorrespondingConstructor

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:

  • HasCorrespondingConstructor does 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 = true is not allowed for Smart Enums. Smart Enums use predefined items, not constructors.
  • The type must actually have a matching constructor.

Multiple Object Factories

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

Zero-Allocation JSON with ReadOnlySpan<char> (.NET 9+)

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 (not All) since only System.Text.Json supports span-based deserialization. Other frameworks will continue using the regular string-based or key-based conversion.

Type-Specific Notes

Smart Enums

  • HasCorrespondingConstructor = true is not allowed for Smart Enums. Smart Enums use predefined items (public static readonly fields), not constructors.
  • For keyless Smart Enums ([SmartEnum] without a key type), ObjectFactory is 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+. Use DisableSpanBasedJsonConversion = true to opt out.

Value Objects

  • Works with both Simple ([ValueObject<TKey>]) and Complex ([ComplexValueObject]) Value Objects.
  • When SkipFactoryMethods = true is set on a Value Object, serialization converters are normally suppressed. However, if an [ObjectFactory<T>] with UseForSerialization is present, it overrides this suppression and serialization converters are still generated.
  • HasCorrespondingConstructor = true is available for Value Objects, enabling EF Core to bypass validation when loading from the database.

Discriminated Unions

  • Ad-hoc unions ([Union<T1, T2>]) have no type discriminator, so they cannot be serialized as polymorphic JSON. ObjectFactory is the primary mechanism for enabling serialization -- you define how the union is represented as a single value (commonly a string).
  • Regular unions ([Union]) can use EF Core's built-in inheritance (TPH/TPT) for persistence, but ObjectFactory provides an alternative single-value serialization approach.
  • When using ObjectFactory with unions, you still need framework-specific setup (e.g., registering ThinktectureModelBinderProvider for ASP.NET Core, calling UseThinktectureValueConverters() or AddThinktectureValueConverters() for EF Core).

Attribute Property Reference

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

Analyzer Diagnostics

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

Runtime Metadata Priority

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 UseForSerialization overrides the default key-based JSON/MessagePack/Newtonsoft converter for the specified frameworks
  • EF Core: the factory's UseWithEntityFramework = true overrides the default key-based value converter
  • Model binding: the factory's UseForModelBinding = true overrides the default key-based binding

Clone this wiki locally