Discriminated Unions in .NET: Integration with Frameworks and Libraries

A key aspect of adopting any new pattern is understanding how it interacts with the surrounding application infrastructure. When using Discriminated Unions, questions arise: How can a Result union be serialized to JSON? How can an OrderState union be persisted using Entity Framework Core? This article explores practical integration strategies with common .NET frameworks.

In diesem Artikel:

pg
Pawel Gerr ist Architekt und Consultant bei Thinktecture. Er hat sich auf .NET Core Backends spezialisiert und kennt Entity Framework von vorne bis hinten.

Estimated reading time : 10 min.

Introduction

The first article in this series introduced discriminated unions as a type-safe way to model values that can be one of several distinct types. Second article explored the powerful and exhaustive pattern matching capabilities they enable via Switch and Map methods. Third article discussed their application within Domain-Driven Design for modeling states and variants.

This article explores how discriminated unions created with Thinktecture.Runtime.Extensions can be integrated with common .NET frameworks and libraries. We’ll see that integration often relies on leveraging standard framework features, especially for regular unions built with inheritance. Ad hoc unions, due to their structure, present more challenges for seamless framework integration.

Framework Integration Considerations

Using discriminated unions for concepts like API responses (Success, NotFound, etc) or entity states (Pending, Completed) brings type safety and clarity to domain logic. However, integrating these unions with infrastructure layers requires careful consideration:

  • JSON Serialization: How should the different structures of union cases be represented in JSON? How can the correct case be identified during deserialization?
  • ASP.NET Core Model Binding: How can discriminated unions be bound from HTTP requests (route parameters, query strings, form data) in Minimal APIs and controllers?
  • Entity Framework Core Persistence: How can the different cases of a union like OrderState, potentially holding different data, be mapped effectively to a relational database schema?

Integrating these unions effectively requires leveraging appropriate framework mechanisms. While Thinktecture.Runtime.Extensions provides the foundation by generating robust union types, connecting them to frameworks like System.Text.Json, ASP.NET Core, or EF Core usually involves using standard techniques, which differ between ad hoc and regular unions.

JSON Serialization

Serializing discriminated unions requires handling the different structures of each case. For regular unions, this often involves indicating which case is being represented (a discriminator). Unlike Value Objects or Smart Enums, the specific JSON integration packages, like Thinktecture.Runtime.Extensions.Json, don’t provide specific converters for discriminated unions. Instead, we rely on standard framework features or custom implementations.

Regular Unions

Regular unions represent polymorphic types, requiring explicit configuration of corresponding JSON serializer.

  • System.Text.Json: Use the built-in polymorphic serialization support. Apply [JsonDerivedType(typeof(DerivedType), "discriminator")] attributes to the base union class for each nested case. This adds a type discriminator ($type) to the JSON, allowing the deserializer to instantiate the correct derived type.
  • Newtonsoft.Json: Requires the configuration of TypeNameHandling which adds a type discriminator ($type) to the JSON.

Consider a PartiallyKnownDate union that represents dates with varying levels of precision. This example demonstrates how to apply the [JsonDerivedType] attributes for proper polymorphic serialization:

				
					[JsonDerivedType(typeof(PartiallyKnownDate.YearOnly), "Year")]
[JsonDerivedType(typeof(PartiallyKnownDate.YearMonth), "YearMonth")]
[JsonDerivedType(typeof(PartiallyKnownDate.Date), "Date")]
[Union]
public abstract partial record PartiallyKnownDate
{
    public int Year { get; }

    private PartiallyKnownDate(int year)
    {
        Year = year;
    }

    public sealed record YearOnly(int Year) : PartiallyKnownDate(Year);
    public sealed record YearMonth(int Year, int Month) : PartiallyKnownDate(Year);
    public sealed record Date(int Year, int Month, int Day) : PartiallyKnownDate(Year);
}

var date = new PartiallyKnownDate.Date(2024, 3, 15);

// JSON: {"$type":"Date","Year":2024,"Month":3,"Day":15} 
string json = JsonSerializer.Serialize<PartiallyKnownDate>(date);
PartiallyKnownDate? deserialized = JsonSerializer.Deserialize<PartiallyKnownDate>(json);
				
			

Ad hoc Unions

While the source generator doesn’t provide a specific JSON converter for ad hoc unions due to the lack of a built-in discriminator, serialization and deserialization can be achieved. This is done by using the ObjectFactoryAttribute<T> which is described in the section below.

Custom Type Conversion

While regular unions serialize to a polymorphic JSON object, ad hoc unions cannot be serialized due to missing type discriminator. However, there are scenarios where a union needs a custom serialization. For instance, you might want to represent a union as a single string.

