I’ve been building software for over a decade, mostly in web development and across pretty much every major framework out there. My experience with C#, though, had always been tied to gamedev—until recently. About eight months ago, I started working with .NET Core for web, and I have to admit: I’m absolutely enchanted. It’s hard to imagine ever going back to Spring, Nest, Laravel, or any other web framework really (.NET Core is hands down the best backend framework in 2025 😍, but that rant deserves its own article). Now, back to our Enum story...
When I have a dropdown with a range of choices in my frontend, I instinctively reach out for the Enum type, as I find it to be the most clean and natural depiction of "selection from predefined values". Thankfully my whole stack supports Enums (Typescript, C#, Postgres) thus what better way to enjoy the "cleanliness" other than including them in my API endpoints and database entities. It feels especially nice when you generate typescript client services from your swagger specs👌.
I setup my endpoint, added a validator for my data transfer object and started postman to do some testing. I was pretty confident that everything would work as it should with this being a relatively simple endpoint, but I was taken by surprise when I started having some trouble with my enums' validation. I spent some time trying to understand what I was doing wrong only to realise there is a flaw in the request pipeline during json deserialization when .Net tries to do model binding. Let's see what the issue is and every possible way to address it.
Middleware Setup and Enum notes
But first let's get some things out of the way before diving into the issue.
I will be using exception middleware to capture the validation exceptions and present them in the way you'll see in the following examples.
All the validation exceptions you will see are thrown by my middleware catching the ValidationException my validators throw. If you don't have the following setup you will get and send full system exceptions with their stacktrace to the client when an unhandled exception occurs🥶. It would also beat the whole point of this article.
public class GlobalExceptionMiddleware(ILogger<GlobalExceptionMiddleware> logger) : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
try
{
await next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex, logger);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception ex, ILogger<GlobalExceptionMiddleware> logger)
{
context.Response.ContentType = "application/json";
switch (ex)
{
case ValidationException validationException:
await HandleValidationExceptionAsync(context, validationException, logger);
break;
default:
await HandleGenericExceptionAsync(context, ex, logger);
break;
}
}
private static async Task HandleValidationExceptionAsync(HttpContext context, ValidationException ex, ILogger<GlobalExceptionMiddleware> logger)
{
var validationErrors = ex.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
context.Response.StatusCode = StatusCodes.Status400BadRequest;
ValidationProblemDetails problemDetails = new(validationErrors)
{
Title = "Validation error",
Status = StatusCodes.Status400BadRequest,
Detail = "One or more validation errors occurred.",
Instance = context.Request.Path,
};
problemDetails.Extensions["traceId"] = context.TraceIdentifier;
logger.LogError("Validation Errors: {ValidationErrors}",problemDetails);
await context.Response.WriteAsJsonAsync(problemDetails);
}
private static async Task HandleGenericExceptionAsync(HttpContext context, Exception ex, ILogger<GlobalExceptionMiddleware> logger)
{
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
ProblemDetails problemDetails = new()
{
Title = "An unexpected error occurred",
Status = StatusCodes.Status500InternalServerError,
Detail = "Internal Server Error",
Instance = context.Request.Path
};
problemDetails.Extensions["traceId"] = context.TraceIdentifier;
logger.LogError(ex, "An unhandled exception occurred. Problem Details: {ProblemDetails}",problemDetails);
await context.Response.WriteAsJsonAsync(problemDetails);
}
In program.cs
builder.Services.AddTransient<GlobalExceptionMiddleware>();
WebApplication app = builder.Build();
app.UseMiddleware<GlobalExceptionMiddleware>();
Configure controllers to accept the string name of the Enum attributes when deserializing
In Program.cs let's add this json converter to the controllers' configuration, so model binding works with non-numerical string values for our Enums.
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
This will now allow the client to send the value "Nicosia" to be bound on the model by the framework.
Non nullable Enums have a default value
We need the Enum to come from the frontend inside the POST request, parse and validate the value and add it to the database. So I declare my Enum in both the Entity and the endpoint's DTO as required and non-nullable.
JobCity City, //in DTO record
public required JobCity City { get; set; } //in DB Entity
public enum JobCity //the enum
{
//Defaults to Nicosia = 0, then the rest automatically follow the numerical sequence unless specifically set
Nicosia,
Limassol
...
}
But non-nullable Enums, since they are represented by numerical values (specifically an int) as their name suggests, they default to 0.
Therefore, if the client somehow sends the request without the Enum present in the payload, your Enum will still exist in the body of the request as the default 0 value, in our case, Nicosia.
Solving the required part without setting to nullable:
Simply setting your first Enum value to 1 will properly throw a validation error if the body does not contain the enum field. This will happen regardless if you are using the default data annotation [Required] in your dto, or a custom validator like FluentValidations, etc.
public enum JobCity
{
// the default value 0 does not exist now
Nicosia = 1,
// this is now 2
Limassol,
...
}
❗The Problem: Model Binding , JSON deserialization and the Request Pipeline 🚧
JSON deserialization occurs before validation can execute, causing unhandled exceptions instead of graceful validation errors when invalid Enum values are provided. This architectural limitation affects API consistency and user experience, but we will look at several solutions that can resolve the issue while maintaining clean validation logic.
The core problem stems from ASP.NET Core's request processing pipeline where JSON input formatters execute before the validation pipeline. When invalid Enum values are encountered, System.Text.Json
(or Newtonsoft.Json
if you are stuck in a legacy app 😬) throws exceptions during deserialization, preventing FluentValidation from ever executing its custom validation rules. (I will be using FV, but it's irrelevant since this happens with other validation libraries, including the default solution from MS DataAnnnotations)
Understanding the pipeline order problem
The ASP.NET Core request processing follows this sequence for API endpoints: HTTP Request → JSON Deserialization → Model Binding → Model Validation → Controller Action. This design choice creates an architectural gap where Enum validation cannot be handled gracefully by validation.
When invalid Enum values are provided in JSON, the model binding process fails. The funny part is that it does produce a validation exception but the validation messages are not customisable and extremely hard to work with. For example, sending {"city": "Nicosiazxca"}
to the endpoint results in the following validation error message. This validation message is not particularly helpful neither to the client or our logs hindering the ability to properly investigate potential issues down the line. To properly parse and handle globally this validation error for all cases of all DTOs and all Enums would require some dirty code gymnastics that I would not feel comfortable having in production (I will still provide a working example even though you should really not do that!)
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"createDto": ["The createDto field is required."],
"$.city": [
"The JSON value could not be converted to Application.JobListings.DTOs.CreateJobListingDto. Path: $.city | LineNumber: 4 | BytePositionInLine: 22."
]
},
"traceId": "00-6360b9de3ac93ac48fe937a290664517-2c7d2b725938231c-00"
}
💡The Solutions 🤓
1. String binding and conversion (valid solution ✅)
An effective solution, possibly the most widely adopted, involves binding enum values as strings and performing validation before conversion. This approach allows us to maintain complete control over the validation process.
Step 1: Let's change our DTO to use a string representation of the enum.
// instead of using the enum type:
public class CreateJobListingDto
{
...
public JobCity City { get; set; } ❌
...
}
// we now use a string type
public class CreateJobListingDto
{
...
public string City { get; set; } ✅
...
}
public enum JobCity
{
Nicosia,
Limassol,
Paphos,
Larnaca,
Famagusta,
Keryneia
}
Step 2: We'll create a FluentValidation rule that validates the string value as a type of the Enum. I created an elegant solution with generics that can be applicable to all enum validators in our app. It's also static, so place it in its own class to be used widely.
public class CreateJobListingValidator : AbstractValidator<CreateJobListingDto>
{
public CreateJobListingValidator()
{
...
RuleFor(j => j.City).NotEmpty()
.Must(BeValidEnum<JobCity>)
.WithMessage($"Job city field is required. Valid values are: {string.Join($", ", Enum.GetValues<JobCity>())}");
...
}
public static bool BeValidEnum<T>(string enumValue) where T : struct, Enum
{
return Enum.TryParse<T>(enumValue, ignoreCase: true, out _);
}
}
Step 3: Convert to enum in our dto mapping after validation passes.
public static JobListing ToEntity(this CreateJobListingDto dto)
{
return new JobListing
{
...
City = Enum.Parse<JobCity>(dto.City, ignoreCase: true),
...
};
}
^ This is safe since the value is already validated in our service when our validator is called and Enum.Parse will 100% work. Here's a simplified view of our service:
public class JobListingService(AppDbContext context, IValidator<CreateJobListingDto> createJobDtoValidator) : IJobListingService
{
public async Task<JobListingDetailDto> CreateJobListingAsync(CreateJobListingDto dto)
{
await createJobDtoValidator.ValidateAndThrowAsync(dto);
JobListing entity = dto.ToEntity();
context.JobListings.Add(entity);
await context.SaveChangesAsync();
return entity.ToDetailDto();
}
...
}
Step 4: Profit 🤑 If we send an invalid city type, we now get a customised validation response and we can also log the error properly in our validation exception middleware.
{
"title": "Validation error",
"status": 400,
"detail": "One or more validation errors occurred.",
"instance": "/api/joblistings",
"errors": {
"City": [
"Job city field is required. Valid values are: Nicosia, Limassol, Paphos, Larnaca, Famagusta, Keryneia"
]
},
"traceId": "0HNF0I6M1FSUP:00000001"
}
This approach provides complete validation control, maintains type safety after validation, and supports EnumMember attributes.
2. Do it like it's 2015. Enum? You mean another database table, right?🚀 (valid solution✅)
Enums? Absolutely ridiculous. The 2010's approach is the ultimate one. Cities is another database table with relationship data to the job listing with all the extra complexity. You only have 6 possible values that will never change and will never require additional data or metadata? Doesn't matter.. TABLE! Just imagine the indexing potential! 📈💰
Well jokes aside, I have nothing to show for this point, you just create another table for any value that has options with an Id and a Name column and manage the appropriate relationships, in this example's case Many(job listings) to One(city).
This approach is preferable and shines if:
1) You have a dataset that has a chance of changing in the future. It's much easier inserting another row in a table and requires 0 downtime, instead of needing to change the existing structure of your table containing the enum.
2) You need additional data for each option apart from just a name, or it has relationships with other entities. In our City example, maybe we needed the population or the mayor of the city for some reason. Or maybe we would like to link cities to companies too and not only to job-listings.
A separate table makes perfect sense for these cases.
3. Custom JSON converter for direct safe enum binding (valid solution, I don't prefer it or find it as clean✅)
If for some reason in your application you require direct enum binding, a custom JSON converter can intercept invalid values during JSON deserialization and return default values, allowing FluentValidation to execute and provide meaningful error messages. What annoys me the most in addition to the verbosity of this approach is that you need to pollute your enum with an "invalid" option.
Note: You'll need to add an explicit zero/default value to your enum and make sure your enum field is NOT nullable:
public enum JobCity
{
Unknown = 0, // we need this so FV can validate as invalid
Nicosia,
Limassol,
Paphos,
Larnaca,
Famagusta,
Keryneia
}
Step 1: Create the converter
public class SafeEnumConverter<TEnum> : JsonConverter<TEnum> where TEnum : struct, Enum
{
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
{
string enumString = reader.GetString();
if (Enum.TryParse<TEnum>(enumString, ignoreCase: true, out TEnum result))
return result;
// return default so FluentValidation can catch it
return default(TEnum);
}
return default(TEnum);
}
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
Step 2: Register it globally in Program.cs. Part of why I dislike this approach is that you would need to register multiple converters for all your enums to be covered.
builder.Services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.Converters.Add(new SafeEnumConverter<JobCity>());
});
Step 3: Add a special validation to catch the default values
public class CreateJobListingValidator : AbstractValidator<CreateJobListingDto>
{
public CreateJobListingValidator()
{
RuleFor(x => x.City)
.IsInEnum()
.NotEqual(default(JobCity)) // catch the default value from converter
.WithMessage($"Job city field is required. Valid values are: {string.Join(", ", Enum.GetNames(typeof(JobCity)).Where(name => (int)Enum.Parse(typeof(JobCity), name) != 0))}"); // i show all the possible values except the zero/invalid value
}
}
4. ⚠️ The dirty hacky way (don't use, seriously)❌
This approach is pure technical debt so please avoid using it in any serious application. I do not know all the inner workings of the framework and all the possible exception messages so this could trigger by something else and send user, support and developers down the wrong rabbit hole. Even if it doesn't match an error today, you can't know it wont in the next .net release. "String magic" is disgusting and should be avoided by all means.🤢 The only reason I am including it its because it gives an interesting view in the ConfigureApiBehaviorOptions configuration and how you can tap inside the model binding part of the pipeline.
.Net allows us to alter the behaviour of most built in processes of the request pipeline (configuration & factory patterns at their finest 🫰), thus we can add a piece of logic on the invalid state "event" of the model while binding and do our own stuff or just let it continue doing its own thing. Here is a hacky way to intercept and parse the default validation error for broken Enum bindings and return it as custom ValidationProblemDetails and do some custom logging
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
})
.ConfigureApiBehaviorOptions(options =>
{
var builtInFactory = options.InvalidModelStateResponseFactory;
options.InvalidModelStateResponseFactory = context =>
{
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILogger<Program>>();
if (!context.ModelState.IsValid)
{
var enumErrors = context.ModelState
.Where(kvp => kvp.Value?.Errors.Any(e =>
e.ErrorMessage.Contains("The JSON value could not be converted", StringComparison.OrdinalIgnoreCase)
&& e.ErrorMessage.Contains("Path: $.", StringComparison.OrdinalIgnoreCase))
== true)
.ToList();
if (enumErrors.Any())
{
var errors = enumErrors.ToDictionary(
err =>
err.Key.StartsWith("$.")
? err.Key.Substring(2)
: err.Key,
err => new[] { "The provided value is not a valid enum value." }
);
ValidationProblemDetails problemDetails = new(errors)
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1",
Title = "One or more validation errors occurred.",
Status = StatusCodes.Status400BadRequest,
Extensions =
{
["traceId"] = context.HttpContext.TraceIdentifier
}
};
logger.LogError("Failed at model binding. {ValidationErrors}",problemDetails);
return new BadRequestObjectResult(problemDetails);
}
}
return builtInFactory(context);
};
});
Conclusion
I consider string binding with FluentValidation as the most universal approach. This solution provides complete control over validation logic and error handling, and you can keep your swagger Enum options with very little additional effort.
Going with an extra options database table is another great solution and depending on your needs it might as well be the best solution. For example, if I wanted my City to have extra data like population, or relationships with other entities this would become pretty much the only valid solution.
For applications requiring direct Enum binding, custom JSON converters offer an alternative that prevents deserialization exceptions while enabling FluentValidation to execute. I am against this approach as it is way more verbose, prone to errors and feels hackish to me since you need to pollute your enums.
Approach | Pros | Cons | When to use |
---|---|---|---|
String binding | Full control, custom errors, swagger works | Requires mapping, Slight performance overhead | Static dataset |
DB table | Flexible, relational | Overhead, not worth it for static values | Dynamic dataset |
Custom JSON converter | Keeps enums, FV still works | Verbose, pollutes enums | Strict enum binding required |
Dirty hack | Quick, flexible | Fragile, tech debt | Never 😅 |
If you made it this far I am very happy and thankful for your time ❤️ I hope I offered something of value to you, have an awesome day and happy coding.
Top comments (0)