UnionGenerator.EntityFrameworkCore 0.1.0

dotnet add package UnionGenerator.EntityFrameworkCore --version 0.1.0
                    
NuGet\Install-Package UnionGenerator.EntityFrameworkCore -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="UnionGenerator.EntityFrameworkCore" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="UnionGenerator.EntityFrameworkCore" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="UnionGenerator.EntityFrameworkCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add UnionGenerator.EntityFrameworkCore --version 0.1.0
                    
#r "nuget: UnionGenerator.EntityFrameworkCore, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package UnionGenerator.EntityFrameworkCore@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=UnionGenerator.EntityFrameworkCore&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=UnionGenerator.EntityFrameworkCore&version=0.1.0
                    
Install as a Cake Tool

UnionGenerator.EntityFrameworkCore

Store complex Result union types in databases seamlessly. Automatically serialize/deserialize discriminated unions to JSON columns without manual mapping or boilerplate.

❓ Why This Package?

The Problem

Without JSON serialization, storing union results in databases is awkward:

// ❌ Relational schema with separate tables (over-engineered)
public class Order
{
    public int Id { get; set; }
    public OrderData? SuccessData { get; set; }
    public ErrorInfo? ErrorData { get; set; }
    public bool IsSuccess { get; set; } // Redundant discriminator
    
    // Problem: Nullable hell, must maintain 3 fields, business logic unclear
}

// ❌ Or custom converters (boilerplate + error-prone)
public class CustomResultConverter : ValueConverter<Result<OrderData, ErrorInfo>, string>
{
    // Manual serialization logic...
}

The Solution

// ✅ Single JSON column, automatic conversion
public class Order
{
    public int Id { get; set; }
    public Result<OrderData, ErrorInfo> ProcessingResult { get; set; }
}

// In DbContext:
modelBuilder.Entity<Order>()
    .HasResultConversion(o => o.ProcessingResult);
    
// Database: ProcessingResult column stores {"case":"Ok","value":{...}}
// Entity layer: Work with Result<T,E> naturally

Benefits:

  • Single source of truth: One JSON column instead of nullable parallel fields
  • Automatic conversion: EF handles serialization transparently
  • Type-safe queries: LINQ queries work directly on Result<T,E>
  • Audit-friendly: Full result history (success, failure, error details) in one place

Features

  • 🎯 Value Converters: Store Result<T, E> as JSON columns with automatic serialization
  • 🔄 JSON Format: Compact JSON representation with case discrimination
  • 🎨 Fluent API: Easy configuration with HasResultConversion() extension methods
  • 📋 Null Handling: Proper support for nullable Result properties
  • 🚀 Auto Configuration: Scan and configure all Result properties automatically
  • 🔍 Query Support: LINQ queries with client-side evaluation for complex Result operations

Installation

dotnet add package UnionGenerator.EntityFrameworkCore

Quick Start

1. Define Your Result Union

using UnionGenerator.Attributes;

[GenerateUnion]
public partial class Result<T, E>
{
    public static Result<T, E> Ok(T value) => new OkCase(value);
    public static Result<T, E> Error(E error) => new ErrorCase(error);
}

public record OrderData(string OrderNumber, decimal Total);
public record ErrorInfo(string Code, string Message);

2. Define Your Entity

public class Order
{
    public int Id { get; set; }
    public string CustomerName { get; set; } = string.Empty;
    public Result<OrderData, ErrorInfo> ProcessingResult { get; set; } = null!;
    public DateTime CreatedAt { get; set; }
}

3. Configure DbContext

using Microsoft.EntityFrameworkCore;
using UnionGenerator.EntityFrameworkCore.Extensions;

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

    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Option 1: Configure individual properties
        modelBuilder.Entity<Order>()
            .HasResultConversion<Order, Result<OrderData, ErrorInfo>, OrderData, ErrorInfo>(
                o => o.ProcessingResult
            );

        // Option 2: Auto-configure all Result properties (basic column type only)
        // modelBuilder.ConfigureResultConversions();
    }
}

4. Use in Your Application

// Create and save
var order = new Order
{
    CustomerName = "John Doe",
    ProcessingResult = Result<OrderData, ErrorInfo>.Ok(
        new OrderData("ORD-001", 99.99m)
    ),
    CreatedAt = DateTime.UtcNow
};

await dbContext.Orders.AddAsync(order);
await dbContext.SaveChangesAsync();

// Query
var orders = await dbContext.Orders
    .Where(o => o.CustomerName == "John Doe")
    .ToListAsync();

foreach (var o in orders)
{
    o.ProcessingResult.Match(
        ok: data => Console.WriteLine($"Order {data.OrderNumber}: ${data.Total}"),
        error: err => Console.WriteLine($"Error {err.Code}: {err.Message}")
    );
}

Database Schema

Result properties are stored as JSON strings in the database:

CREATE TABLE Orders (
    Id INT PRIMARY KEY,
    CustomerName NVARCHAR(255),
    ProcessingResult NVARCHAR(MAX), -- JSON: {"case":"Ok","value":{...}}
    CreatedAt DATETIME2
);

JSON Format

Success case:

{
  "case": "Ok",
  "value": {
    "orderNumber": "ORD-001",
    "total": 99.99
  }
}

Error case:

{
  "case": "Error",
  "value": {
    "code": "PAYMENT_FAILED",
    "message": "Payment processing failed"
  }
}

Advanced Usage

Nullable Result Properties

public class Order
{
    public int Id { get; set; }
    public Result<OrderData, ErrorInfo>? OptionalResult { get; set; }
}

// Configure
modelBuilder.Entity<Order>()
    .HasNullableResultConversion<Order, Result<OrderData, ErrorInfo>, OrderData, ErrorInfo>(
        o => o.OptionalResult
    );

