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.

In this article:

pg
Pawel Gerr is architect and consultant at Thinktecture. He focuses on backends with .NET Core and knows Entity Framework inside out.

Article series

  1. Discriminated Unions: Representation of Alternative Types in .NET
  2. Pattern Matching with Discriminated Unions in .NET
  3. Discriminated Unions in .NET: Modeling States and Variants ⬅

Estimated reading time : 15 min.

Introduction

In the first article of this series, we introduced discriminated unions as a type-safe way to represent values that could be one of several distinct types. The second article explored the powerful pattern matching capabilities provided by the library Thinktecture.Runtime.Extensions, emphasizing compile-time exhaustiveness checking.

This article shifts focus to the application of discriminated unions within the context of Domain-Driven Design (DDD). We’ll explore how they serve as a valuable tool for modeling complex domain states and variants, enhancing clarity, enforcing invariants, and bridging the gap between business concepts and code implementation.

Discriminated Unions in the DDD Context

In DDD, the goal is to create software models that closely reflect the domain of the business. Discriminated unions fit naturally into this philosophy:

  • Modeling Alternatives: They explicitly represent the “one-of” nature of many domain concepts (e.g., a Result is either Success or Failure; a ContactMethod is either Email or Phone).
  • Enforcing Invariants: By design, a discriminated union instance can only be one of its defined cases, preventing invalid states at the type level. Each case can enforce its own specific invariants (e.g., a Shipped order state must have a tracking number).
  • Clarity and Intent: The definition of a discriminated union clearly communicates the possible variations of a concept, making the domain logic easier to understand.
  • Integration with Other Patterns: They combine well with other DDD patterns like value objects (where a union case itself can be a value object), Entities (where an entity’s state can be represented by a discriminated union), and Smart Enums.

Domain-Specific Behavior and State Transitions

Discriminated unions aren’t just data containers; they can encapsulate state-specific behavior. Using the exhaustive Switch and Map methods (covered in the previous article), you can implement logic that varies depending on the current case of the union.

				
					// Example: OrderState from previous articles
[Union]
public abstract partial record OrderState
{
    // Behavior specific to the state
    public abstract bool CanCancel(); 

    public sealed record Pending(DateTime CreatedAt) : OrderState
    {
        public override bool CanCancel() => true; // Can cancel while pending
    }

    public sealed record Shipped(DateTime ShippedAt, string TrackingNumber) : OrderState
    {
        public override bool CanCancel() => false; // Cannot cancel once shipped
    }

   // more states ...    
}

// Usage
OrderState currentState = /* get state */;

if (currentState.CanCancel())
{
    // Proceed with cancellation
} 
				
			

Alternatively, behavior can be implemented externally using the Switch method, which is often preferred for state transitions or operations involving multiple domain objects:

				
					public bool TryCancelOrder(Order order)
{
    return order.CurrentState.Switch(
        pending: _ => 
        { 
            order.SetState(new OrderState.Cancelled(DateTime.UtcNow, "Cancelled by user")); 
            return true; 
        },
        shipped: _ => false, // Cannot cancel
        
        // handling of other states
    );
}
				
			

This approach keeps state transition logic cohesive and leverages the compile-time exhaustiveness check of the method Switch. This method ensures that all possible states are considered, and the logic explicitly defines which transitions are allowed and which are not.

📝 This pattern of using a discriminated union to represent the state of an entity (like an Order) and controlling transitions via methods that use Switch is very common and effective. It’s also applicable to modeling lifecycles or processing workflows.

Example: Partially Known Date

Consider modeling dates where the exact day or month might be unknown, which is common in historical records.

				
					[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)
   {
      // Validation of the month
   }

   public sealed record Date(int Year, int Month, int Day) : PartiallyKnownDate(Year)
   {
      // Validation of the date
   }

   // Convenience method
   public static implicit operator PartiallyKnownDate(DateOnly dateOnly) =>
      new Date(dateOnly.Year, dateOnly.Month, dateOnly.Day);
}
				
			

DDD Principles in Action:

  • Modeling Variations: Directly represents the different ways a date can be known (year, year-month or full date) as described in the domain.
  • Ubiquitous Language: The types YearOnlyYearMonthDate mirror how users might describe these partial dates.
  • State-Specific Data: Each case holds only the data relevant to its level of precision.
  • Encapsulation: Validation specific to each case (e.g., valid month) can be encapsulated within that case.

We can define domain-specific operations using Switch:

				
					// 🛠️ Helper for formatting dates based on the known parts
public string Format(PartiallyKnownDate date)
{
   return date.Switch(
      yearOnly: y => $"{y.Year:D4}",
      yearMonth: ym => $"{ym.Year}-{ym.Month}",
      date: d => $"{d.Year}-{d.Month:D2}-{d.Day:D2}"
   );
}

// Usage
PartiallyKnownDate birthYear = new PartiallyKnownDate.YearOnly(1980);
PartiallyKnownDate eventDate = new PartiallyKnownDate.Date(2024, 03, 15);

