Smart Enums Integration with Frameworks and Libraries - PawelGerr/Thinktecture.Runtime.Extensions GitHub Wiki

Smart Enums in .NET: Integration with Frameworks and Libraries

Article series

  1. Smart Enums: Beyond Traditional Enumerations in .NET
  2. Smart Enums: Adding Domain Logic to Enumerations in .NET
  3. Smart Enums in .NET: Integration with Frameworks and Libraries ⬅

Introduction

In the first article of this series, we introduced Smart Enums as a robust alternative to traditional C# enums, offering type safety and the ability to encapsulate data. The second article explored how Smart Enums can directly embed domain logic, leading to more cohesive and maintainable code.

However, defining rich domain objects is only part of the story. For these objects to be truly useful, they need to integrate smoothly with the surrounding application infrastructure – serialization libraries, web frameworks, and data access layers. A common hurdle teams face when adopting richer domain models is ensuring they work seamlessly with these essential components. How do we serialize Smart Enums to JSON? How do we bind them in ASP.NET Core? How do we persist them using Entity Framework Core?

This article focuses on the practical aspects of integrating Smart Enums created with Thinktecture.Runtime.Extensions into common .NET frameworks and libraries. We'll cover configuration options and provide examples for JSON serialization, ASP.NET Core model binding, OpenAPI/Swagger generation, and Entity Framework Core persistence.

Framework Integration Challenges

Imagine a team developing an e-commerce system. They've embraced Smart Enums for concepts like ProductType (Electronics, Books, etc.). While this improves their domain model's clarity and type safety, they soon encounter integration questions:

  • How should ProductType.Electronics be represented in the JSON responses of their Web API?
  • When a user selects a ProductType in the frontend and sends it to the backend, how does ASP.NET Core correctly bind the incoming string "Electronics" back to the ProductType.Electronics instance?
  • How should Smart Enums be represented in the OpenAPI/Swagger documentation? When frontend developers or API consumers examine the API specification, they need to understand what values are valid for ProductType parameters.
  • How should the ProductType be stored in the database using Entity Framework Core? Storing the full object isn't feasible; it needs to be converted to a primitive type like a string or int.

These integration points are crucial. Without proper handling, the benefits of a rich domain model can be undermined by cumbersome mapping layers or inconsistent data representation. Fortunately, Thinktecture.Runtime.Extensions provides built-in support to make these integrations straightforward.

JSON Serialization

Smart Enums, by default, serialize using their underlying key value. For a ProductType defined as [SmartEnum<string>] with an item ProductType Electronics = new("Electronics"), the default JSON representation would be the string "Electronics".

Thinktecture.Runtime.Extensions offers dedicated packages to streamline JSON serialization with the two most popular .NET libraries: System.Text.Json and Newtonsoft.Json.

There are two main ways to enable the provided converters:

Option 1: Project Dependency (Recommended)

One straightforward approach is to add the relevant NuGet package (Thinktecture.Runtime.Extensions.Json or Thinktecture.Runtime.Extensions.Newtonsoft.Json) as a dependency to the project where your Smart Enums are defined. The presence of the package triggers the source generator to automatically add a JsonConverterAttribute to your Smart Enum classes, pointing to the appropriate library-provided converter.

<!-- Example for System.Text.Json -->
<ItemGroup>
  <PackageReference Include="Thinktecture.Runtime.Extensions.Json" Version="x.y.z" />
</ItemGroup>

With this approach, zero configuration is required in your serialization setup. The Smart Enums become serializable out-of-the-box.

// Assuming ProductType is defined in a project referencing Thinktecture.Runtime.Extensions.Json
var productType = ProductType.Electronics;

string json = System.Text.Json.JsonSerializer.Serialize(productType); // "Electronics"
ProductType? deserialized = System.Text.Json.JsonSerializer.Deserialize<ProductType>(json);

Option 2: Registration of JsonConverterFactory

If adding a dependency to your domain project is not desirable, you can install the package in your application host project and manually register the converter factory (ThinktectureJsonConverterFactory or ThinktectureNewtonsoftJsonConverterFactory) with the serializer settings.

// Registration with ASP.NET Core MVC
builder.Services.AddControllers()
        .AddJsonOptions(options =>
            options.JsonSerializerOptions.Converters.Add(new ThinktectureJsonConverterFactory()));

// Registration with Minimal APIs
builder.Services.ConfigureHttpJsonOptions(options =>
    options.SerializerOptions.Converters.Add(new ThinktectureJsonConverterFactory()));