Custom JSON Options

using System.Text.Json;
using UnionGenerator.EntityFrameworkCore.Converters;

var jsonOptions = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = false,
    Converters = { new ResultJsonConverter<Result<OrderData, ErrorInfo>, OrderData, ErrorInfo>() }
};

modelBuilder.Entity<Order>()
    .HasResultConversionWithOptions<Order, Result<OrderData, ErrorInfo>, OrderData, ErrorInfo>(
        o => o.ProcessingResult,
        jsonOptions
    );

Querying Result Properties

// Basic queries work as expected
var orders = await dbContext.Orders
    .Where(o => o.CreatedAt > DateTime.UtcNow.AddDays(-7))
    .ToListAsync();

// Filter by Result state (client-side evaluation)
var successfulOrders = orders
    .Where(o => o.ProcessingResult.Match(ok: _ => true, error: _ => false))
    .ToList();

// Note: Querying nested JSON properties requires database-specific functions
// For SQL Server with JSON support:
// var query = dbContext.Orders
//     .FromSqlRaw("SELECT * FROM Orders WHERE JSON_VALUE(ProcessingResult, '$.case') = 'Ok'");

Migrations

When adding Result properties to existing entities, EF Core will generate appropriate migrations:

// dotnet ef migrations add AddProcessingResult

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

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

Handling Existing Data

If you're adding Result properties to entities with existing data, you may need a data migration:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AddColumn<string>(
        name: "ProcessingResult",
        table: "Orders",
        type: "nvarchar(max)",
        nullable: true); // Make nullable initially

    // Migrate existing data to default Result state
    migrationBuilder.Sql(@"
        UPDATE Orders 
        SET ProcessingResult = '{""case"":""Ok"",""value"":{""orderNumber"":"""",""total"":0}}'
        WHERE ProcessingResult IS NULL
    ");

    // Make non-nullable after migration
    migrationBuilder.AlterColumn<string>(
        name: "ProcessingResult",
        table: "Orders",
        type: "nvarchar(max)",
        nullable: false);
}

Best Practices

  1. Use Value Objects: Keep TData and TError types lightweight (records or simple classes)
  2. Index Strategy: Create computed columns or JSON indexes for frequently queried fields
  3. Column Size: Use nvarchar(max) for variable-size JSON; consider fixed size for small Results
  4. Client Evaluation: Complex LINQ queries on Result properties will use client-side evaluation
  5. Null Handling: Use nullable Result properties when the value is truly optional
  6. Migration Testing: Always test migrations on a copy of production data

Performance Considerations

  • Serialization Overhead: JSON serialization adds ~10-50μs per Result depending on size
  • Column Storage: JSON columns use more storage than normalized tables
  • Query Performance: Filtering by JSON properties is slower than indexed columns
  • Recommendation: Use Result for entities where:
    • The data is read more often than queried
    • The structure is flexible and may change
    • The business logic benefits from Result pattern safety

Database Provider Support

Provider Status Notes
SQL Server ✅ Full nvarchar(max) columns, JSON functions available
PostgreSQL ✅ Full text/jsonb columns, excellent JSON support
SQLite ✅ Full text columns, limited JSON functions
MySQL ✅ Full longtext columns, JSON functions available
In-Memory ✅ Full No persistence, useful for testing

Limitations

  • Query Translation: Complex LINQ expressions on Result properties require client evaluation
  • JSON Functions: Database-specific JSON functions (JSON_VALUE, etc.) require raw SQL
  • Index Performance: JSON columns cannot be efficiently indexed (use computed columns)
  • Change Tracking: Full Result replacement required; partial updates not supported

Thread Safety

  • Value converters are stateless and thread-safe
  • JSON converters use System.Text.Json which is thread-safe
  • DbContext is not thread-safe (standard EF Core behavior)

Examples

Audit Trail with Results

public class AuditEntry
{
    public int Id { get; set; }
    public string Action { get; set; } = string.Empty;
    public Result<AuditData, AuditError> Result { get; set; } = null!;
    public DateTime Timestamp { get; set; }
}

// Track success and failure
var auditEntry = new AuditEntry
{
    Action = "UpdateUser",
    Result = userUpdateResult, // Result<AuditData, AuditError>
    Timestamp = DateTime.UtcNow
};

await dbContext.AuditEntries.AddAsync(auditEntry);
await dbContext.SaveChangesAsync();

Saga State Management

public class SagaInstance
{
    public Guid Id { get; set; }
    public string SagaType { get; set; } = string.Empty;
    public Result<SagaState, SagaError>? CurrentState { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? CompletedAt { get; set; }
}

// Query incomplete sagas
var pendingSagas = await dbContext.Sagas
    .Where(s => s.CompletedAt == null)
    .ToListAsync();

API Reference

ResultValueConverter<TResult, TData, TError>

  • Constructor: ResultValueConverter()
  • Constructor: ResultValueConverter(JsonSerializerOptions)
  • Converts Result<T, E> ↔ JSON string

ResultJsonConverter<TResult, TData, TError>

  • Read(ref Utf8JsonReader, Type, JsonSerializerOptions): Deserialize from JSON
  • Write(Utf8JsonWriter, TResult, JsonSerializerOptions): Serialize to JSON

ModelBuilderExtensions

  • HasResultConversion<TEntity, TResult, TData, TError>(): Configure Result property
  • HasNullableResultConversion<TEntity, TResult, TData, TError>(): Configure nullable Result
  • HasResultConversionWithOptions<TEntity, TResult, TData, TError>(): Configure with custom JSON options
  • ConfigureResultConversions(): Auto-configure all Result properties

License

This project is part of UnionGenerator and uses the same license.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0 96 1/21/2026

Initial release: EF Core integration with JSON value converters for union persistence.