Format(birthYear); // 1980
Format(eventDate); // 2024-03-15
				
			

The “regular” union is ideal here because the cases represent related variations of the “Date” concept, share a common property (Year), and benefit from specific data and potential validation per case.

Example: Jurisdiction - Modeling with Value Objects and Unions

This example combines discriminated unions with value objects to model geographical jurisdictions, which could be a country, a federal state, a district, or unknown.

				
					[Union]
public abstract partial class Jurisdiction
{
   // Case 1: Country ISO code
   [ValueObject<string>(KeyMemberName = "_isoCode")]
   public partial class Country : Jurisdiction
   {
      // Validation specific to Country
      static partial void ValidateFactoryArguments(
        ref ValidationError? validationError, ref string isoCode)
      {
         isoCode = isoCode.Trim().ToUpperInvariant();

         if (isoCode.Length != 2)
            validationError = new ValidationError("ISO code must be exactly 2 characters long.");
      }
   }

   // Case 2: Federal State (assuming states are represented by a number internally)
   [ValueObject<int>]
   public partial class FederalState : Jurisdiction;

   // Case 3: District
   [ValueObject<string>(KeyMemberName = "_name")]
   public partial class District : Jurisdiction
   {
       // 🛠️ Validation specific to District
       static partial void ValidateFactoryArguments(
        ref ValidationError? validationError, ref string name)
       {
           name = name.Trim(); // Normalization
           
           if (string.IsNullOrWhiteSpace(name))
               validationError = new ValidationError("District name must not be empty.");
       }
   }

   // Case 4: Unknown Jurisdiction
   [ComplexValueObject] // (ab)using value object to get equality comparison based on type only
   public partial class Unknown : Jurisdiction;
}
				
			

DDD Principles in Action:

  • Combining Patterns: Demonstrates the synergy between discriminated unions (representing the type of jurisdiction) and value objects (representing the value and rules of each type).
  • Type-Specific Invariants: Each case enforces its own validation rules (ISO code length, non-empty district name).
  • Modeling Related Concepts: Groups distinct but related concepts (country, state, district) under a single Jurisdiction abstraction.
  • Special Cases: Models the “Unknown” state explicitly within the union.

Besides value objects , we can also use Smart Enums with discriminated unions. While a continent as jurisdiction doesn’t make much practical sense, we’ll use it here to illustrate the concept.

				
					// ... 

   // Case 5: Continent
   [SmartEnum<string>]
   public partial class Continent : Jurisdiction
   {
      public static readonly Continent Africa = new("Africa");
      public static readonly Continent Antarctica = new("Antarctica");
      public static readonly Continent Asia = new("Asia");
      public static readonly Continent Australia = new("Australia");
      public static readonly Continent Europe = new("Europe");
      public static readonly Continent NorthAmerica = new("North America");
      public static readonly Continent SouthAmerica = new("South America");
   }
}
				
			

The usage is very similar to PartiallyKnownDate:

				
					// Usage
Jurisdiction germany = Jurisdiction.Country.Create("DE");
Jurisdiction europe = Jurisdiction.Continent.Europe;
Jurisdiction unknownJurisdiction = Jurisdiction.Unknown.Instance;

// 🛠️ Helper method for getting description
public string GetDescription(Jurisdiction jurisdiction)
{
   return jurisdiction.Switch(
      country: c => $"Country: {c}",
      federalState: s => $"Federal State: {s}",
      district: d => $"District: {d}",
      continent: c => $"Continent: {c}",
      unknown: _ => "Jurisdiction is Unknown"
   );
}

GetDescription(germany); // "Country: DE"
GetDescription(europe); // Continent: Europe
GetDescription(unknownJurisdiction); // "Jurisdiction is Unknown"
				
			

Summary

Discriminated unions are a powerful tool in the Domain-Driven Design toolkit. They allow developers to model domain concepts with multiple states or variations in a type-safe, explicit, and maintainable way. By representing alternatives directly in the type system, they help enforce domain invariants and encapsulate state-specific data and behavior.

Using features like the Switch method provided by Thinktecture.Runtime.Extensions, developers can implement state-dependent logic and transitions with compile-time guarantees that all cases are handled. This significantly reduces the risk of runtime errors compared to traditional flag-based approach. Whether modeling result types, entity states, or complex domain variants like PartiallyKnownDate or Jurisdiction, discriminated unions offer a clearer and safer way to build domain models that accurately reflect business reality.

The next article in this series will delve into the practical aspects of integrating discriminated unions with common frameworks and libraries, such as JSON serializers, ASP.NET Core model binding, and Entity Framework Core.

Free
Newsletter

Current articles, screencasts and interviews by our experts

Don’t miss any content on Angular, .NET Core, Blazor, Azure, and Kubernetes and sign up for our free monthly dev newsletter.

EN Newsletter Anmeldung (#7)
Related Articles
.NET
pg
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
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 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