This approach keeps your domain project free from serialization-specific dependencies but requires explicit configuration.

ASP.NET Core Model Binding

ASP.NET Core needs to convert incoming request data from route and query strings into your action method parameters, including Smart Enum types. Depending on the chosen approach (MVC or Minimal API), the conversion is performed differently.

ASP.NET Core MVC (Controllers)

MVC offers sophisticated model binding. To enable seamless binding of Smart Enums, install the package Thinktecture.Runtime.Extensions.AspNetCore and register the ThinktectureModelBinderProvider.

<ItemGroup>
  <PackageReference Include="Thinktecture.Runtime.Extensions.AspNetCore" Version="x.y.z" />
</ItemGroup>

⚠️ Insert the provider at the beginning to ensure it handles Smart Enums before default binders attempt to process them.

services.AddControllers(options =>
{
    // Insert the provider at the beginning of the list
    options.ModelBinderProviders.Insert(0, new ThinktectureModelBinderProvider());
});

Now you can use Smart Enums directly in controller actions:

[Route("api/products")]
[ApiController]
public class ProductController : Controller
{
    // Binding from route parameter, e.g., /api/products/electronics
    [HttpGet("{type}")]
    public IActionResult GetProductsByType(ProductType type)
    {
        // 'type' is automatically bound to ProductType.Electronics

        // find products ...
        return Ok(products);
    }
}

The ThinktectureModelBinderProvider integrates with ASP.NET Core's validation system. If an unknown value is provided (e.g., /api/products/foo), the model state will become invalid, typically resulting in an automatic 400 Bad Request response when using the [ApiController] attribute.

Minimal APIs

Minimal APIs rely on the interface IParsable<T> for parameter binding. Smart Enums generated by Thinktecture.Runtime.Extensions automatically implement IParsable<TSelf> if their key type is parsable (like int, Guid, etc.). This means they often work out-of-the-box with Minimal APIs for route or query parameters.

// ASP.NET Core uses ProductType.TryParse() to bind the route parameter
app.MapGet("/api/products/{type}", (ProductType type) => {
    // find products ...
    return Results.Ok(products);
});

⚠️ Minimal API binding has limited support for returning detailed validation errors from TryParse. If binding fails, it typically results in a generic response 400 Bad Request. One approach is to use maybe-pattern proposed in another article about integration of value objects.

OpenAPI/Swagger

When building Web APIs with Smart Enums, proper API documentation becomes crucial for frontend developers and API consumers. They need to understand what values are valid for Smart Enum parameters and how these enums should be represented in requests and responses.

Thinktecture.Runtime.Extensions provides seamless integration with Swashbuckle (a popular OpenAPI implementation for .NET) through the Thinktecture.Runtime.Extensions.Swashbuckle package. Use the method AddThinktectureOpenApiFilters to enable support for Smart Enums:

services.AddSwaggerGen()
        .AddThinktectureOpenApiFilters();

Furthermore, you can customize how Smart Enums are represented in the OpenAPI schema:

services.AddThinktectureOpenApiFilters(options =>
{
    // Configure the way the Smart Enums are represented in OpenAPI
    options.SmartEnumSchemaFilter = SmartEnumSchemaFilter.Default;

    // Configure additional schema extensions
    options.SmartEnumSchemaExtension = SmartEnumSchemaExtension.VarNamesFromStringRepresentation;
});

Available SmartEnumSchemaFilter are:

  • Default: Uses standard enum representation, i.e. enum: ["Electronics", ...]
  • OneOf: Uses oneOf pattern: oneOf: [{"title": "Electronics", "const": "Electronics"} ...]
  • AnyOf: Uses anyOf pattern
  • AllOf: Uses allOf pattern
  • FromDependencyInjection: Custom implementation of ISmartEnumSchemaFilter provided via dependency injection

The SmartEnumSchemaExtension adds helpful metadata:

  • None: No additional metadata (default)
  • VarNamesFromStringRepresentation: Adds x-enum-varnames using string representation
  • VarNamesFromDotnetIdentifiers: Adds x-enum-varnames using .NET field names
  • FromDependencyInjection: Uses implementation of ISmartEnumSchemaExtension from dependency injection

With default configuration the schema for ProductType will be as follows:

"schemas": {
  "ProductType": {
    "enum": [
      "Books",
      "Electronics",
      ...
    ],
    "type": "string"
  }
}

Entity Framework Core

