Skip to main content

Entity Framework Core Integration

The UnionGenerator.EntityFrameworkCore package enables you to store and query discriminated unions in databases with automatic value converters and query translation.

Overview

This integration provides:

  • Automatic value converters for union types
  • JSON column storage support
  • LINQ query translation
  • Migration support
  • Complex type mapping

Installation

dotnet add package UnionGenerator.EntityFrameworkCore

Requirements:

  • .NET 6.0 or later
  • Entity Framework Core 6.0 or later
  • UnionGenerator package

Quick Start

Define Your Union

[GenerateUnion]
public partial record OrderStatus
{
public static partial OrderStatus Pending();
public static partial OrderStatus Processing(string workerId);
public static partial OrderStatus Shipped(string trackingNumber);
public static partial OrderStatus Delivered(DateTime deliveredAt);
public static partial OrderStatus Cancelled(string reason);
}

Configure Entity

public class Order
{
public int Id { get; set; }
public OrderStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
}

public class AppDbContext : DbContext
{
public DbSet<Order> Orders { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property(o => o.Status)
.HasConversion<OrderStatusConverter>();
}
}

Value Converter

using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using System.Text.Json;

public class OrderStatusConverter : ValueConverter<OrderStatus, string>
{
public OrderStatusConverter()
: base(
v => Serialize(v),
v => Deserialize(v))
{
}

private static string Serialize(OrderStatus status)
{
return status.Match(
pending => JsonSerializer.Serialize(new { Type = "Pending" }),
processing => JsonSerializer.Serialize(new { Type = "Processing", WorkerId = processing.WorkerId }),
shipped => JsonSerializer.Serialize(new { Type = "Shipped", TrackingNumber = shipped.TrackingNumber }),
delivered => JsonSerializer.Serialize(new { Type = "Delivered", DeliveredAt = delivered.DeliveredAt }),
cancelled => JsonSerializer.Serialize(new { Type = "Cancelled", Reason = cancelled.Reason })
);
}

private static OrderStatus Deserialize(string json)
{
var doc = JsonDocument.Parse(json);
var type = doc.RootElement.GetProperty("Type").GetString();

return type switch
{
"Pending" => OrderStatus.Pending(),
"Processing" => OrderStatus.Processing(
doc.RootElement.GetProperty("WorkerId").GetString()!
),
"Shipped" => OrderStatus.Shipped(
doc.RootElement.GetProperty("TrackingNumber").GetString()!
),
"Delivered" => OrderStatus.Delivered(
doc.RootElement.GetProperty("DeliveredAt").GetDateTime()
),
"Cancelled" => OrderStatus.Cancelled(
doc.RootElement.GetProperty("Reason").GetString()!
),
_ => throw new InvalidOperationException($"Unknown type: {type}")
};
}
}

Querying Unions

Basic Queries

// Query by union type
var pendingOrders = await context.Orders
.Where(o => EF.Property<string>(o.Status, "Type") == "Pending")
.ToListAsync();

// Filter in memory after loading
var processingOrders = await context.Orders
.ToListAsync();

var filtered = processingOrders
.Where(o => o.Status.IsProcessing)
.ToList();

Complex Queries

// Count by status type
var statusCounts = await context.Orders
.GroupBy(o => EF.Property<string>(o.Status, "Type"))
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToListAsync();

// Find orders by tracking number (requires JSON query support)
var order = await context.Orders
.Where(o => EF.Functions.JsonValue(
EF.Property<string>(o.Status, "Json"),
"$.TrackingNumber") == trackingNumber)
.FirstOrDefaultAsync();

Storage Strategies

Store entire union as JSON:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property(o => o.Status)
.HasConversion<OrderStatusConverter>()
.HasColumnType("jsonb"); // PostgreSQL
// .HasColumnType("json"); // MySQL
// .HasColumnType("nvarchar(max)"); // SQL Server
}

Separate Columns

Store discriminator and data separately:

public class Order
{
public int Id { get; set; }

// Shadow properties for storage
private string StatusType { get; set; }
private string StatusData { get; set; }

public OrderStatus Status
{
get => DeserializeStatus(StatusType, StatusData);
set
{
(StatusType, StatusData) = SerializeStatus(value);
}
}
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property<string>("StatusType")
.HasMaxLength(50);

modelBuilder.Entity<Order>()
.Property<string>("StatusData")
.HasColumnType("nvarchar(max)");

modelBuilder.Entity<Order>()
.Ignore(o => o.Status);
}

Migration Support

Adding Union Column