The library enables this custom conversion through the [ObjectFactoryAttribute<T>]. By applying this attribute, you can define how your union 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, and Entity Framework Core persistence.

To implement a custom conversion, you must decorate the union with the [ObjectFactory<T>]. The source generator will add one or two interfaces to your union that you need to implement. The method Validate is for parsing the custom format and creating an instance of the union. The method ToValue is for converting the union back into its custom representation.

The TextOrNumber example from previous sections illustrates this for an ad hoc union:

				
					[Union<string, int>(T1Name = "Text", T2Name = "Number")]
[ObjectFactory<string>(
   UseForSerialization = SerializationFrameworks.All, // JSON, MessagePack
   UseForModelBinding = true,                         // Model Binding, OpenAPI
   UseWithEntityFramework = true)]                    // Entity Framework Core
public partial class TextOrNumber
{
   // For serialization (implementation of IConvertible<string>)
   public string ToValue()
   {
      return Switch(text: t => $"Text|{t}",
                    number: n => $"Number|{n}");
   }

   // For deserialization (implementation of IObjectFactory<TextOrNumber, string, ValidationError>)
   public static ValidationError? Validate(
      string? value, IFormatProvider? provider, out TextOrNumber? item)
   {
      if (value.StartsWith("Text|", StringComparison.OrdinalIgnoreCase))
      {
         item = value.Substring(5); // successful deserialization of the text
         return null;
      }

      if (value.StartsWith("Number|", StringComparison.OrdinalIgnoreCase))
      {
         if (Int32.TryParse(value.Substring(7), out var number))
         {
            item = number; // successful deserialization of the number
            return null;
         }

         item = null;
         return new ValidationError("Invalid number format");
      }

      item = null;
      return new ValidationError("Invalid format");
   }
}
				
			

With the [ObjectFactory<T>] attribute, you define the custom conversion logic for a union. 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 a ModelBinderProvider for ASP.NET Core or use UseThinktectureValueConverters for Entity Framework Core. The UseFor... properties on the attribute simply instruct the library on how to handle the union once they are configured.

ASP.NET Core Model Binding

Similar to JSON serialization, there’s no direct, built-in support for model binding of unions in ASP.NET Core (e.g., from route parameters, query strings, or form data) provided by the library itself.

However, you can enable model binding by using the ObjectFactoryAttribute<string> as shown above. By using a string as the serialization type, we get the implementation of IParsable<TUnion> for free. The object factory and TryParse enable both ASP.NET Core MVC and Minimal API to correctly parse incoming values into your unions.

For best results with ASP.NET Core MVC (i.e. controllers), register the ThinktectureModelBinderProvider from the Thinktecture.Runtime.Extensions.AspNetCore package at the beginning of the pipeline to handle unions before default model binders.

				
					services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(0, new ThinktectureModelBinderProvider());
});
				
			

Entity Framework Core

Persisting discriminated unions with EF Core requires mapping the different cases to a database schema. This relies on standard EF Core features or custom implementations, not specific helpers from Thinktecture.Runtime.Extensions.

Ad hoc Unions

Ad hoc unions can be persisted in Entity Framework Core using the same ObjectFactoryAttribute<T> approach demonstrated earlier for JSON serialization and ASP.NET Core model binding. By implementing the required interfaces (IConvertible<T> and IObjectFactory<...>), the union can be converted to and from a primitive type that EF Core can handle.

EF Core’s ValueConverter is used to bridge between the union type and its primitive representation.

				
					// Entity containing the union
public class Document
{
    public required TextOrNumber Content { get; set; }
}

public class DocumentDbContext : DbContext
{
   protected override void OnModelCreating(ModelBuilder modelBuilder)
   {
      // Adds value converters
      modelBuilder.AddThinktectureValueConverters();
   }
}
				
			

Regular Unions

Regular unions can be persisted using EF Core’s built-in inheritance. With Table-Per-Hierarchy (TPH) all cases are stored in a single table with a discriminator. With Table-Per-Type (TPT), every type gets a separate table.

Consider a MessageState union that tracks the processing state of messages in a system:

				
					[Union]
public abstract partial record MessageState
{
   public sealed record Initial : MessageState;
   public sealed record Parsed(DateTime CreatedAt) : MessageState;
   public sealed record Processed(DateTime CreatedAt) : MessageState;
   public sealed record Error(string Message) : MessageState;
}
				
			

Each case of the message state captures the specific data relevant to that state – Initial has no additional data, Parsed and Processed track when the state was reached, and Error stores the error message. The union uses inheritance with a base abstract record and derived records for each case.