Persisting Smart Enums with EF Core requires converting them to and from a database-compatible primitive type (the key type). The mechanism for this are EF Core's Value Converters. Thinktecture.Runtime.Extensions provides packages to simplify this setup for different EF Core versions (v7+).

Choose the package matching your EF Core version: Thinktecture.Runtime.Extensions.EntityFrameworkCore{Version}

<!-- Example for EF Core 9 -->
<ItemGroup>
  <PackageReference Include="Thinktecture.Runtime.Extensions.EntityFrameworkCore9"
                    Version="x.y.z" />
</ItemGroup>

You can register converters globally when configuring the DbContextOptions or inside OnModelCreating.

// via DbContextOptionsBuilder
builder.Services.AddDbContext<MyDbContext>(options => options.UseThinktectureValueConverters());

// Alternatively, via ModelBuilder
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.UseThinktectureValueConverters();
}

The latter approach can also be applied selectively to entities, owned types, or (complex) properties.

Once configured, you can use Smart Enums in your entities and queries naturally:

public class Product
{
    public Guid Id { get; set; }
    public ProductType Type { get; set; } // Persisted via its key, i.e. as a string
    // other properties...
}

// Querying using Smart Enum
var products = await dbContext.Products
    .Where(p => p.Type == ProductType.Electronics)
    .ToListAsync();

// Saving changes
product.Type = ProductType.Books;
await dbContext.SaveChangesAsync();

Integration with Keyless Smart Enums

Keyless Smart Enums (defined with [SmartEnum]) present unique integration challenges because they lack a key value for serialization or persistence. The default library converters don't support keyless Smart Enums.

📝 In general, if you need serialization, persistence, or API integration, using a keyed Smart Enum ([SmartEnum<TKey>]) is often more straightforward than working around the limitations of keyless ones in these contexts. Reserve keyless Smart Enums for cases where instances are primarily used internally within the application logic and don't cross infrastructure boundaries. If you must use a keyless Smart Enum, integration is still possible using the [ObjectFactory<T>] attribute as described below.

Custom Type Conversion

While keyed Smart Enums serialize to their underlying key by default (e.g., string or int), you can define a custom representation for framework integrations using the [ObjectFactory<T>] attribute. This allows you to control how a Smart Enum is converted to and from another type—typically a string—which then seamlessly integrates with JSON serialization, ASP.NET Core model binding, OpenAPI documentation, and Entity Framework Core persistence.

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

[SmartEnum<int>]
[ObjectFactory<string>(
   UseForSerialization = SerializationFrameworks.All, // JSON, MessagePack
   UseForModelBinding = true,                         // Model Binding, OpenAPI
   UseWithEntityFramework = true)]                    // Entity Framework Core
public partial class FileType
{
    public static readonly FileType Document = new(1, "Document");
    public static readonly FileType Image = new(2, "Image");
    public static readonly FileType Video = new(3, "Video");

    public string Name { get; }

    // Required for deserialization; expects the name of the file type
    public static ValidationError? Validate(
        string? value, // e.g. "Document"
        IFormatProvider? provider,
        out FileType? item)
    {
        item = null;

        if (value is null)
            return null;

        // Find item by name
        item = Items.FirstOrDefault(i => i.Name.Equals(value, StringComparison.OrdinalIgnoreCase));

        if (item is null)
            return new ValidationError($"Unknown file type '{value}'.");

        return null;
    }

    // Required for serialization
    public string ToValue() => Name; // e.g. "Document"
}

With the [ObjectFactory<T>] attribute, you define the custom conversion logic for a Smart Enum. 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 the ValueObjectModelBinderProvider for ASP.NET Core, configure Swashbuckle filters for OpenAPI, or call UseValueObjectValueConverter for Entity Framework Core. The UseFor... properties on the attribute simply instruct the library on how to handle the Smart Enum once the frameworks are configured.

Summary

Integrating Smart Enums with common .NET frameworks is crucial for leveraging their benefits in real-world applications. Thinktecture.Runtime.Extensions provides dedicated packages and mechanisms to streamline this process. For JSON serialization, you can use the respective packages which enable automatic converter generation when referenced directly, or allow manual registration of the provided factories. In ASP.NET Core, the corresponding package offers the ThinktectureModelBinderProvider for seamless MVC model binding, while Minimal APIs often support Smart Enums with parsable key types directly. For persistence with Entity Framework Core, the corresponding packages allow easy configuration of value converters using methods like UseThinktectureValueConverters() on the ModelBuilder or DbContextOptionsBuilder.

⚠️ **GitHub.com Fallback** ⚠️