public partial class AddOrderStatus : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Status",
table: "Orders",
type: "nvarchar(max)",
nullable: false,
defaultValue: "{\"Type\":\"Pending\"}");
}

protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Status",
table: "Orders");
}
}

Changing Union Structure

// Migration to add new case
public partial class AddCancelledStatus : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// No schema changes needed if using JSON storage
// Old data remains compatible
}
}

Real-World Examples

Order Management

public class Order
{
public int Id { get; set; }
public OrderStatus Status { get; set; }
public List<OrderStatusHistory> StatusHistory { get; set; } = new();

public void UpdateStatus(OrderStatus newStatus)
{
StatusHistory.Add(new OrderStatusHistory
{
Status = Status,
ChangedAt = DateTime.UtcNow
});

Status = newStatus;
}
}

public class OrderStatusHistory
{
public int Id { get; set; }
public OrderStatus Status { get; set; }
public DateTime ChangedAt { get; set; }
}

// Query status history
var order = await context.Orders
.Include(o => o.StatusHistory)
.FirstAsync(o => o.Id == orderId);

var timeline = order.StatusHistory
.Select(h => new
{
Status = h.Status.Match(
pending => "Pending",
processing => $"Processing by {processing.WorkerId}",
shipped => $"Shipped: {shipped.TrackingNumber}",
delivered => $"Delivered at {delivered.DeliveredAt}",
cancelled => $"Cancelled: {cancelled.Reason}"
),
Date = h.ChangedAt
})
.ToList();

User Authentication

[GenerateUnion]
public partial record AuthMethod
{
public static partial AuthMethod Password(string hashedPassword, DateTime lastChanged);
public static partial AuthMethod OAuth(string provider, string externalId);
public static partial AuthMethod TwoFactor(string secret, List<string> backupCodes);
}

public class User
{
public int Id { get; set; }
public string Email { get; set; }
public AuthMethod AuthMethod { get; set; }
}

// Query users by auth method
var oauthUsers = await context.Users
.ToListAsync();

var googleUsers = oauthUsers
.Where(u => u.AuthMethod is AuthMethod.OAuthCase { Provider: "Google" })
.ToList();

Best Practices

Indexing

Create indexes on discriminator for better query performance:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Add index on JSON property (PostgreSQL)
modelBuilder.Entity<Order>()
.HasIndex(o => new { })
.HasMethod("gin")
.HasFilter("Status -> 'Type'");

// Or separate discriminator column
modelBuilder.Entity<Order>()
.Property<string>("StatusType")
.HasMaxLength(50);

modelBuilder.Entity<Order>()
.HasIndex("StatusType");
}

Validation

Validate unions before saving:

public override int SaveChanges()
{
var orders = ChangeTracker.Entries<Order>()
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified)
.Select(e => e.Entity);

foreach (var order in orders)
{
ValidateOrderStatus(order);
}

return base.SaveChanges();
}

private void ValidateOrderStatus(Order order)
{
order.Status.Match(
pending => { /* Always valid */ },
processing => ValidateProcessing(processing),
shipped => ValidateShipped(shipped),
delivered => ValidateDelivered(delivered),
cancelled => ValidateCancelled(cancelled)
);
}

Versioning

Handle schema evolution gracefully:

private static OrderStatus Deserialize(string json)
{
var doc = JsonDocument.Parse(json);
var type = doc.RootElement.GetProperty("Type").GetString();

// Handle old schema versions
if (type == "InTransit") // Old case name
{
return OrderStatus.Shipped(
doc.RootElement.GetProperty("TrackingNumber").GetString()!
);
}

return type switch
{
"Pending" => OrderStatus.Pending(),
"Shipped" => OrderStatus.Shipped(
doc.RootElement.GetProperty("TrackingNumber").GetString()!
),
// ... other cases
};
}

Limitations

Query Limitations

// ❌ Cannot query union case properties directly in SQL
var orders = await context.Orders
.Where(o => o.Status.IsShipped) // Not translatable to SQL
.ToListAsync();

// ✅ Load and filter in memory
var orders = await context.Orders.ToListAsync();
var shippedOrders = orders.Where(o => o.Status.IsShipped).ToList();

// ✅ Or use JSON query functions
var orders = await context.Orders
.Where(o => EF.Property<string>(o.Status, "Type") == "Shipped")
.ToListAsync();

Key Takeaways

Value converters enable union storage
JSON columns provide flexible storage
Query in memory for complex union operations
Index discriminators for better performance
Handle versioning for schema evolution