This configuration uses EF Core’s Table-Per-Hierarchy (TPH) strategy to store all MessageState cases in a single table. The StateType column acts as a discriminator, storing string values that identify which specific case each row represents. When querying, EF Core automatically filters and materializes the correct derived type based on this discriminator value.

				
					protected override void OnModelCreating(ModelBuilder modelBuilder)
{
   modelBuilder.Entity<Message>(builder =>
   {
      // Configure TPH discriminator using standard EF Core Fluent API
      builder.HasDiscriminator<string>("StateType")     // Discriminator column name
             .HasValue<MessageState.Initial>("Initial") // Discriminator value for each case
             .HasValue<MessageState.Parsed>("Parsed")
             .HasValue<MessageState.Processed>("Processed")
             .HasValue<MessageState.Error>("Error");
   });

   // Tell EF to use the same column for CreatedAt
   modelBuilder.Entity<MessageState.Parsed>(builder =>
   {
      builder.Property(s => s.CreatedAt).HasColumnName("StateCreatedAt");
   });

   modelBuilder.Entity<MessageState.Processed>(builder =>
   {
      builder.Property(s => s.CreatedAt).HasColumnName("StateCreatedAt");
   });
}
				
			

Summary

This article explored how discriminated unions integrate with common .NET frameworks and libraries. The key insight is that integration relies on standard framework features rather than specialized library support. Ad hoc unions use the ObjectFactoryAttribute<T> pattern to convert to primitive types, enabling consistent behavior across JSON serialization, ASP.NET Core model binding, and Entity Framework Core persistence. Regular unions leverage their inheritance structure with built-in features like polymorphic JSON serialization and EF Core’s TPH/TPT mapping. This approach enables discriminated unions to integrate with .NET frameworks through standard mechanisms, though in some cases it requires implementing custom conversion logic rather than relying on pre-built converters.

Kostenloser
Newsletter

Aktuelle Artikel, Screencasts, Webinare und Interviews unserer Experten für Sie

Verpassen Sie keine Inhalte zu Angular, .NET Core, Blazor, Azure und Kubernetes und melden Sie sich zu unserem kostenlosen monatlichen Dev-Newsletter an.

Newsletter Anmeldung
Diese Artikel könnten Sie interessieren
.NET
pg

Advanced Value Object Patterns in .NET

While basic value objects solve primitive obsession, complex domain requirements need sophisticated modeling techniques. This article explores advanced patterns using Thinktecture.Runtime.Extensions to tackle real-world scenarios: open-ended dates for employment contracts, composite file identifiers across storage systems, recurring anniversaries without year components, and geographical jurisdictions using discriminated unions.
19.10.2025
.NET
pg

Discriminated Unions in .NET: Modeling States and Variants

Domain models often involve concepts that exist in multiple distinct states or variations. Traditional approaches using enums and nullable properties can lead to invalid states and scattered logic. This article explores how discriminated unions provide a structured, type-safe way to model domain variants in .NET, aligning perfectly with Domain-Driven Design principles while enforcing invariants at the type level.
06.10.2025
.NET
pg

Smart Enums in .NET: Integration with Frameworks and Libraries

Learn how to seamlessly integrate Smart Enums with essential .NET frameworks and libraries. This article covers practical solutions for JSON serialization, ASP.NET Core model binding for both Minimal APIs and MVC controllers, and Entity Framework Core persistence using value converters. Discover how Thinktecture.Runtime.Extensions provides dedicated packages to eliminate integration friction and maintain type safety across your application stack.
21.09.2025
.NET
pg

Value Objects in .NET: Enhancing Business Semantics

Value objects are fundamental building blocks in Domain-Driven Design, serving far more than simple data wrappers. This article explores their strategic importance in bridging technical code and business concepts, enforcing domain rules, and fostering clearer communication with domain experts. Learn how to build robust aggregates, cultivate ubiquitous language, and encapsulate domain-specific behavior using Thinktecture.Runtime.Extensions in .NET applications.
16.09.2025
.NET
pg

Pattern Matching with Discriminated Unions in .NET

Traditional C# pattern matching with switch statements and if/else chains is error-prone and doesn't guarantee exhaustive handling of all cases. When you add new types or states, it's easy to miss updating conditional logic, leading to runtime bugs. The library Thinktecture.Runtime.Extensions solves this with built-in Switch and Map methods for discriminated unions that enforce compile-time exhaustiveness checking.
26.08.2025
.NET
pg

Value Objects in .NET: 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.
12.08.2025